diff --git a/.github/workflows/browser-demos-ci.yml b/.github/workflows/browser-demos-ci.yml index 6b1a84e2f6..b2c0c23683 100644 --- a/.github/workflows/browser-demos-ci.yml +++ b/.github/workflows/browser-demos-ci.yml @@ -61,7 +61,7 @@ jobs: run: | npx playwright test \ test/coi.spec.ts \ - test/browser-kernel-lazy-registration.spec.ts \ + test/package-deferred-tree-browser.spec.ts \ test/wasm-trap-signal.spec.ts \ --project=chromium \ --project=firefox \ diff --git a/.github/workflows/homebrew-main-shell-ci.yml b/.github/workflows/homebrew-main-shell-ci.yml index f50c759c2c..e2d8197c21 100644 --- a/.github/workflows/homebrew-main-shell-ci.yml +++ b/.github/workflows/homebrew-main-shell-ci.yml @@ -217,12 +217,91 @@ jobs: npx playwright install chromium --with-deps ) - - name: Resolve current direct browser bundling inputs and build the exact candidate kernel + - name: Select one verified package generation + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail abi=$(sed -nE 's/^pub const ABI_VERSION: u32 = ([0-9]+);$/\1/p' \ crates/shared/src/lib.rs) - export WASM_POSIX_BINARY_INDEX_URL="https://github.com/Automattic/kandelo/releases/download/binaries-abi-v${abi}/index.toml" + [[ "$abi" =~ ^[0-9]+$ ]] + canonical_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/binaries-abi-v${abi}/index.toml" + selected_url="$canonical_url" + + if [ "$GITHUB_EVENT_NAME" = pull_request ]; then + pr_number=$(jq -er '.pull_request.number' "$GITHUB_EVENT_PATH") + [[ "$pr_number" =~ ^[1-9][0-9]*$ ]] + target_tag="pr-${pr_number}-staging" + expected="$RUNNER_TEMP/homebrew-main-shell-staging-expected.json" + snapshot="$RUNNER_TEMP/homebrew-main-shell-staging-snapshot" + + # WHY: an ABI bump has no canonical package release yet. Reuse the + # parallel staging build only after proving it is a complete, + # exact-current generation; accepting a partial mutable index would + # mix packages from different builds or silently source-build gaps. + if bash scripts/dev-shell.sh env \ + EXPECTED="$expected" \ + SNAPSHOT="$snapshot" \ + TARGET_TAG="$target_tag" \ + ABI="$abi" \ + bash -c ' + set -euo pipefail + host_target=$(rustc -vV | awk "/^host/ {print \$2}") + cargo build --release -p xtask --target "$host_target" + xtask="target/$host_target/release/xtask" + "$xtask" staging-reuse expected \ + --registry packages/registry \ + --expected-abi "$ABI" \ + --exclude erlang-vfs,perl,perl-vfs,python-vfs,redis,texlive \ + --output "$EXPECTED" + bash .github/scripts/validate-staging-release.sh \ + --tag "$TARGET_TAG" \ + --expected-ledger "$EXPECTED" \ + --mode current \ + --output-dir "$SNAPSHOT" \ + --xtask "$xtask" + '; then + frozen_index="$snapshot/frozen-index.toml" + target_index_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${target_tag}/index.toml" + # WHY: index freezing consumes only the bytes already validated + # above. Remove network credentials so this pure transformation + # cannot accidentally grow an authenticated code path. + env -u GH_TOKEN -u GITHUB_TOKEN \ + -u HOMEBREW_GITHUB_API_TOKEN \ + -u HOMEBREW_GITHUB_PACKAGES_TOKEN \ + bash scripts/dev-shell.sh env \ + SOURCE_INDEX="$snapshot/source-index.toml" \ + FROZEN_INDEX="$frozen_index" \ + TARGET_INDEX_URL="$target_index_url" \ + ABI="$abi" \ + bash -c ' + set -euo pipefail + host_target=$(rustc -vV | awk "/^host/ {print \$2}") + xtask="target/$host_target/release/xtask" + # WHY: the PR release tag remains mutable. Keep the validated + # index bytes local while rewriting relative archive names to + # their exact release URLs; archive hashes still fail closed. + "$xtask" index-candidate seed \ + --canonical-index "$SOURCE_INDEX" \ + --candidate-index "$FROZEN_INDEX" \ + --canonical-index-url "$TARGET_INDEX_URL" \ + --expected-abi "$ABI" \ + --generated-at "1970-01-01T00:00:00Z" \ + --generator "Homebrew main-shell frozen staging generation" + ' + selected_url="file://${frozen_index}" + echo "Using complete verified package generation from $target_tag" + else + echo "Complete current $target_tag generation is unavailable; using canonical/source fallback" + fi + fi + + echo "WASM_POSIX_BINARY_INDEX_URL=$selected_url" >> "$GITHUB_ENV" + + - name: Resolve current direct browser bundling inputs and build the exact candidate kernel + run: | + set -euo pipefail + test -n "${WASM_POSIX_BINARY_INDEX_URL:-}" # Vite resolves static @binaries imports across the wider demo app. # Derive those registry roots from the imports and package manifests # so adding an import cannot silently leave this browser proof stale. diff --git a/.github/workflows/reusable-homebrew-bottle-publish.yml b/.github/workflows/reusable-homebrew-bottle-publish.yml index c82a59cfc2..e51a611d88 100644 --- a/.github/workflows/reusable-homebrew-bottle-publish.yml +++ b/.github/workflows/reusable-homebrew-bottle-publish.yml @@ -96,6 +96,27 @@ jobs: return 2 } + normalize_write_kandelo_ref() { + local ref="$1" + + if [ "$ref" = "main" ]; then + printf 'refs/heads/main\n' + return 0 + fi + if [[ "$ref" =~ ^[0-9a-f]{40}$ ]]; then + # WHY: a new ABI needs bottles before its bottle-backed shell can + # validate and merge. The protected tap caller must hardcode this + # reviewed SHA, and the Kandelo merge must preserve it as an + # ancestor of main; accepting a branch would make executable + # publication input mutable between review and checkout. + printf '%s\n' "$ref" + return 0 + fi + + echo "::error::write publication requires Kandelo main or an exact reviewed lowercase 40-character commit SHA" >&2 + return 2 + } + normalized_caller_repository="$(printf '%s' "$CALLER_REPOSITORY" | tr '[:upper:]' '[:lower:]')" normalized_tap_repository="$(printf '%s' "$TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" normalized_tap_name="$(printf '%s' "$TAP_NAME" | tr '[:upper:]' '[:lower:]')" @@ -134,13 +155,10 @@ jobs: "$CALLER_REPOSITORY/.github/workflows/maintain-bottles.yml@refs/heads/main") ;; *) echo "::error::publication requires a reviewed tap write workflow"; exit 2 ;; esac - [ "$KANDELO_REF" = "main" ] || { - echo "::error::write publication requires Kandelo main"; exit 2; - } [ "$TAP_REF" = "main" ] || { echo "::error::write publication requires tap main"; exit 2; } - validated_kandelo_ref="refs/heads/main" + validated_kandelo_ref="$(normalize_write_kandelo_ref "$KANDELO_REF")" validated_tap_ref="refs/heads/main" fi { diff --git a/Cargo.lock b/Cargo.lock index db13ab33cc..dece0e0c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,7 @@ dependencies = [ "anyhow", "clap", "walrus", + "wasm-posix-shared", "wasmparser 0.247.0", "wasmprinter", "wat", diff --git a/abi/snapshot.json b/abi/snapshot.json index b19c20077d..3f84edf3e8 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1,5 +1,5 @@ { - "abi_version": 41, + "abi_version": 42, "channel_buffers": { "data_offset": 72, "data_size": 65536, @@ -88,6 +88,7 @@ } ], "custom_sections": [ + "kandelo.wpk_fork.linked_frames", "wasm-posix-abi" ], "export_deny": { @@ -113,7 +114,7 @@ }, "host_adapter": { "manifest": { - "abi_version": 41, + "abi_version": 42, "channel_data_offset": 72, "channel_data_size": 65536, "channel_header_size": 72, @@ -196,6 +197,10 @@ "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", @@ -203,12 +208,20 @@ "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" ], "required_worker_features": 7, @@ -389,22 +402,17 @@ { "kind": "func", "name": "kernel_create_process", - "signature": "(i32) -> (i32)" + "signature": "() -> (i32)" }, { "kind": "func", "name": "kernel_create_process_with_stdio", - "signature": "(i32,i32,i32,i32) -> (i32)" - }, - { - "kind": "func", - "name": "kernel_deliver_signal", - "signature": "(i32) -> (i32)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", "name": "kernel_dequeue_signal", - "signature": "(i32,i32) -> (i32)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -471,11 +479,6 @@ "name": "kernel_exec_prepare", "signature": "(i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_exec_setup", - "signature": "(i32) -> (i32)" - }, { "kind": "func", "name": "kernel_exec_setup_for_thread", @@ -561,11 +564,6 @@ "name": "kernel_flock", "signature": "(i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_fork", - "signature": "() -> (i32)" - }, { "kind": "func", "name": "kernel_fork_process", @@ -616,11 +614,6 @@ "name": "kernel_get_cwd", "signature": "(i32,i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_get_exec_state", - "signature": "(i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_get_exit_status", @@ -876,21 +869,6 @@ "name": "kernel_host_adapter_manifest_ptr", "signature": "() -> (i32)" }, - { - "kind": "func", - "name": "kernel_init", - "signature": "(i32) -> ()" - }, - { - "kind": "func", - "name": "kernel_init_from_exec", - "signature": "(i32,i32,i32) -> (i32)" - }, - { - "kind": "func", - "name": "kernel_init_from_fork", - "signature": "(i32,i32,i32) -> (i32)" - }, { "kind": "func", "name": "kernel_inject_connection", @@ -926,11 +904,31 @@ "name": "kernel_ipc_shmat", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shmat_for_process", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_ipc_shmat_for_task", + "signature": "(i32,i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_ipc_shmdt", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_ipc_shmdt_for_process", + "signature": "(i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_ipc_shmdt_for_task", + "signature": "(i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_is_fd_nonblock", @@ -1164,7 +1162,7 @@ { "kind": "func", "name": "kernel_prepare_write_operation", - "signature": "(i32,i32,i64,i32,i32) -> (i64)" + "signature": "(i32,i32,i32,i64,i32,i32) -> (i64)" }, { "kind": "func", @@ -1301,11 +1299,6 @@ "name": "kernel_reserve_host_region_at", "signature": "(i32,i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_reset_signal_mask", - "signature": "(i32) -> (i32)" - }, { "kind": "func", "name": "kernel_rewinddir", @@ -1366,20 +1359,10 @@ "name": "kernel_set_brk_limit", "signature": "(i32,i32) -> (i32)" }, - { - "kind": "func", - "name": "kernel_set_child_pid", - "signature": "(i32) -> ()" - }, - { - "kind": "func", - "name": "kernel_set_current_pid", - "signature": "(i32) -> ()" - }, { "kind": "func", "name": "kernel_set_current_tid", - "signature": "(i32) -> ()" + "signature": "(i32,i32) -> (i32)" }, { "kind": "func", @@ -1539,7 +1522,7 @@ { "kind": "func", "name": "kernel_spawn_process", - "signature": "(i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -1691,6 +1674,11 @@ "name": "kernel_utimensat", "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_validate_task", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_vblank", @@ -1704,7 +1692,7 @@ { "kind": "func", "name": "kernel_wait_child_poll", - "signature": "(i32,i32,i32,i32,i32) -> (i32)" + "signature": "(i32,i32,i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -3227,6 +3215,130 @@ }, "wasm_page_size": 65536 }, + "program_artifact": { + "fork_instrumentation": { + "linked_frame_descriptor": { + "alignment": 8, + "descriptor_size": 24, + "flags": [ + { + "bit": 2, + "name": "abort_unwinding" + }, + { + "bit": 1, + "name": "transactional_nodes" + } + ], + "magic_bytes": [ + 75, + 76, + 67, + 70 + ], + "pointer_widths": [ + { + "bytes": 4, + "chunk_header_size": 32, + "node_header_size": 24 + }, + { + "bytes": 8, + "chunk_header_size": 56, + "node_header_size": 32 + } + ], + "required_flags": 3, + "section": "kandelo.wpk_fork.linked_frames", + "version": 1 + }, + "required_exports": [ + { + "kind": "func", + "name": "wpk_fork_abort_begin", + "params": [ + "ptr" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_abort_end", + "params": [], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_rewind_begin", + "params": [ + "ptr" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_rewind_end", + "params": [], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_state", + "params": [], + "results": [ + "i32" + ] + }, + { + "kind": "func", + "name": "wpk_fork_unwind_begin", + "params": [ + "ptr" + ], + "results": [] + }, + { + "kind": "func", + "name": "wpk_fork_unwind_end", + "params": [], + "results": [] + } + ], + "required_imports": [ + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_frame_commit", + "params": [ + "ptr" + ], + "results": [] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_frame_next", + "params": [ + "ptr" + ], + "results": [ + "ptr" + ] + }, + { + "kind": "func", + "module": "env", + "name": "__wpk_fork_frame_reserve", + "params": [ + "ptr" + ], + "results": [ + "ptr" + ] + } + ] + } + }, "syscall_arg_descriptors": { "1": [ { diff --git a/apps/browser-demos/lib/init/index.ts b/apps/browser-demos/lib/init/index.ts index 45d42b07ef..b6951d4269 100644 --- a/apps/browser-demos/lib/init/index.ts +++ b/apps/browser-demos/lib/init/index.ts @@ -3,7 +3,7 @@ * * Helpers shared across browser surfaces for VFS image construction and worker-side * setup. The legacy SystemInit orchestrator was removed once all demos - * migrated to dinit-as-PID-1 (see scripts/dinit-image-helpers.ts). + * migrated to dinit as the first user process (see dinit-image-helpers.ts). */ // Terminal panel UI component diff --git a/apps/browser-demos/lib/pty-terminal.ts b/apps/browser-demos/lib/pty-terminal.ts index 06ab0c724d..118b527b6d 100644 --- a/apps/browser-demos/lib/pty-terminal.ts +++ b/apps/browser-demos/lib/pty-terminal.ts @@ -114,8 +114,8 @@ export class PtyTerminal { * connect xterm.js I/O. Same as {@link BrowserKernel.boot} but with PTY * forced on. Returns a promise that resolves with the exit code. * - * The kernel worker is the source of truth for the pid; we wait for the - * spawn round-trip to know it. PTY output that arrives before the + * The Rust ProcessTable is the PID authority; the worker returns its + * allocation over the spawn round-trip. PTY output that arrives before the * onPtyOutput handler is registered is buffered in BrowserKernel and * drained when the handler attaches. */ diff --git a/apps/browser-demos/pages/mariadb-test/main.ts b/apps/browser-demos/pages/mariadb-test/main.ts index 057af50153..6537c2b365 100644 --- a/apps/browser-demos/pages/mariadb-test/main.ts +++ b/apps/browser-demos/pages/mariadb-test/main.ts @@ -1,6 +1,6 @@ /** * MariaDB mysql-test browser runner — boots a service-demo VFS image - * with dinit (PID 1) bringing up: + * with dinit bringing up: * * mariadb-bootstrap (scripted, oneshot) → mariadb (process) * @@ -9,11 +9,11 @@ * invocation is a transient kernel.spawn() of mysqltest.wasm — those * processes are not part of the dinit service tree. * - * Process layout once boot completes: - * pid 1: dinit (--container) - * pid 100+: mariadb-bootstrap (exits cleanly after SQL drained) - * pid 100+: mariadb (daemon, port 3306) - * pid 100+: mysqltest (transient, one per __runMariadbTest call) + * Process layout once boot completes (all IDs are kernel-assigned): + * dinit (--container) + * mariadb-bootstrap (exits cleanly after SQL drained) + * mariadb (daemon, port 3306) + * mysqltest (transient, one per __runMariadbTest call) */ import { BrowserKernel } from "@host/browser-kernel-host"; import kernelWasmUrl from "@kernel-wasm?url"; diff --git a/apps/browser-demos/pages/network/network-demo-worker.ts b/apps/browser-demos/pages/network/network-demo-worker.ts index e9be953c83..3bff3bf8b6 100644 --- a/apps/browser-demos/pages/network/network-demo-worker.ts +++ b/apps/browser-demos/pages/network/network-demo-worker.ts @@ -149,7 +149,7 @@ async function runProgram( ): Promise { const workers = new Map(); const ptrWidth = detectPtrWidth(options.programBytes); - const pid = 100; + let pid = 0; let stdout = ""; let stderr = ""; let settled = false; @@ -212,10 +212,10 @@ async function runProgram( growToMax(memory, ptrWidth, 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); + pid = kernelWorker.createProcess(CAPTURED_STDIO); kernelWorker.registerProcess(pid, memory, [channelOffset], { argv: options.argv, ptrWidth, - stdio: CAPTURED_STDIO, }); const initialHeapBase = extractHeapBase(options.programBytes); if (initialHeapBase !== null) kernelWorker.setBrkBase(pid, initialHeapBase); @@ -226,7 +226,6 @@ async function runProgram( const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes: options.programBytes, memory, channelOffset, diff --git a/apps/browser-demos/test/closed-lazy-asset-sources-browser.spec.ts b/apps/browser-demos/test/closed-lazy-asset-sources-browser.spec.ts new file mode 100644 index 0000000000..8b4be8bac2 --- /dev/null +++ b/apps/browser-demos/test/closed-lazy-asset-sources-browser.spec.ts @@ -0,0 +1,268 @@ +import { expect, test } from "@playwright/test"; +import { createHash } from "node:crypto"; +import { createServer, type ServerResponse } from "node:http"; +import type { AddressInfo, Socket } from "node:net"; +import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; + +const modulePath = fileURLToPath( + new URL("../../../host/src/vfs/closed-lazy-assets.ts", import.meta.url), +); + +test("Chromium verifies and closes native lazy-asset transports", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the transport contract targets Chromium"); + expect(baseURL).toBeTruthy(); + + const viteModuleUrl = new URL(`/@fs/${modulePath}`, baseURL!).href; + const viteModuleResponse = await fetch(viteModuleUrl); + const viteModuleSource = await viteModuleResponse.text(); + expect( + viteModuleResponse.ok, + `${viteModuleResponse.status} ${viteModuleResponse.url}: ` + + viteModuleSource.slice(0, 500), + ).toBe(true); + + const decodedPayload = Buffer.from("lazy Homebrew bottle bytes\n".repeat(512)); + const encodedPayload = gzipSync(decodedPayload); + const state = { + cookieProbe: "", + gzipCookie: "", + gzipReferer: "", + redirectTargetHits: 0, + overflowClosed: false, + overflowFinished: false, + slowClosed: false, + slowFinished: false, + streamErrorClosed: false, + }; + const sockets = new Set(); + const streamingResponses = new Set(); + const trackStreamingResponse = ( + response: ServerResponse, + kind: "overflow" | "slow" | "stream-error", + ): void => { + streamingResponses.add(response); + response.once("close", () => { + streamingResponses.delete(response); + if (kind === "overflow") state.overflowClosed = true; + if (kind === "slow") state.slowClosed = true; + if (kind === "stream-error") state.streamErrorClosed = true; + }); + response.once("finish", () => { + if (kind === "overflow") state.overflowFinished = true; + if (kind === "slow") state.slowFinished = true; + }); + }; + + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", "http://127.0.0.1"); + switch (url.pathname) { + case "/": + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "set-cookie": "closed-source-session=present; Path=/; SameSite=Lax", + }); + response.end("closed lazy source transport"); + return; + case "/cookie-probe": + state.cookieProbe = request.headers.cookie ?? ""; + response.writeHead(200, { "content-type": "text/plain" }); + response.end("cookie observed"); + return; + case "/closed-lazy-assets.ts": + response.writeHead(200, { + "content-type": "application/javascript; charset=utf-8", + }); + response.end(viteModuleSource); + return; + case "/gzip": + state.gzipCookie = request.headers.cookie ?? ""; + state.gzipReferer = request.headers.referer ?? ""; + response.writeHead(200, { + "content-encoding": "gzip", + "content-length": String(encodedPayload.byteLength), + "content-type": "application/octet-stream", + }); + response.end(encodedPayload); + return; + case "/redirect": + response.writeHead(302, { location: "/redirect-target" }); + response.end(); + return; + case "/redirect-target": + state.redirectTargetHits += 1; + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.end(Buffer.from([1])); + return; + case "/overflow": + trackStreamingResponse(response, "overflow"); + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.flushHeaders(); + response.write(Buffer.from([1, 2, 3, 4])); + return; + case "/slow": + trackStreamingResponse(response, "slow"); + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.flushHeaders(); + response.write(Buffer.from([9])); + return; + case "/stream-error": + trackStreamingResponse(response, "stream-error"); + response.writeHead(200, { "content-type": "application/octet-stream" }); + response.flushHeaders(); + response.write(Buffer.from([7])); + setTimeout(() => response.socket?.destroy(), 10); + return; + default: + response.writeHead(404); + response.end(); + } + }); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + try { + const { port } = server.address() as AddressInfo; + const origin = `http://127.0.0.1:${port}`; + await page.goto(origin, { waitUntil: "domcontentloaded" }); + expect(await page.evaluate(async () => (await fetch("/cookie-probe")).text())) + .toBe("cookie observed"); + expect(state.cookieProbe).toContain("closed-source-session=present"); + + // Execute Vite's transformation of the real host source from the same + // origin as the native transport endpoints. This keeps root-relative + // source URLs meaningful without weakening the browser's CORS policy. + const moduleUrl = `${origin}/closed-lazy-assets.ts`; + const sha256 = createHash("sha256").update(decodedPayload).digest("hex"); + const result = await page.evaluate(async ({ moduleUrl, sha256, size }) => { + const { loadClosedLazyAssetSources } = await import( + /* @vite-ignore */ moduleUrl + ); + const binding = (sourceUrl: string, index: number, expectedSize = 3) => ({ + url: `https://example.test/releases/v1/asset-${index}.bin`, + sourceUrl, + sha256: "0".repeat(64), + size: expectedSize, + }); + const rejection = async (promise: Promise) => { + try { + await promise; + return { rejected: false, name: "", message: "" }; + } catch (error) { + return { + rejected: true, + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + }; + } + }; + + const loaded = await loadClosedLazyAssetSources([{ + url: "https://example.test/releases/v1/gzip.bin", + sourceUrl: "/gzip?credential-check=private", + sha256, + size, + }]); + const redirect = await rejection(loadClosedLazyAssetSources([ + binding("/redirect", 1, 1), + ])); + const overflow = await rejection(loadClosedLazyAssetSources([ + binding("/overflow", 2), + ])); + const streamError = await rejection(loadClosedLazyAssetSources([ + binding("/stream-error", 3), + ])); + + const controller = new AbortController(); + const abortReason = new Error("browser caller stopped lazy loading"); + let abortTimer: ReturnType | undefined; + const slowPromise = loadClosedLazyAssetSources([ + binding("/slow", 4), + ], { + signal: controller.signal, + fetchImpl: async (input: string | URL, init?: RequestInit) => { + const response = await fetch(input, init); + abortTimer = setTimeout(() => controller.abort(abortReason), 10); + return response; + }, + }); + let slowSameReason = false; + const slow = await slowPromise.then( + () => ({ rejected: false, name: "", message: "" }), + (error: unknown) => { + slowSameReason = error === abortReason; + return { + rejected: true, + name: error instanceof Error ? error.name : typeof error, + message: error instanceof Error ? error.message : String(error), + }; + }, + ); + if (abortTimer !== undefined) clearTimeout(abortTimer); + + return { + capabilities: { + cryptoDigest: typeof crypto.subtle.digest, + readableStream: typeof ReadableStream, + secureContext: isSecureContext, + }, + gzipBytes: Array.from(loaded[0]!.bytes), + redirect, + overflow, + streamError, + slow, + slowSameReason, + }; + }, { + moduleUrl, + sha256, + size: decodedPayload.byteLength, + }); + + expect(result.capabilities).toEqual({ + cryptoDigest: "function", + readableStream: "function", + secureContext: true, + }); + expect(Buffer.from(result.gzipBytes)).toEqual(decodedPayload); + expect(encodedPayload.byteLength).not.toBe(decodedPayload.byteLength); + expect(state.gzipCookie).toBe(""); + expect(state.gzipReferer).toBe(""); + expect(result.redirect.rejected).toBe(true); + expect(state.redirectTargetHits).toBe(0); + expect(result.overflow).toMatchObject({ + rejected: true, + message: expect.stringContaining("exceeds 3 bytes"), + }); + expect(result.streamError.rejected).toBe(true); + expect(result.slow).toMatchObject({ + rejected: true, + message: "browser caller stopped lazy loading", + }); + expect(result.slowSameReason).toBe(true); + await expect.poll(() => state.overflowClosed).toBe(true); + await expect.poll(() => state.slowClosed).toBe(true); + await expect.poll(() => state.streamErrorClosed).toBe(true); + expect(state.overflowFinished).toBe(false); + expect(state.slowFinished).toBe(false); + } finally { + for (const response of streamingResponses) response.destroy(); + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); diff --git a/apps/browser-demos/test/epoll-repro.ts b/apps/browser-demos/test/epoll-repro.ts index cf9eaa1e90..469df41f14 100644 --- a/apps/browser-demos/test/epoll-repro.ts +++ b/apps/browser-demos/test/epoll-repro.ts @@ -45,7 +45,8 @@ async function main() { // Grow to max so channel offset is valid procMem.grow(MAX_PAGES - 17); const channelOff = (MAX_PAGES - 2) * PAGE_SIZE; - kw.registerProcess(1, procMem, [channelOff], { stdio: CAPTURED_STDIO }); + 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()}`); @@ -53,12 +54,20 @@ async function main() { // 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 bindResult = setCurrentTid(pid, pid); + if (bindResult !== 0) { + throw new Error(`kernel_set_current_tid(${pid}, ${pid}) failed: ${bindResult}`); + } + return handleChannel(BigInt(scratchOffset), pid); + }; // 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); - handleChannel(BigInt(scratchOffset), 1); + handleBoundChannel(); const epfd = Number(kernelView.getBigInt64(CH_RETURN, true)); console.log(`epoll_create1(0) = ${epfd}, SP=${getSP()}`); @@ -67,7 +76,7 @@ async function main() { 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); - handleChannel(BigInt(scratchOffset), 1); + 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); @@ -83,7 +92,7 @@ async function main() { 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); - handleChannel(BigInt(scratchOffset), 1); + 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 @@ -98,7 +107,7 @@ async function main() { console.log(`\nCalling epoll_pwait... SP before=${getSP()}`); try { - handleChannel(BigInt(scratchOffset), 1); + 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()}`); @@ -118,7 +127,7 @@ async function main() { console.log(`\nCalling epoll_pwait(timeout=1000)... SP before=${getSP()}`); try { - handleChannel(BigInt(scratchOffset), 1); + 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()}`); 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 3f1146bed0..9f8db0d84f 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 @@ -33,6 +33,7 @@ const MAX_LOCK_RECORDS = 4096; const FLOCK_PTR = 0x4000; interface RegisteredProcess { + pid: number; memory: WebAssembly.Memory; channelOffset: number; layout: ProcessMemoryLayout; @@ -70,7 +71,7 @@ function internals(worker: CentralizedKernelWorker): KernelWorkerInternals { return worker as unknown as KernelWorkerInternals; } -function makeProcessMemory(): RegisteredProcess { +function makeProcessMemory(): Omit { const layout = computeProcessMemoryLayout({ ptrWidth: 4, heapBase: 0x0012_0000, @@ -85,16 +86,15 @@ function makeProcessMemory(): RegisteredProcess { function register( worker: CentralizedKernelWorker, - pid: number, ): RegisteredProcess { const process = makeProcessMemory(); + const pid = worker.createProcess(CAPTURED_STDIO); worker.registerProcess(pid, process.memory, [process.channelOffset], { brkBase: process.layout.brkBase, mmapBase: process.layout.mmapBase, maxAddr: process.layout.maxAddr, - stdio: CAPTURED_STDIO, }); - return process; + return { ...process, pid }; } function issue( @@ -120,6 +120,14 @@ function issue( 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)), @@ -235,7 +243,7 @@ self.onmessage = async (event: MessageEvent) => { const renamedIdentityPath = `${identityPath}-renamed`; const opfs = OpfsFileSystem.create(buffer); let worker: CentralizedKernelWorker | null = null; - const pids = [810, 811, 812, 813]; + const pids: number[] = []; let response: Record | null = null; try { @@ -251,10 +259,12 @@ self.onmessage = async (event: MessageEvent) => { ); await worker.init(kernelWasm); - register(worker, pids[0]); - const peer = register(worker, pids[1]); - const capacityOwner = register(worker, pids[2]); - register(worker, pids[3]); + pids.push(register(worker).pid); + const peer = register(worker); + pids.push(peer.pid); + 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); diff --git a/apps/browser-demos/test/fixtures/package-deferred-tree-worker.ts b/apps/browser-demos/test/fixtures/package-deferred-tree-worker.ts new file mode 100644 index 0000000000..cf1687dc73 --- /dev/null +++ b/apps/browser-demos/test/fixtures/package-deferred-tree-worker.ts @@ -0,0 +1,89 @@ +import { resolveLazyUrl } from "../../../../host/src/vfs/lazy-url"; +import { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs"; + +interface InspectPackageTreeRequest { + image: number[]; + lazyUrlBase: string; +} + +interface WorkerScope { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: unknown): void; +} + +const workerScope = self as unknown as WorkerScope; + +workerScope.onmessage = async (event) => { + try { + const fs = MemoryFileSystem.fromImagePreservingCapacity( + Uint8Array.from(event.data.image), + ); + fs.rewriteLazyFileUrls((url) => + resolveLazyUrl(event.data.lazyUrlBase, url) + ); + fs.rewriteLazyArchiveUrls((url) => + resolveLazyUrl(event.data.lazyUrlBase, url) + ); + const snapshot = (path: string) => { + const stat = fs.lstat(path); + return { + mode: stat.mode & 0o7777, + uid: stat.uid, + gid: stat.gid, + size: stat.size, + deferred: fs.isPathDeferred(path), + }; + }; + const readPath = "/opt/browser-package-tree/share/runtime.txt"; + const executablePath = "/opt/browser-package-tree/bin/tool"; + const directoryPath = "/opt/browser-package-tree/share"; + const before = { + data: snapshot(readPath), + executable: snapshot(executablePath), + directory: snapshot(directoryPath), + }; + const handle = fs.opendir(directoryPath); + const names: string[] = []; + try { + for (;;) { + const entry = fs.readdir(handle); + if (entry === null) break; + names.push(entry.name); + } + } finally { + fs.closedir(handle); + } + const prepared = await fs.preparePath(readPath); + const stat = fs.stat(readPath); + const bytes = new Uint8Array(stat.size); + const fd = fs.open(readPath, 0, 0); + try { + const read = fs.read(fd, bytes, null, bytes.byteLength); + if (read !== bytes.byteLength) { + throw new Error(`short package-tree read: ${read}/${bytes.byteLength}`); + } + } finally { + fs.close(fd); + } + workerScope.postMessage({ + ok: true, + result: { + before, + after: { + data: snapshot(readPath), + executable: snapshot(executablePath), + directory: snapshot(directoryPath), + }, + names: names.sort(), + prepared, + text: new TextDecoder().decode(bytes), + pendingTrees: fs.exportLazyArchiveEntries().length, + }, + }); + } catch (error) { + workerScope.postMessage({ + ok: false, + error: error instanceof Error ? error.stack ?? error.message : String(error), + }); + } +}; diff --git a/apps/browser-demos/test/fork-continuation.spec.ts b/apps/browser-demos/test/fork-continuation.spec.ts new file mode 100644 index 0000000000..480dc0246d --- /dev/null +++ b/apps/browser-demos/test/fork-continuation.spec.ts @@ -0,0 +1,165 @@ +import { expect, test, type Page } from "@playwright/test"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { resolveBinary } from "../../../host/src/binary-resolver"; + +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", +); + +interface BrowserFixtureResult { + exitCode: number; + stdout: string; + stderr: string; + diagnostics: Array<{ source: string; message: string }>; +} + +async function runBrowserFixture( + page: Page, + baseURL: string, + fixturePath: string, + argv0: string, + maxMemoryPages?: number, +): Promise { + const asViteFsUrl = (path: string) => + new URL(`/@fs/${path}`, baseURL).href; + + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + return page.evaluate( + async ({ + browserKernelModuleUrl, + memoryFsModuleUrl, + fixtureUrl, + argv0, + maxMemoryPages, + }) => { + // WHY: BrowserKernel already imports MemoryFileSystem. Loading the host + // entry first avoids asking a cold Vite server to optimize the same + // dependency graph through two concurrent dynamic imports. + 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<{ source: string; message: string }> = []; + const kernel = new BrowserKernel({ + maxWorkers: 4, + ...(maxMemoryPages === undefined ? {} : { maxMemoryPages }), + onStdout: (data: Uint8Array) => { + stdout += decoder.decode(data); + }, + onStderr: (data: Uint8Array) => { + stderr += decoder.decode(data); + }, + onHostDiagnostic: (diagnostic: { source: string; message: string }) => { + diagnostics.push({ + source: diagnostic.source, + message: diagnostic.message, + }); + }, + }); + let initialized = false; + + try { + // WHY: these fixtures do not use files. A minimal image keeps this a + // BrowserKernel integration proof without coupling it to the much + // larger shell image or its package publication state. + const imageOwner = MemoryFileSystem.create( + new SharedArrayBuffer(1024 * 1024), + ); + const vfsImage = await imageOwner.saveImage(); + await kernel.initFromImage({ vfsImage }); + initialized = true; + + const response = await fetch(fixtureUrl); + if (!response.ok) { + throw new Error( + `fixture fetch failed: ${response.status} ${fixtureUrl}`, + ); + } + const exitCode = await kernel.spawn( + await response.arrayBuffer(), + [argv0], + ); + return { exitCode, stdout, stderr, diagnostics }; + } finally { + if (initialized) await kernel.destroy(); + } + }, + { + browserKernelModuleUrl: asViteFsUrl(browserKernelModulePath), + memoryFsModuleUrl: asViteFsUrl(memoryFsModulePath), + fixtureUrl: asViteFsUrl(fixturePath), + argv0, + maxMemoryPages, + }, + ); +} + +test("Chromium grows and replays a continuation beyond ABI 41's fixed reserve", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + + const result = await runBrowserFixture( + page, + baseURL!, + resolveBinary("programs/p_10_deep_linked_continuation.wasm"), + "p_10_deep_linked_continuation", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("PRE_DEEP_FORK"); + expect(result.stdout).toContain("DEEP_CHILD: ok"); + expect(result.stdout).toContain("DEEP_PARENT: child="); + expect(result.stdout).toContain("PASS: P-10"); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); +}); + +test("Chromium preserves the parent across root and later continuation ENOMEM", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + test.setTimeout(180_000); + expect(baseURL).toBeTruthy(); + + const result = await runBrowserFixture( + page, + baseURL!, + resolveBinary("programs/p_11_fork_continuation_enomem.wasm"), + "p_11_fork_continuation_enomem", + // Keep the exhaustion loop bounded while leaving enough initial pages for + // the program and BrowserKernel-owned channel/control memory. + 384, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("ROOT_CONTINUATION_ENOMEM: ok"); + expect(result.stdout).toContain("ROOT_NO_PHANTOM_CHILD: ok"); + expect(result.stdout).toContain("ROOT_PARENT_USABLE: ok"); + expect(result.stdout).toContain("CONTINUATION_ENOMEM: ok"); + expect(result.stdout).toContain("NO_PHANTOM_CHILD: ok"); + expect(result.stdout).toContain("CONTINUATION_PAGE_REUSED: ok"); + expect(result.stdout).toContain("RECOVERY_CHILD: ok"); + expect(result.stdout).toContain("RECOVERY_PARENT: child="); + expect(result.stdout).toContain("PASS: P-11"); + expect(result.stderr).toBe(""); + expect(result.diagnostics).toEqual([]); +}); diff --git a/apps/browser-demos/test/lazy-archive-runtime.spec.ts b/apps/browser-demos/test/lazy-archive-runtime.spec.ts index 1a7e77a722..8b5cb4f9a4 100644 --- a/apps/browser-demos/test/lazy-archive-runtime.spec.ts +++ b/apps/browser-demos/test/lazy-archive-runtime.spec.ts @@ -4,13 +4,19 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect, test, type Page } from "@playwright/test"; -import { gzipSync, zipSync } from "fflate"; +import { gzipSync, zipSync, type Zippable } from "fflate"; import { ABI_VERSION } from "../../../host/src/generated/abi"; import { MemoryFileSystem, type LazyTreeRegistrationEntry, } from "../../../host/src/vfs/memory-fs"; +import { + derivePackageDeferredZipTree, + materializePackageDeferredZipTree, + registerPackageDeferredZipTree, + type PackageDeferredZipTreeSpec, +} from "../../../host/src/vfs/package-deferred-tree"; import { parseZipCentralDirectory } from "../../../host/src/vfs/zip"; interface LazyAcceptanceResult { @@ -83,6 +89,63 @@ async function lazyImage(groups: Array<{ return fs.saveImage(); } +async function packageTreeImages( + archive: Uint8Array, +): Promise<{ lazy: Uint8Array; eager: Uint8Array }> { + const spec = { + schema: 1, + kind: "kandelo-package-deferred-zip-tree", + id: "browser/package-runtime", + content_role: "runtime-tree", + package: { + name: "package-runtime", + output: "package-runtime.zip", + }, + archive: { + url: "package-runtime.zip", + mode_policy: "portable-posix-v1", + }, + mount_prefix: "/opt/package-runtime", + owner: { + uid: 1000, + gid: 1000, + }, + activation: { + mode: "first-use", + capabilities: ["package:runtime"], + roots: ["/opt/package-runtime/bin/environment-lifecycle"], + }, + } as const satisfies PackageDeferredZipTreeSpec; + const derived = derivePackageDeferredZipTree(spec, archive); + const createFs = () => { + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(1024 * 1024), + ); + fs.setImageMetadata({ version: 1, kernelAbi: ABI_VERSION }); + // The environment lifecycle fixture re-execs itself through this stable + // path. Keep the package-owned executable under its mount prefix while + // exercising normal VFS symlink resolution for the fixture's re-exec. + fs.mkdir("/bin", 0o755); + fs.symlink( + "/opt/package-runtime/bin/environment-lifecycle", + "/bin/environment-lifecycle", + ); + return fs; + }; + + const lazyFs = createFs(); + registerPackageDeferredZipTree(lazyFs, derived); + + const eagerFs = createFs(); + const registered = registerPackageDeferredZipTree(eagerFs, derived); + await materializePackageDeferredZipTree(eagerFs, registered, archive); + + return { + lazy: await lazyFs.saveImage(), + eager: await eagerFs.saveImage(), + }; +} + async function routeBytes( page: Page, url: string, @@ -205,6 +268,79 @@ test("Chromium boots, reads, and execs through verified lazy archives", async ({ expect(execFetches).toBe(1); }); +test("Chromium retries a transient lazy-tree response before surfacing EIO", async ({ + page, + baseURL, +}) => { + if (!baseURL) throw new Error("Playwright baseURL is required"); + const archiveUrl = "https://fixtures.kandelo.invalid/transient.tar.gz"; + const imageUrl = "https://fixtures.kandelo.invalid/transient.vfs"; + const payload = new TextEncoder().encode("verified-after-transient-502"); + const tar = tarBytes([ + { + path: "etc/transient-data", + mode: 0o644, + data: payload, + }, + ]); + const archive = gzipSync(tar); + const image = await lazyImage([{ + url: archiveUrl, + archive, + tarBytes: tar.byteLength, + inventory: [{ + vfsPath: "/etc/transient-data", + sourcePath: "etc/transient-data", + type: "file", + mode: 0o644, + size: payload.byteLength, + inodeGroup: "transient-data", + }], + }]); + let fetches = 0; + await routeBytes(page, imageUrl, image, "application/octet-stream"); + await page.route(archiveUrl, async (route) => { + fetches++; + if (fetches === 1) { + await route.fulfill({ + status: 502, + body: "temporary release edge failure", + headers: { + "access-control-allow-origin": "*", + "retry-after": "0", + }, + }); + return; + } + 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/transient-data", + timeoutMs: 30_000, + }), + imageUrl, + ); + + expect(result.firstReadError).toBeUndefined(); + expect(result.readText).toBe("verified-after-transient-502"); + expect(fetches).toBe(2); +}); + test("Chromium reports digest failure without mutation and retries cleanly", async ({ page, baseURL, @@ -253,6 +389,90 @@ test("Chromium reports digest failure without mutation and retries cleanly", asy expect(fetches).toBe(2); }); +test("Chromium consumes lazy and eager package trees derived from one exact ZIP", async ({ + page, + baseURL, +}) => { + test.setTimeout(180_000); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const executable = new Uint8Array(readFileSync(environmentProgram)); + const archive = zipSync({ + "bin/": unixZipEntry(new Uint8Array(), 0o040700), + "bin/environment-lifecycle": unixZipEntry(executable, 0o100711), + "share/": unixZipEntry(new Uint8Array(), 0o040777), + "share/package-runtime.txt": unixZipEntry( + new TextEncoder().encode("same package tree\n"), + 0o100600, + ), + } satisfies Zippable); + const images = await packageTreeImages(archive); + const lazyImageUrl = "https://fixtures.kandelo.invalid/package-lazy.vfs"; + const eagerImageUrl = "https://fixtures.kandelo.invalid/package-eager.vfs"; + const archiveUrl = new URL("package-runtime.zip", baseURL).href; + let archiveFetches = 0; + await routeBytes(page, lazyImageUrl, images.lazy, "application/octet-stream"); + await routeBytes(page, eagerImageUrl, images.eager, "application/octet-stream"); + await page.route(archiveUrl, async (route) => { + archiveFetches++; + await route.fulfill({ + status: 200, + body: Buffer.from(archive), + headers: { + "content-length": String(archive.byteLength), + "content-type": "application/zip", + }, + }); + }); + + await page.goto(new URL("/pages/homebrew-vfs-test/", baseURL).href); + await expect.poll( + () => page.evaluate(() => window.__homebrewVfsTestReady), + { timeout: 120_000 }, + ).toBe(true); + const request = { + readPath: "/opt/package-runtime/share/package-runtime.txt", + executable: "/opt/package-runtime/bin/environment-lifecycle", + argv: ["/opt/package-runtime/bin/environment-lifecycle"], + env: ["INITIAL=parent", "REMOVE=before-fork"], + timeoutMs: 90_000, + }; + const lazy = await page.evaluate( + ({ url, acceptance }) => window.__runLazyVfsAcceptance({ + vfsUrl: url, + ...acceptance, + }), + { url: lazyImageUrl, acceptance: request }, + ); + expect(lazy).toMatchObject({ + readText: "same package tree\n", + exitCode: 0, + stderr: "", + }); + expect(lazy.stdout).toContain("EXEC_ENV_PASS"); + expect(lazy.stdout).toContain("EMPTY_ENV_PASS"); + expect(archiveFetches).toBe(1); + + const eager = await page.evaluate( + ({ url, acceptance }) => window.__runLazyVfsAcceptance({ + vfsUrl: url, + ...acceptance, + }), + { url: eagerImageUrl, acceptance: request }, + ); + expect(eager).toMatchObject({ + readText: "same package tree\n", + exitCode: 0, + stderr: "", + }); + expect(eager.stdout).toContain("EXEC_ENV_PASS"); + expect(eager.stdout).toContain("EMPTY_ENV_PASS"); + expect(archiveFetches).toBe(1); +}); + +function unixZipEntry(bytes: Uint8Array, mode: number): Zippable[string] { + return [bytes, { os: 3, attrs: ((mode << 16) >>> 0) }]; +} + interface TarSpec { path: string; mode: number; diff --git a/apps/browser-demos/test/package-deferred-tree-browser.spec.ts b/apps/browser-demos/test/package-deferred-tree-browser.spec.ts new file mode 100644 index 0000000000..e160ac6fb2 --- /dev/null +++ b/apps/browser-demos/test/package-deferred-tree-browser.spec.ts @@ -0,0 +1,234 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, test, type Page } from "@playwright/test"; +import { zipSync, type Zippable } from "fflate"; + +import { ABI_VERSION } from "../../../host/src/generated/abi"; +import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { + derivePackageDeferredZipTree, + materializePackageDeferredZipTree, + registerPackageDeferredZipTree, + type PackageDeferredZipTreeSpec, +} from "../../../host/src/vfs/package-deferred-tree"; + +const here = dirname(fileURLToPath(import.meta.url)); +const workerModulePath = resolve( + here, + "fixtures/package-deferred-tree-worker.ts", +); + +interface BrowserPackageTreeResult { + before: { + data: PathSnapshot; + executable: PathSnapshot; + directory: PathSnapshot; + }; + after: { + data: PathSnapshot; + executable: PathSnapshot; + directory: PathSnapshot; + }; + names: string[]; + prepared: boolean; + text: string; + pendingTrees: number; +} + +interface PathSnapshot { + mode: number; + uid: number; + gid: number; + size: number; + deferred: boolean; +} + +function unixZipEntry(bytes: Uint8Array, mode: number): Zippable[string] { + return [bytes, { os: 3, attrs: ((mode << 16) >>> 0) }]; +} + +async function packageTreeImages(): Promise<{ + archive: Uint8Array; + lazy: Uint8Array; + eager: Uint8Array; +}> { + const archive = zipSync({ + "bin/": unixZipEntry(new Uint8Array(), 0o040700), + "bin/tool": unixZipEntry( + new TextEncoder().encode("#!/bin/package-tool\n"), + 0o100711, + ), + "share/": unixZipEntry(new Uint8Array(), 0o040777), + "share/runtime.txt": unixZipEntry( + new TextEncoder().encode("browser package tree\n"), + 0o100600, + ), + } satisfies Zippable); + const spec = { + schema: 1, + kind: "kandelo-package-deferred-zip-tree", + id: "browser/package-tree", + content_role: "runtime-tree", + package: { + name: "browser-package-tree", + output: "browser-package-tree.zip", + }, + archive: { + url: "browser-package-tree.zip", + mode_policy: "portable-posix-v1", + }, + mount_prefix: "/opt/browser-package-tree", + owner: { + uid: 1000, + gid: 1000, + }, + activation: { + mode: "first-use", + capabilities: ["package:browser-test"], + roots: ["/opt/browser-package-tree/bin/tool"], + }, + } as const satisfies PackageDeferredZipTreeSpec; + const derived = derivePackageDeferredZipTree(spec, archive); + const createFs = () => { + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(1024 * 1024), + ); + fs.setImageMetadata({ version: 1, kernelAbi: ABI_VERSION }); + return fs; + }; + + const lazyFs = createFs(); + registerPackageDeferredZipTree(lazyFs, derived); + + const eagerFs = createFs(); + const registered = registerPackageDeferredZipTree(eagerFs, derived); + await materializePackageDeferredZipTree(eagerFs, registered, archive); + + return { + archive, + lazy: await lazyFs.saveImage(), + eager: await eagerFs.saveImage(), + }; +} + +async function inspectPackageTreeInBrowser( + page: Page, + workerUrl: string, + image: Uint8Array, + lazyUrlBase: string, +): Promise { + return page.evaluate( + ({ workerUrl, image, lazyUrlBase }) => { + return new Promise((resolve, reject) => { + const worker = new Worker(workerUrl, { type: "module" }); + worker.onmessage = (event) => { + worker.terminate(); + if (event.data?.ok === true) { + resolve(event.data.result as BrowserPackageTreeResult); + } else { + reject(new Error(event.data?.error ?? "package-tree worker failed")); + } + }; + worker.onerror = (event) => { + worker.terminate(); + reject(new Error(event.message || "package-tree worker crashed")); + }; + worker.postMessage({ image, lazyUrlBase }); + }); + }, + { + workerUrl, + image: Array.from(image), + lazyUrlBase, + }, + ); +} + +test("browsers retry transient lazy package trees and consume the exact ZIP", async ({ + page, + baseURL, + browserName, +}) => { + expect(baseURL).toBeTruthy(); + if (!baseURL) throw new Error("Playwright baseURL is required"); + const workerUrl = new URL(`/@fs${workerModulePath}`, baseURL).href; + const archiveUrl = new URL( + "/package-assets/browser-package-tree.zip", + baseURL, + ).href; + const lazyUrlBase = new URL("/package-assets/", baseURL).href; + const images = await packageTreeImages(); + let archiveFetches = 0; + await page.route(archiveUrl, async (route) => { + archiveFetches++; + if (archiveFetches === 1) { + await route.fulfill({ + status: 502, + body: "temporary package release edge failure", + headers: { "retry-after": "0" }, + }); + return; + } + await route.fulfill({ + status: 200, + body: Buffer.from(images.archive), + headers: { + "content-length": String(images.archive.byteLength), + "content-type": "application/zip", + }, + }); + }); + await page.goto(new URL("/trap-signal-test.html", baseURL).href); + + const lazy = await inspectPackageTreeInBrowser( + page, + workerUrl, + images.lazy, + lazyUrlBase, + ); + expect(lazy.before, browserName).toEqual({ + data: { + mode: 0o644, + uid: 1000, + gid: 1000, + size: 21, + deferred: true, + }, + executable: { + mode: 0o755, + uid: 1000, + gid: 1000, + size: 20, + deferred: true, + }, + directory: { + mode: 0o755, + uid: 1000, + gid: 1000, + size: 44, + deferred: false, + }, + }); + expect(lazy.names, browserName).toEqual([".", "..", "runtime.txt"]); + expect(lazy.prepared, browserName).toBe(true); + expect(lazy.text, browserName).toBe("browser package tree\n"); + expect(lazy.after.data.deferred, browserName).toBe(false); + expect(lazy.after.executable.deferred, browserName).toBe(false); + expect(lazy.pendingTrees, browserName).toBe(0); + expect(archiveFetches, browserName).toBe(2); + + const eager = await inspectPackageTreeInBrowser( + page, + workerUrl, + images.eager, + lazyUrlBase, + ); + expect(eager.before, browserName).toEqual(lazy.after); + expect(eager.names, browserName).toEqual(lazy.names); + expect(eager.prepared, browserName).toBe(false); + expect(eager.text, browserName).toBe(lazy.text); + expect(eager.after, browserName).toEqual(lazy.after); + expect(eager.pendingTrees, browserName).toBe(0); + expect(archiveFetches, browserName).toBe(2); +}); diff --git a/crates/fork-instrument/Cargo.toml b/crates/fork-instrument/Cargo.toml index bafaaefb75..499050811b 100644 --- a/crates/fork-instrument/Cargo.toml +++ b/crates/fork-instrument/Cargo.toml @@ -15,6 +15,8 @@ name = "fork_instrument" path = "src/lib.rs" [dependencies] +wasm-posix-shared = { path = "../shared" } + # walrus — typed wasm IR with validator. The primary workhorse for # parsing, manipulating, and emitting instrumented modules. walrus = "0.26" diff --git a/crates/fork-instrument/src/instrument.rs b/crates/fork-instrument/src/instrument.rs index 133344e57a..e9c430fc72 100644 --- a/crates/fork-instrument/src/instrument.rs +++ b/crates/fork-instrument/src/instrument.rs @@ -114,14 +114,14 @@ use std::collections::{HashMap, HashSet}; use walrus::{ + AbstractHeapType, ExportItem, FunctionId, FunctionKind, HeapType, LocalFunction, LocalId, + MemoryId, Module, 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, RefIsNull, RefNull, Return, StoreKind, TableGet, TableSet, Throw, ThrowRef, TryTable, TryTableCatch, UnaryOp, Unreachable, Value, }, - AbstractHeapType, ExportItem, FunctionId, FunctionKind, HeapType, LocalFunction, LocalId, - MemoryId, Module, RefType, TableId, TagId, TypeId, ValType, }; use crate::runtime::{self, Runtime}; @@ -229,6 +229,12 @@ struct CatchStateLocals { exnref_slot: LocalId, } +#[derive(Debug, Clone, Copy)] +struct AbortDispatch { + live_frame: LocalId, + restart_loop: InstrSeqId, +} + #[allow(clippy::too_many_arguments)] fn instrument_one_function( module: &mut Module, @@ -435,6 +441,7 @@ fn instrument_one_function_switch( exnref_slot: 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 @@ -512,6 +519,7 @@ fn instrument_one_function_switch( let ty_id = module.funcs.get(func_id).ty(); module.types.get(ty_id).results().to_vec() }; + 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, @@ -566,6 +574,11 @@ fn instrument_one_function_switch( .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); + let restart_loop = local.builder_mut().dangling_instr_seq(restart_loop_ty).id(); + let abort = AbortDispatch { + live_frame: abort_live_frame, + restart_loop, + }; let post_seqs: Vec = (0..n_calls) .map(|_| { local @@ -601,7 +614,9 @@ fn instrument_one_function_switch( runtime, memory, ptr_ty, + frame_size, catch_state_locals, + abort, ); // Postamble lives outside $unwind_save, in the entry block, right @@ -622,10 +637,10 @@ fn instrument_one_function_switch( &result_types, ); - // Rebuild the entry block: [preamble if/else, Block($unwind_save), - // postamble]. - let entry_seq = &mut local.block_mut(entry_id).instrs; - entry_seq.clear(); + // 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; push_instr( entry_seq, Instr::GlobalGet(GlobalGet { @@ -641,7 +656,25 @@ fn instrument_one_function_switch( push_instr( entry_seq, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + 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( @@ -653,6 +686,9 @@ fn instrument_one_function_switch( ); 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 })); // Phase 6d application: replaces each fork-path try_table with // an $outer/$capture wrap so caught exnrefs are stashed and the @@ -2186,7 +2222,7 @@ fn populate_dispatch_normal( push_instr( s, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + op: BinaryOp::I32GeU, }), ); push_instr( @@ -2276,7 +2312,7 @@ fn populate_internal_dispatch( push_instr( s, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + op: BinaryOp::I32GeU, }), ); push_instr( @@ -2304,7 +2340,9 @@ fn populate_dispatch_structure( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, catch_state_locals: Option, + abort: AbortDispatch, ) { let n_calls = call_sites.len(); @@ -2336,7 +2374,9 @@ fn populate_dispatch_structure( runtime, memory, ptr_ty, + frame_size, catch_state_locals, + abort, ); } @@ -2360,7 +2400,9 @@ fn emit_dispatch_node( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, catch_state_locals: Option, + abort: AbortDispatch, ) { match node { DispatchTree::Leaf { start, end } => emit_leaf_dispatch( @@ -2379,7 +2421,9 @@ fn emit_dispatch_node( runtime, memory, ptr_ty, + frame_size, catch_state_locals, + abort, ), DispatchTree::Internal { children, @@ -2400,7 +2444,9 @@ fn emit_dispatch_node( runtime, memory, ptr_ty, + frame_size, catch_state_locals, + abort, ), } } @@ -2440,7 +2486,9 @@ fn emit_internal_dispatch( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, catch_state_locals: Option, + abort: AbortDispatch, ) { let b = children.len(); debug_assert!(b >= 2, "internal dispatch node must have >= 2 children"); @@ -2502,7 +2550,9 @@ fn emit_internal_dispatch( runtime, memory, ptr_ty, + frame_size, catch_state_locals, + abort, ); } @@ -2530,7 +2580,9 @@ fn emit_internal_dispatch( runtime, memory, ptr_ty, + frame_size, catch_state_locals, + abort, ); } @@ -2557,7 +2609,9 @@ fn emit_leaf_dispatch( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, catch_state_locals: Option, + abort: AbortDispatch, ) { debug_assert!( leaf_end > leaf_start, @@ -2625,8 +2679,10 @@ fn emit_leaf_dispatch( runtime, memory, ptr_ty, + frame_size, catch_state_locals, function_unwind_save, + abort, ); { let s = &mut local.block_mut(post_seqs[k]).instrs; @@ -2661,8 +2717,10 @@ fn emit_leaf_dispatch( runtime, memory, ptr_ty, + frame_size, catch_state_locals, function_unwind_save, + abort, ); if is_last_leaf { let s = &mut local.block_mut(exit_seq).instrs; @@ -2819,20 +2877,142 @@ fn emit_call_index_store_and_unwind_branch( 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 if_then = local + let unwind_then = local .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); - let if_else = local + 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)) + .id(); + let reserve_failed = local .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); { - let s = &mut local.block_mut(if_then).instrs; + 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; + 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, + }), + ); + push_instr(s, ptr_const(ptr_ty, runtime.frames_start_offset as i64)); + push_instr( + s, + Instr::Binop(Binop { + op: ptr_add(ptr_ty), + }), + ); + 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), + }), + ); + push_instr(s, store_i32(memory, CALL_INDEX_OFFSET)); + push_instr( + s, + Instr::Br(Br { + block: abort.restart_loop, + }), + ); + } + + 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, @@ -2844,6 +3024,8 @@ fn emit_call_index_store_and_unwind_branch( push_instr(s, Instr::Br(Br { block: unwind_save })); } + emit_phase_6e_writes(local, normal_else, catch_handlers, catch_state_locals); + let s = &mut local.block_mut(seq_id).instrs; push_instr( s, @@ -2866,8 +3048,8 @@ fn emit_call_index_store_and_unwind_branch( push_instr( s, Instr::IfElse(IfElse { - consequent: if_then, - alternative: if_else, + consequent: unwind_then, + alternative: normal_else, }), ); } @@ -2891,28 +3073,34 @@ fn populate_preamble_then( ) { let s = &mut local.block_mut(preamble_then).instrs; - // *(buf + 0) = *(buf + 0) - frame_size. After this, the buffer - // cursor itself is the current frame pointer for all frame reads. - push_instr( - s, - Instr::GlobalGet(GlobalGet { - global: runtime.buf_global, - }), - ); + // 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. push_instr( s, Instr::GlobalGet(GlobalGet { global: runtime.buf_global, }), ); - push_instr(s, load_ptr(memory, ptr_ty, 0)); - push_instr(s, ptr_const(ptr_ty, frame_size as i64)); - push_instr( - s, - Instr::Binop(Binop { - op: ptr_sub(ptr_ty), - }), - ); + if let Some(frame_next) = runtime.frame_next { + push_instr(s, ptr_const(ptr_ty, frame_size as i64)); + push_instr(s, Instr::Call(Call { func: frame_next })); + } else { + push_instr( + s, + Instr::GlobalGet(GlobalGet { + global: runtime.buf_global, + }), + ); + push_instr(s, load_ptr(memory, ptr_ty, 0)); + push_instr(s, ptr_const(ptr_ty, frame_size as i64)); + push_instr( + s, + Instr::Binop(Binop { + op: ptr_sub(ptr_ty), + }), + ); + } push_instr(s, store_ptr(memory, ptr_ty, 0)); if let Some(catch_state) = catch_state_locals { @@ -3037,22 +3225,28 @@ fn populate_postamble( push_instr(out, Instr::TableSet(TableSet { table })); } - // Advance current_pos: *(buf + 0) = frame_ptr + frame_size - push_instr( - out, - Instr::GlobalGet(GlobalGet { - global: runtime.buf_global, - }), - ); - push_current_frame_ptr(out, runtime, memory, ptr_ty); - push_instr(out, ptr_const(ptr_ty, frame_size as i64)); - push_instr( - out, - Instr::Binop(Binop { - op: ptr_add(ptr_ty), - }), - ); - push_instr(out, store_ptr(memory, ptr_ty, 0)); + if let Some(frame_commit) = runtime.frame_commit { + // Publish only after the complete payload and reference stashes exist. + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, Instr::Call(Call { func: frame_commit })); + } else { + // Advance current_pos: *(buf + 0) = frame_ptr + frame_size + push_instr( + out, + Instr::GlobalGet(GlobalGet { + global: runtime.buf_global, + }), + ); + push_current_frame_ptr(out, runtime, memory, ptr_ty); + push_instr(out, ptr_const(ptr_ty, frame_size as i64)); + push_instr( + out, + Instr::Binop(Binop { + op: ptr_add(ptr_ty), + }), + ); + 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. @@ -3093,8 +3287,10 @@ fn emit_post_call_via_local( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, catch_state_locals: Option, unwind_save: InstrSeqId, + abort: AbortDispatch, ) { // Reload carryovers (deepest first), then args (deepest first). // The call pops only its args, leaving the carryovers + result on @@ -3115,16 +3311,18 @@ fn emit_post_call_via_local( s.push((call_instr, call.loc)); } - emit_phase_6e_writes(local, seq_id, catch_handlers, catch_state_locals); - 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, ); } @@ -3850,7 +4048,9 @@ pub fn plan_b1_scratch(module: &Module, targets: &[FunctionId]) -> B1ScratchPlan let mut slots: Vec = Vec::with_capacity(arm_list.len()); for arm in arm_list { debug_assert!( - arm.operand_tys.iter().all(|t| !matches!(t, ValType::Ref(_))), + arm.operand_tys + .iter() + .all(|t| !matches!(t, ValType::Ref(_))), "B1 plan_b1_scratch invariant: caller must filter ref-payload arms via Stage 2 \ b2_carveout (excluded from fork-path) before reaching the planner. Affected \ function has a tag with a ref-typed operand." @@ -4020,7 +4220,7 @@ fn inject_rewind_throw_stubs( .id() }; - // Prepend the outer guard `if state==REWINDING && cri == K` + // Prepend the outer guard `if state>=REWINDING && cri == K` // to the try_table body. let local = local_mut(module, func_id); let original: Vec<(Instr, InstrLocId)> = @@ -4042,7 +4242,7 @@ fn inject_rewind_throw_stubs( push_instr( body, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + op: BinaryOp::I32GeU, }), ); push_instr( @@ -6065,6 +6265,7 @@ fn instrument_one_function_nested_switch( // (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 @@ -6219,6 +6420,7 @@ fn instrument_one_function_nested_switch( let ty_id = module.funcs.get(func_id).ty(); module.types.get(ty_id).results().to_vec() }; + 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 @@ -6260,6 +6462,11 @@ fn instrument_one_function_nested_switch( .builder_mut() .dangling_instr_seq(InstrSeqType::Simple(None)) .id(); + let restart_loop = local.builder_mut().dangling_instr_seq(restart_loop_ty).id(); + let abort = AbortDispatch { + live_frame: abort_live_frame, + restart_loop, + }; populate_preamble_then( local, @@ -6330,9 +6537,11 @@ fn instrument_one_function_nested_switch( runtime, memory, ptr_ty, + frame_size, cond_swap_local, catch_state_locals, unwind_save, + abort, body_params, ); } @@ -6358,9 +6567,11 @@ fn instrument_one_function_nested_switch( runtime, memory, ptr_ty, + frame_size, cond_swap_local, catch_state_locals, unwind_save, + abort, &result_types, ); @@ -6391,7 +6602,7 @@ fn instrument_one_function_nested_switch( s.extend(entry_body); } - let entry_seq = &mut local.block_mut(entry_id).instrs; + let entry_seq = &mut local.block_mut(restart_loop).instrs; push_instr( entry_seq, Instr::GlobalGet(GlobalGet { @@ -6407,7 +6618,25 @@ fn instrument_one_function_nested_switch( push_instr( entry_seq, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + 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( @@ -6419,6 +6648,9 @@ fn instrument_one_function_nested_switch( ); 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 })); apply_catch_ref_handlers(module, func_id, &catch_handlers, aux_tables); @@ -6547,9 +6779,11 @@ fn transform_region_seq( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, cond_swap_local: LocalId, catch_state_locals: Option, unwind_save: InstrSeqId, + abort: AbortDispatch, // Sub-commit 2.6c: this seq's declared type-params (only set for // multi-value Block/Loop/TryTable bodies). Pre-spilled at body // entry so the cascading POST_K Simple(None) blocks can re-expose @@ -6642,9 +6876,11 @@ fn transform_region_seq( runtime, memory, ptr_ty, + frame_size, cond_swap_local, catch_state_locals, unwind_save, + abort, false, // don't append `return` at end ); @@ -6682,9 +6918,11 @@ fn transform_entry_region( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, cond_swap_local: LocalId, catch_state_locals: Option, unwind_save: InstrSeqId, + abort: AbortDispatch, _result_types: &[ValType], ) { let original: Vec<(Instr, InstrLocId)> = std::mem::take(&mut local.block_mut(seq_id).instrs); @@ -6754,9 +6992,11 @@ fn transform_entry_region( runtime, memory, ptr_ty, + frame_size, cond_swap_local, catch_state_locals, unwind_save, + abort, true, // append `return` for normal-path exit ); } @@ -7086,7 +7326,7 @@ fn populate_region_dispatch( push_instr( s, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + op: BinaryOp::I32GeU, }), ); push_instr( @@ -7113,9 +7353,11 @@ fn populate_region_dispatch_structure( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, cond_swap_local: LocalId, catch_state_locals: Option, unwind_save: InstrSeqId, + abort: AbortDispatch, append_return: bool, ) { let n_landings = landings.len(); @@ -7174,9 +7416,11 @@ fn populate_region_dispatch_structure( runtime, memory, ptr_ty, + frame_size, cond_swap_local, catch_state_locals, unwind_save, + abort, ); { let s = &mut local.block_mut(post_seqs[k]).instrs; @@ -7215,9 +7459,11 @@ fn populate_region_dispatch_structure( runtime, memory, ptr_ty, + frame_size, cond_swap_local, catch_state_locals, unwind_save, + abort, ); { let s = &mut local.block_mut(outer_seq).instrs; @@ -7242,9 +7488,11 @@ fn emit_post_landing( runtime: &Runtime, memory: MemoryId, ptr_ty: ValType, + frame_size: u32, cond_swap_local: LocalId, catch_state_locals: Option, unwind_save: InstrSeqId, + abort: AbortDispatch, ) { match &landing.kind { LandingKind::DirectCall { call_idx } => { @@ -7274,16 +7522,21 @@ fn emit_post_landing( }; s.push((call_instr, site.loc)); } - // Phase 6e + call_idx frame write + UNWIND branch. - emit_phase_6e_writes(local, seq_id, catch_handlers, catch_state_locals); + // 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( local, seq_id, runtime, memory, ptr_ty, + frame_size, *call_idx, unwind_save, + catch_handlers, + catch_state_locals, + abort, ); } LandingKind::SubRegion { .. } => { @@ -7464,7 +7717,7 @@ fn emit_post_landing( push_instr( s, Instr::Binop(Binop { - op: BinaryOp::I32Eq, + op: BinaryOp::I32GeU, }), ); push_instr(s, Instr::Select(walrus::ir::Select { ty: None })); diff --git a/crates/fork-instrument/src/lib.rs b/crates/fork-instrument/src/lib.rs index 0560e0cf87..a2e43a9ca4 100644 --- a/crates/fork-instrument/src/lib.rs +++ b/crates/fork-instrument/src/lib.rs @@ -20,6 +20,7 @@ use wasmparser::{Parser, Payload}; pub mod call_graph; pub mod instrument; +pub mod linked_frames; pub mod runtime; /// Versioned artifact claim emitted by `wasm-fork-instrument` and consumed by @@ -62,8 +63,7 @@ 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 module = walrus::Module::from_buffer(input).context("failed to parse input wasm module")?; let Some(entry) = call_graph::find_import_func(&module, &opts.entry_import) else { bail!( @@ -93,8 +93,8 @@ pub fn analyze(input: &[u8], opts: &Options) -> Result { /// tool is invoked by build scripts across programs that may or may /// not use `fork()`. pub fn instrument(input: &[u8], opts: &Options) -> Result> { - let mut 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")?; // Discover the fork-path closure *before* we mutate the module so // the runtime's own injected functions are not mistaken for @@ -148,7 +148,15 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { .collect(); fork_path_targets.sort(); let b1_plan = instrument::plan_b1_scratch(&module, &fork_path_targets); - let runtime = runtime::inject_runtime(&mut module, b1_plan.total_bytes); + // 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, b1_plan.total_bytes) + } else { + runtime::inject_runtime(&mut module, b1_plan.total_bytes) + }; // Phase 4b: structural wrap of each fork-path function's body. // No-op when `fork_path` is empty (module doesn't use fork). @@ -168,6 +176,30 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { data: vec![FORK_CAPABILITIES_VERSION, fork_capabilities], }); + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == linked_frames::LINKED_FRAME_FORMAT_SECTION) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + let pointer_width = match runtime.buf_type { + walrus::ValType::I32 => linked_frames::PointerWidth::Wasm32, + walrus::ValType::I64 => linked_frames::PointerWidth::Wasm64, + other => unreachable!("unsupported fork buffer pointer type: {other:?}"), + }; + module.customs.add(RawCustomSection { + name: linked_frames::LINKED_FRAME_FORMAT_SECTION.into(), + data: linked_frames::FrameFormatDescriptor::current( + pointer_width, + runtime.fixed_prefix_size, + ) + .encode() + .to_vec(), + }); + // 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; diff --git a/crates/fork-instrument/src/linked_frames.rs b/crates/fork-instrument/src/linked_frames.rs new file mode 100644 index 0000000000..f60ea58c52 --- /dev/null +++ b/crates/fork-instrument/src/linked_frames.rs @@ -0,0 +1,849 @@ +//! Checked layout planning for scalable linked fork-continuation frames. +//! +//! The instrumenter publishes this format in every rewritten artifact. The +//! generated frame emitter and host chunk allocator share these checked layout +//! rules. In particular, a frame node is not limited to one 64-KiB WebAssembly +//! page. + +use std::fmt; +use wasm_posix_shared::abi; + +/// WebAssembly linear-memory allocation granularity. +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. +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; + +const FORMAT_DESCRIPTOR_MAGIC: [u8; 4] = abi::WPK_FORK_LINKED_FRAME_FORMAT_MAGIC; +const FORMAT_DESCRIPTOR_SIZE: usize = abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE as usize; +const FORMAT_REQUIRED_FLAGS: u16 = abi::WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS; +const FORMAT_KNOWN_FLAGS: u16 = FORMAT_REQUIRED_FLAGS; + +/// Transactional lifecycle for one linked frame node. +/// +/// These values are part of the version-1 linked-frame encoding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u16)] +pub enum FrameNodeState { + Reserved = 1, + Committed = 2, + Consumed = 3, +} + +/// Pointer representation used by an instrumented module. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PointerWidth { + Wasm32, + Wasm64, +} + +impl PointerWidth { + pub const fn bytes(self) -> u64 { + match self { + Self::Wasm32 => 4, + Self::Wasm64 => 8, + } + } + + const fn max_record_size(self) -> u64 { + match self { + Self::Wasm32 => u32::MAX as u64, + Self::Wasm64 => u64::MAX, + } + } +} + +/// Checked layout failure. No caller may truncate one of these values to a +/// guest pointer or continue with a partially computed record. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LayoutError { + ArithmeticOverflow, + Wasm32AddressSpaceExceeded { required: u64 }, + InvalidChunkCursor { used: u64, capacity: u64 }, +} + +/// Strict decoding failure for the artifact format descriptor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MetadataError { + InvalidLength { actual: usize }, + InvalidMagic, + UnsupportedVersion { version: u16 }, + UnsupportedPointerWidth { bytes: u8 }, + UnsupportedAlignment { bytes: u8 }, + UnknownFlags { flags: u16 }, + MissingRequiredFlags { flags: u16 }, + HeaderSizeMismatch, +} + +impl fmt::Display for MetadataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidLength { actual } => write!( + f, + "linked continuation metadata has length {actual}, expected {FORMAT_DESCRIPTOR_SIZE}" + ), + Self::InvalidMagic => write!(f, "linked continuation metadata has invalid magic"), + Self::UnsupportedVersion { version } => write!( + f, + "linked continuation metadata version {version} is unsupported" + ), + Self::UnsupportedPointerWidth { bytes } => write!( + f, + "linked continuation metadata pointer width {bytes} is unsupported" + ), + Self::UnsupportedAlignment { bytes } => write!( + f, + "linked continuation metadata alignment {bytes} is unsupported" + ), + Self::UnknownFlags { flags } => write!( + f, + "linked continuation metadata contains unknown flags 0x{flags:04x}" + ), + Self::MissingRequiredFlags { flags } => write!( + f, + "linked continuation metadata is missing required flags 0x{flags:04x}" + ), + Self::HeaderSizeMismatch => write!( + f, + "linked continuation metadata header sizes do not match its pointer width" + ), + } + } +} + +impl std::error::Error for MetadataError {} + +impl fmt::Display for LayoutError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ArithmeticOverflow => write!(f, "linked continuation layout overflow"), + Self::Wasm32AddressSpaceExceeded { required } => write!( + f, + "linked continuation record requires {required} bytes, exceeding wasm32 addressability" + ), + Self::InvalidChunkCursor { used, capacity } => write!( + f, + "linked continuation chunk cursor {used} exceeds capacity {capacity}" + ), + } + } +} + +impl std::error::Error for LayoutError {} + +/// Transaction-planning failure. A failed operation leaves both the committed +/// chain and the active chunk cursor unchanged. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanError { + Layout(LayoutError), + PendingReservationExists, + NoPendingReservation, + ReservationTokenMismatch, + ReservationTokenExhausted, +} + +impl fmt::Display for PlanError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Layout(error) => error.fmt(f), + Self::PendingReservationExists => { + write!(f, "a linked continuation reservation is already pending") + } + Self::NoPendingReservation => { + write!(f, "no linked continuation reservation is pending") + } + Self::ReservationTokenMismatch => { + write!(f, "linked continuation reservation token mismatch") + } + Self::ReservationTokenExhausted => { + write!(f, "linked continuation reservation tokens exhausted") + } + } + } +} + +impl std::error::Error for PlanError {} + +impl From for PlanError { + fn from(error: LayoutError) -> Self { + Self::Layout(error) + } +} + +/// Layout of one variable-sized frame node. +/// +/// The header contains three pointer-width fields (`previous +/// node`, `payload size`, and `total node size`) followed by two u32 fields +/// (`state` and `format/version`). The payload and following node remain +/// eight-byte aligned. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameNodeLayout { + pub header_size: u64, + pub payload_offset: u64, + pub payload_size: u64, + pub node_size: u64, +} + +/// Self-describing, address-free properties published by an instrumented +/// artifact. Keeping this descriptor independent of runtime addresses makes it +/// safe to validate before unwind and before allocating any chunks. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameFormatDescriptor { + pub version: u16, + pub pointer_width: PointerWidth, + pub alignment: u8, + pub flags: u16, + pub chunk_header_size: u32, + pub node_header_size: u32, + /// Bytes at the start of the root chunk's payload reserved for the + /// instrumented runtime header, saved globals, and exception scratch. + pub fixed_prefix_size: u32, +} + +impl FrameFormatDescriptor { + pub fn current(pointer_width: PointerWidth, fixed_prefix_size: u32) -> Self { + let chunk_header_size = chunk_header_size(pointer_width) + .expect("current linked chunk header must be representable"); + let node_header_size = frame_node_header_size(pointer_width) + .expect("current linked frame header must be representable"); + Self { + version: LINKED_FRAME_FORMAT_VERSION, + pointer_width, + alignment: RECORD_ALIGNMENT as u8, + flags: FORMAT_REQUIRED_FLAGS, + chunk_header_size: chunk_header_size as u32, + node_header_size: node_header_size as u32, + fixed_prefix_size, + } + } + + pub fn encode(self) -> [u8; FORMAT_DESCRIPTOR_SIZE] { + let mut encoded = [0; FORMAT_DESCRIPTOR_SIZE]; + encoded[0..4].copy_from_slice(&FORMAT_DESCRIPTOR_MAGIC); + encoded[4..6].copy_from_slice(&self.version.to_le_bytes()); + encoded[6..8].copy_from_slice(&(FORMAT_DESCRIPTOR_SIZE as u16).to_le_bytes()); + encoded[8] = self.pointer_width.bytes() as u8; + encoded[9] = self.alignment; + encoded[10..12].copy_from_slice(&self.flags.to_le_bytes()); + encoded[12..16].copy_from_slice(&self.chunk_header_size.to_le_bytes()); + encoded[16..20].copy_from_slice(&self.node_header_size.to_le_bytes()); + encoded[20..24].copy_from_slice(&self.fixed_prefix_size.to_le_bytes()); + encoded + } + + pub fn decode(encoded: &[u8]) -> Result { + if encoded.len() != FORMAT_DESCRIPTOR_SIZE { + return Err(MetadataError::InvalidLength { + actual: encoded.len(), + }); + } + if encoded[0..4] != FORMAT_DESCRIPTOR_MAGIC { + return Err(MetadataError::InvalidMagic); + } + + let version = u16::from_le_bytes([encoded[4], encoded[5]]); + if version != LINKED_FRAME_FORMAT_VERSION { + return Err(MetadataError::UnsupportedVersion { version }); + } + let declared_size = u16::from_le_bytes([encoded[6], encoded[7]]) as usize; + if declared_size != FORMAT_DESCRIPTOR_SIZE { + return Err(MetadataError::InvalidLength { + actual: declared_size, + }); + } + let pointer_width = match encoded[8] { + 4 => PointerWidth::Wasm32, + 8 => PointerWidth::Wasm64, + bytes => return Err(MetadataError::UnsupportedPointerWidth { bytes }), + }; + if encoded[9] != RECORD_ALIGNMENT as u8 { + return Err(MetadataError::UnsupportedAlignment { bytes: encoded[9] }); + } + let flags = u16::from_le_bytes([encoded[10], encoded[11]]); + if flags & !FORMAT_KNOWN_FLAGS != 0 { + return Err(MetadataError::UnknownFlags { flags }); + } + if flags & FORMAT_REQUIRED_FLAGS != FORMAT_REQUIRED_FLAGS { + return Err(MetadataError::MissingRequiredFlags { + flags: FORMAT_REQUIRED_FLAGS, + }); + } + let chunk_size = u32::from_le_bytes(encoded[12..16].try_into().unwrap()); + let node_size = u32::from_le_bytes(encoded[16..20].try_into().unwrap()); + let fixed_prefix_size = u32::from_le_bytes(encoded[20..24].try_into().unwrap()); + let expected = Self::current(pointer_width, fixed_prefix_size); + if chunk_size != expected.chunk_header_size || node_size != expected.node_header_size { + return Err(MetadataError::HeaderSizeMismatch); + } + + Ok(Self { + version, + pointer_width, + alignment: encoded[9], + flags, + chunk_header_size: chunk_size, + node_header_size: node_size, + fixed_prefix_size, + }) + } +} + +/// One successful suballocation inside a continuation chunk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FrameReservation { + pub node_offset: u64, + pub payload_offset: u64, + pub payload_size: u64, + pub node_size: u64, + pub next_used: u64, +} + +/// Outcome of trying to reserve one complete frame node in the active chunk. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReserveFrame { + Reserved(FrameReservation), + NeedsAnotherChunk { required_node_size: u64 }, +} + +/// Opaque identity for a pending frame reservation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReservationToken(u64); + +/// One chunk in a not-yet-emitted linked continuation plan. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PlannedChunk { + pub capacity: u64, + pub used: u64, +} + +/// A reservation which has not yet advanced its chunk's committed cursor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PendingFrameReservation { + pub token: ReservationToken, + pub chunk_index: usize, + pub frame: FrameReservation, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PendingFrame { + reservation: PendingFrameReservation, + created_chunk: bool, +} + +/// One frame published into the logical continuation chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CommittedFrame { + pub chunk_index: usize, + pub frame: FrameReservation, + /// Earlier frame in unwind order. Replay follows this link in reverse. + pub previous: Option, +} + +/// Transactional model for building a multi-chunk continuation. +/// +/// Unwind reserves a complete node before writing it. The chunk cursor moves +/// only when the caller commits that node after all payload writes succeed. +/// Consequently, a cancelled or failed reservation cannot expose a partial +/// frame to replay. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinkedContinuationPlan { + width: PointerWidth, + preferred_chunk_capacity: u64, + chunks: Vec, + committed: Vec, + pending: Option, + next_token: u64, +} + +impl LinkedContinuationPlan { + pub fn new(width: PointerWidth, preferred_chunk_capacity: u64) -> Self { + Self { + width, + preferred_chunk_capacity, + chunks: Vec::new(), + committed: Vec::new(), + pending: None, + next_token: 1, + } + } + + pub fn chunks(&self) -> &[PlannedChunk] { + &self.chunks + } + + pub fn committed_frames(&self) -> &[CommittedFrame] { + &self.committed + } + + pub fn pending(&self) -> Option { + self.pending.map(|pending| pending.reservation) + } + + /// Reserve a complete node without publishing it or advancing `used`. + pub fn reserve(&mut self, payload_size: u64) -> Result { + if self.pending.is_some() { + return Err(PlanError::PendingReservationExists); + } + + let next_token = self + .next_token + .checked_add(1) + .ok_or(PlanError::ReservationTokenExhausted)?; + let mut created_chunk = false; + + let reservation = if let Some(chunk) = self.chunks.last() { + match reserve_frame(self.width, chunk.capacity, chunk.used, payload_size)? { + ReserveFrame::Reserved(frame) => PendingFrameReservation { + token: ReservationToken(self.next_token), + chunk_index: self.chunks.len() - 1, + frame, + }, + ReserveFrame::NeedsAnotherChunk { .. } => { + created_chunk = true; + self.reserve_in_new_chunk(payload_size)? + } + } + } else { + created_chunk = true; + self.reserve_in_new_chunk(payload_size)? + }; + + self.next_token = next_token; + self.pending = Some(PendingFrame { + reservation, + created_chunk, + }); + Ok(reservation) + } + + fn reserve_in_new_chunk( + &mut self, + payload_size: u64, + ) -> Result { + let capacity = + chunk_capacity_for_frame(self.width, self.preferred_chunk_capacity, payload_size)?; + let used = chunk_header_size(self.width)?; + let frame = match reserve_frame(self.width, capacity, used, payload_size)? { + ReserveFrame::Reserved(frame) => frame, + ReserveFrame::NeedsAnotherChunk { .. } => { + unreachable!("chunk_capacity_for_frame must accommodate its requested frame") + } + }; + let chunk_index = self.chunks.len(); + self.chunks.push(PlannedChunk { capacity, used }); + Ok(PendingFrameReservation { + token: ReservationToken(self.next_token), + chunk_index, + frame, + }) + } + + /// Publish the pending frame and advance the active chunk cursor. + pub fn commit(&mut self, token: ReservationToken) -> Result { + let pending = self.validate_pending(token)?; + let previous = self.committed.len().checked_sub(1); + let committed = CommittedFrame { + chunk_index: pending.reservation.chunk_index, + frame: pending.reservation.frame, + previous, + }; + self.chunks[committed.chunk_index].used = committed.frame.next_used; + self.committed.push(committed); + self.pending = None; + Ok(committed) + } + + /// Discard the pending frame. A chunk created solely for this reservation + /// is also discarded, returning the plan to its pre-reservation shape. + pub fn cancel(&mut self, token: ReservationToken) -> Result<(), PlanError> { + let pending = self.validate_pending(token)?; + if pending.created_chunk { + debug_assert_eq!(pending.reservation.chunk_index + 1, self.chunks.len()); + self.chunks.pop(); + } + self.pending = None; + Ok(()) + } + + fn validate_pending(&self, token: ReservationToken) -> Result { + let pending = self.pending.ok_or(PlanError::NoPendingReservation)?; + if pending.reservation.token != token { + return Err(PlanError::ReservationTokenMismatch); + } + Ok(pending) + } + + /// Frames are committed inner-to-outer during unwind and replayed + /// outer-to-inner, so replay traverses the committed chain in reverse. + pub fn replay_order(&self) -> impl Iterator { + self.committed.iter().rev() + } +} + +fn checked_align_up(value: u64, alignment: u64) -> Result { + debug_assert!(alignment.is_power_of_two()); + value + .checked_add(alignment - 1) + .map(|n| n & !(alignment - 1)) + .ok_or(LayoutError::ArithmeticOverflow) +} + +fn check_pointer_width(width: PointerWidth, value: u64) -> Result { + if value > width.max_record_size() { + return Err(LayoutError::Wasm32AddressSpaceExceeded { required: value }); + } + Ok(value) +} + +/// Size of the version-1 chunk header: a fixed eight-byte magic/version/flags +/// prefix followed by six pointer-width fields (`root`, `previous`, `next`, +/// `capacity`, `used`, and the root's global committed tail`). Every address +/// is continuation-owned and may be rebased when a chain is aggregated. +pub fn chunk_header_size(width: PointerWidth) -> Result { + let pointer_fields = width + .bytes() + .checked_mul(6) + .ok_or(LayoutError::ArithmeticOverflow)?; + let raw = pointer_fields + .checked_add(8) + .ok_or(LayoutError::ArithmeticOverflow)?; + check_pointer_width(width, checked_align_up(raw, RECORD_ALIGNMENT)?) +} + +/// Size of the prospective node header: three pointer-width fields plus two +/// u32 fields, rounded to the record alignment. +pub fn frame_node_header_size(width: PointerWidth) -> Result { + let pointer_fields = width + .bytes() + .checked_mul(3) + .ok_or(LayoutError::ArithmeticOverflow)?; + let raw = pointer_fields + .checked_add(8) + .ok_or(LayoutError::ArithmeticOverflow)?; + check_pointer_width(width, checked_align_up(raw, RECORD_ALIGNMENT)?) +} + +/// Compute the complete contiguous node required for one serialized frame. +pub fn frame_node_layout( + width: PointerWidth, + payload_size: u64, +) -> Result { + let header_size = frame_node_header_size(width)?; + let node_size = checked_align_up( + header_size + .checked_add(payload_size) + .ok_or(LayoutError::ArithmeticOverflow)?, + RECORD_ALIGNMENT, + )?; + check_pointer_width(width, node_size)?; + Ok(FrameNodeLayout { + header_size, + payload_offset: header_size, + payload_size, + node_size, + }) +} + +/// Try to suballocate one complete frame node from the active chunk. +/// +/// Returning `NeedsAnotherChunk` is not an error and never exposes a partial +/// reservation. The future emitter must perform all frame/reference writes +/// only after receiving `Reserved`, then publish the node as COMMITTED last. +pub fn reserve_frame( + width: PointerWidth, + chunk_capacity: u64, + used: u64, + payload_size: u64, +) -> Result { + let header_size = chunk_header_size(width)?; + if used < header_size || used > chunk_capacity { + return Err(LayoutError::InvalidChunkCursor { + used, + capacity: chunk_capacity, + }); + } + + let layout = frame_node_layout(width, payload_size)?; + let remaining = chunk_capacity - used; + if layout.node_size > remaining { + return Ok(ReserveFrame::NeedsAnotherChunk { + required_node_size: layout.node_size, + }); + } + + let payload_offset = used + .checked_add(layout.payload_offset) + .ok_or(LayoutError::ArithmeticOverflow)?; + let next_used = used + .checked_add(layout.node_size) + .ok_or(LayoutError::ArithmeticOverflow)?; + check_pointer_width(width, next_used)?; + + Ok(ReserveFrame::Reserved(FrameReservation { + node_offset: used, + payload_offset, + payload_size, + node_size: layout.node_size, + next_used, + })) +} + +/// Choose a page-rounded chunk capacity that can hold its header and the +/// requested frame node. `preferred_capacity` is a growth/performance hint, +/// never a maximum. +pub fn chunk_capacity_for_frame( + width: PointerWidth, + preferred_capacity: u64, + payload_size: u64, +) -> Result { + let required = chunk_header_size(width)? + .checked_add(frame_node_layout(width, payload_size)?.node_size) + .ok_or(LayoutError::ArithmeticOverflow)?; + let preferred = preferred_capacity.max(WASM_PAGE_SIZE); + let capacity = checked_align_up(required.max(preferred), WASM_PAGE_SIZE)?; + check_pointer_width(width, capacity) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_follow_pointer_width_and_alignment() { + assert_eq!(chunk_header_size(PointerWidth::Wasm32), Ok(32)); + assert_eq!(chunk_header_size(PointerWidth::Wasm64), Ok(56)); + assert_eq!(frame_node_header_size(PointerWidth::Wasm32), Ok(24)); + assert_eq!(frame_node_header_size(PointerWidth::Wasm64), Ok(32)); + } + + #[test] + fn format_descriptor_round_trips_for_both_pointer_widths() { + for width in [PointerWidth::Wasm32, PointerWidth::Wasm64] { + let descriptor = FrameFormatDescriptor::current(width, 1234); + assert_eq!( + FrameFormatDescriptor::decode(&descriptor.encode()), + Ok(descriptor) + ); + } + } + + #[test] + fn format_descriptor_rejects_unknown_or_incomplete_contracts() { + let mut encoded = FrameFormatDescriptor::current(PointerWidth::Wasm32, 32).encode(); + encoded[10..12].copy_from_slice(&(1_u16 << 15).to_le_bytes()); + assert_eq!( + FrameFormatDescriptor::decode(&encoded), + Err(MetadataError::UnknownFlags { flags: 1 << 15 }) + ); + + encoded[10..12].copy_from_slice(&0_u16.to_le_bytes()); + assert_eq!( + FrameFormatDescriptor::decode(&encoded), + Err(MetadataError::MissingRequiredFlags { + flags: FORMAT_REQUIRED_FLAGS, + }) + ); + } + + #[test] + fn format_descriptor_rejects_layout_mismatch() { + let mut encoded = FrameFormatDescriptor::current(PointerWidth::Wasm64, 32).encode(); + encoded[12..16].copy_from_slice(&24_u32.to_le_bytes()); + assert_eq!( + FrameFormatDescriptor::decode(&encoded), + Err(MetadataError::HeaderSizeMismatch) + ); + } + + #[test] + fn reserves_exactly_to_the_chunk_boundary() { + let width = PointerWidth::Wasm32; + let header = chunk_header_size(width).unwrap(); + let node = frame_node_layout(width, 100).unwrap(); + let capacity = header + node.node_size; + assert_eq!( + reserve_frame(width, capacity, header, 100), + Ok(ReserveFrame::Reserved(FrameReservation { + node_offset: header, + payload_offset: header + node.payload_offset, + payload_size: 100, + node_size: node.node_size, + next_used: capacity, + })) + ); + } + + #[test] + fn one_byte_short_requests_another_chunk_without_partial_reservation() { + let width = PointerWidth::Wasm64; + let header = chunk_header_size(width).unwrap(); + let node = frame_node_layout(width, 100).unwrap(); + assert_eq!( + reserve_frame(width, header + node.node_size - 1, header, 100), + Ok(ReserveFrame::NeedsAnotherChunk { + required_node_size: node.node_size, + }) + ); + } + + #[test] + fn a_single_frame_can_span_multiple_wasm_pages() { + let payload_size = WASM_PAGE_SIZE + 29_000; + let layout = frame_node_layout(PointerWidth::Wasm32, payload_size).unwrap(); + assert!(layout.node_size > WASM_PAGE_SIZE); + assert_eq!(layout.node_size % RECORD_ALIGNMENT, 0); + + let capacity = + chunk_capacity_for_frame(PointerWidth::Wasm32, WASM_PAGE_SIZE, payload_size).unwrap(); + assert_eq!(capacity, 2 * WASM_PAGE_SIZE); + assert!(capacity >= chunk_header_size(PointerWidth::Wasm32).unwrap() + layout.node_size); + } + + #[test] + fn preferred_chunk_capacity_is_a_floor_not_a_frame_limit() { + let small = chunk_capacity_for_frame(PointerWidth::Wasm64, 4 * WASM_PAGE_SIZE, 32).unwrap(); + assert_eq!(small, 4 * WASM_PAGE_SIZE); + + let large = + chunk_capacity_for_frame(PointerWidth::Wasm64, WASM_PAGE_SIZE, 3 * WASM_PAGE_SIZE) + .unwrap(); + assert_eq!(large, 4 * WASM_PAGE_SIZE); + } + + #[test] + fn rejects_invalid_chunk_cursor() { + assert_eq!( + reserve_frame(PointerWidth::Wasm32, 100, 101, 8), + Err(LayoutError::InvalidChunkCursor { + used: 101, + capacity: 100, + }) + ); + assert_eq!( + reserve_frame(PointerWidth::Wasm32, 100, 0, 8), + Err(LayoutError::InvalidChunkCursor { + used: 0, + capacity: 100, + }) + ); + } + + #[test] + fn rejects_wasm32_node_larger_than_its_address_space() { + assert_eq!( + frame_node_layout(PointerWidth::Wasm32, u32::MAX as u64), + Err(LayoutError::Wasm32AddressSpaceExceeded { + required: (u32::MAX as u64 + 24 + 7) & !7, + }) + ); + } + + #[test] + fn rejects_rounding_overflow() { + assert_eq!( + chunk_capacity_for_frame(PointerWidth::Wasm64, WASM_PAGE_SIZE, u64::MAX), + Err(LayoutError::ArithmeticOverflow) + ); + } + + #[test] + fn committed_small_frames_share_a_chunk() { + let mut plan = LinkedContinuationPlan::new(PointerWidth::Wasm32, WASM_PAGE_SIZE); + let first = plan.reserve(100).unwrap(); + plan.commit(first.token).unwrap(); + let second = plan.reserve(200).unwrap(); + plan.commit(second.token).unwrap(); + + assert_eq!(plan.chunks().len(), 1); + assert_eq!(plan.committed_frames().len(), 2); + assert_eq!(plan.committed_frames()[1].previous, Some(0)); + assert_eq!( + plan.chunks()[0].used, + plan.committed_frames()[1].frame.next_used + ); + } + + #[test] + fn reservation_is_invisible_until_commit_and_cancel_reuses_space() { + let mut plan = LinkedContinuationPlan::new(PointerWidth::Wasm32, WASM_PAGE_SIZE); + let header = chunk_header_size(PointerWidth::Wasm32).unwrap(); + let first = plan.reserve(80).unwrap(); + + assert_eq!(plan.chunks()[0].used, header); + assert!(plan.committed_frames().is_empty()); + plan.cancel(first.token).unwrap(); + assert!(plan.chunks().is_empty()); + + let replacement = plan.reserve(80).unwrap(); + assert_eq!(replacement.frame.node_offset, header); + plan.commit(replacement.token).unwrap(); + } + + #[test] + fn only_one_reservation_can_be_pending() { + let mut plan = LinkedContinuationPlan::new(PointerWidth::Wasm32, WASM_PAGE_SIZE); + let pending = plan.reserve(16).unwrap(); + assert_eq!(plan.reserve(16), Err(PlanError::PendingReservationExists)); + assert_eq!(plan.pending(), Some(pending)); + } + + #[test] + fn token_mismatch_preserves_the_pending_reservation() { + let mut plan = LinkedContinuationPlan::new(PointerWidth::Wasm64, WASM_PAGE_SIZE); + let pending = plan.reserve(16).unwrap(); + let wrong = ReservationToken(pending.token.0 + 1); + + assert_eq!(plan.commit(wrong), Err(PlanError::ReservationTokenMismatch)); + assert_eq!(plan.cancel(wrong), Err(PlanError::ReservationTokenMismatch)); + assert_eq!(plan.pending(), Some(pending)); + plan.commit(pending.token).unwrap(); + } + + #[test] + fn oversized_frame_receives_a_multi_page_chunk() { + let mut plan = LinkedContinuationPlan::new(PointerWidth::Wasm32, WASM_PAGE_SIZE); + let pending = plan.reserve(WASM_PAGE_SIZE + 29_000).unwrap(); + + assert_eq!( + plan.chunks()[pending.chunk_index].capacity, + 2 * WASM_PAGE_SIZE + ); + plan.commit(pending.token).unwrap(); + } + + #[test] + fn full_active_chunk_causes_transactional_chunk_growth() { + let width = PointerWidth::Wasm32; + let header = chunk_header_size(width).unwrap(); + let first_payload = WASM_PAGE_SIZE - header - frame_node_header_size(width).unwrap(); + let mut plan = LinkedContinuationPlan::new(width, WASM_PAGE_SIZE); + let first = plan.reserve(first_payload).unwrap(); + plan.commit(first.token).unwrap(); + assert_eq!(plan.chunks()[0].used, WASM_PAGE_SIZE); + + let second = plan.reserve(8).unwrap(); + assert_eq!(second.chunk_index, 1); + assert_eq!(plan.chunks().len(), 2); + plan.cancel(second.token).unwrap(); + assert_eq!(plan.chunks().len(), 1); + } + + #[test] + fn replay_reverses_unwind_commit_order() { + let mut plan = LinkedContinuationPlan::new(PointerWidth::Wasm32, WASM_PAGE_SIZE); + for payload_size in [11, 22, 33] { + let pending = plan.reserve(payload_size).unwrap(); + plan.commit(pending.token).unwrap(); + } + + let replayed: Vec = plan + .replay_order() + .map(|committed| committed.frame.payload_size) + .collect(); + assert_eq!(replayed, vec![33, 22, 11]); + } +} diff --git a/crates/fork-instrument/src/runtime.rs b/crates/fork-instrument/src/runtime.rs index 9d8b23bc00..950ec2e5d7 100644 --- a/crates/fork-instrument/src/runtime.rs +++ b/crates/fork-instrument/src/runtime.rs @@ -5,16 +5,19 @@ //! //! - Two mutable globals: `_wpk_fork_state` (i32) and `_wpk_fork_buf` //! (i32 for wasm32, i64 for wasm64). -//! - Five exported control functions: `wpk_fork_unwind_begin`, +//! - Seven exported control functions: `wpk_fork_unwind_begin`, //! `wpk_fork_unwind_end`, `wpk_fork_rewind_begin`, -//! `wpk_fork_rewind_end`, `wpk_fork_state`. +//! `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 +//! replay variable-sized frame nodes. //! //! ## Phase 4e additions: saved-globals area //! //! To fork correctly, the child process's Wasm instance must see the //! same mutable globals as the parent at fork time. `wpk_fork_unwind_begin` //! takes a snapshot of every pre-existing mutable *scalar* global -//! into the save buffer, and `wpk_fork_rewind_begin` reloads it. The +//! into the root chunk's fixed prefix, and `wpk_fork_rewind_begin` reloads it. The //! two runtime-owned globals (`_wpk_fork_state`, `_wpk_fork_buf`) are //! excluded: they are set explicitly by each begin function to the //! known transition values. @@ -22,20 +25,22 @@ //! Ref-typed mutable globals (funcref/externref/exnref) require //! auxiliary tables (Phase 4f); this phase skips them. //! -//! Buffer layout (all offsets byte-exact; `P` is pointer width — +//! Module-prefix layout (all offsets byte-exact; `P` is pointer width — //! 4 bytes on wasm32, 8 on wasm64; `B` is the B1 plain-catch scratch //! reservation, 0 when no fork-path function has a plain catch): //! //! ```text -//! +0 P current_pos Absolute address of next free frame byte -//! +P P end_pos One past end of buffer +//! +0 P active_frame Current frame payload during save/replay +//! +P P reserved Reserved pointer word //! +2P N saved_globals[] Mutable scalar globals, declaration order //! +2P+N B b1_scratch[] Per-arm scratch tuples (Stage 1 B1) -//! +2P+N+B - frame data Grows upward from here +//! +2P+N+B 16 abort_selector Live-frame call-site selector //! ``` //! -//! `frames_start_offset` in [`Runtime`] exposes `2P + N + B` so the -//! runtime can initialize `current_pos` to `buf + frames_start_offset`. +//! `frames_start_offset` in [`Runtime`] exposes the abort-selector offset +//! `2P + N + B`; `fixed_prefix_size` includes the following 16 bytes. In the +//! linked runtime, frame payloads live after per-node headers in host-managed +//! chunks rather than directly after this prefix. //! `b1_scratch_base` exposes `2P + N` (== `frames_start_offset` when //! `B == 0`) and `b1_scratch_size` exposes `B` (rounded up to 8). @@ -49,6 +54,8 @@ use walrus::{ pub const STATE_NORMAL: i32 = 0; pub const STATE_UNWINDING: i32 = 1; pub const STATE_REWINDING: i32 = 2; +pub const STATE_ABORT_UNWINDING: i32 = 3; +pub const ABORT_SELECTOR_SIZE: u32 = 16; /// Names for the runtime globals and exported control functions. /// Centralized so the rest of the crate doesn't hardcode spellings. @@ -56,11 +63,17 @@ pub mod names { pub const GLOBAL_STATE: &str = "_wpk_fork_state"; pub const GLOBAL_BUF: &str = "_wpk_fork_buf"; - pub const EXPORT_UNWIND_BEGIN: &str = "wpk_fork_unwind_begin"; - pub const EXPORT_UNWIND_END: &str = "wpk_fork_unwind_end"; - pub const EXPORT_REWIND_BEGIN: &str = "wpk_fork_rewind_begin"; - pub const EXPORT_REWIND_END: &str = "wpk_fork_rewind_end"; - pub const EXPORT_STATE: &str = "wpk_fork_state"; + pub const EXPORT_UNWIND_BEGIN: &str = wasm_posix_shared::abi::WPK_FORK_EXPORT_UNWIND_BEGIN; + pub const EXPORT_UNWIND_END: &str = wasm_posix_shared::abi::WPK_FORK_EXPORT_UNWIND_END; + pub const EXPORT_REWIND_BEGIN: &str = wasm_posix_shared::abi::WPK_FORK_EXPORT_REWIND_BEGIN; + pub const EXPORT_REWIND_END: &str = wasm_posix_shared::abi::WPK_FORK_EXPORT_REWIND_END; + pub const EXPORT_ABORT_BEGIN: &str = wasm_posix_shared::abi::WPK_FORK_EXPORT_ABORT_BEGIN; + pub const EXPORT_ABORT_END: &str = wasm_posix_shared::abi::WPK_FORK_EXPORT_ABORT_END; + pub const EXPORT_STATE: &str = wasm_posix_shared::abi::WPK_FORK_EXPORT_STATE; + + 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; } /// Metadata about a saved mutable global. @@ -85,21 +98,33 @@ pub struct Runtime { pub unwind_end: FunctionId, pub rewind_begin: FunctionId, pub rewind_end: FunctionId, + pub abort_begin: FunctionId, + pub abort_end: FunctionId, pub state: FunctionId, + /// Host-managed linked-frame hooks. All three are present together for + /// the scalable format and absent for the legacy contiguous format. + pub frame_reserve: Option, + pub frame_commit: Option, + pub frame_next: Option, + /// Mutable scalar globals that `wpk_fork_unwind_begin` snapshots /// and `wpk_fork_rewind_begin` restores. Declaration order. pub saved_globals: Vec, - /// Byte offset at which frame data begins. Includes any space + /// Offset of the linked runtime's abort selector. Includes any space /// reserved for B1's plain-catch scratch area /// (see `b1_scratch_base` / `b1_scratch_size`). - /// `wpk_fork_unwind_begin` adds the save-buffer base to this value - /// before storing the absolute `current_pos` pointer at offset 0. - /// The buffer must be sized such that - /// `frames_start_offset + sum_of_frame_sizes <= buffer_size`. + /// `wpk_fork_unwind_begin` adds the module-buffer base to this value for + /// the initial active-frame word. Linked postambles replace that word with + /// the payload returned by the reserve hook before writing any frame data. pub frames_start_offset: u32, + /// Host-visible fixed prefix. Linked runtimes reserve one selector-sized + /// area after `frames_start_offset` for the still-live activation that + /// reverses a failed partial unwind. + pub fixed_prefix_size: u32, + /// Stage 1 (B1): byte offset at which the plain-catch scratch /// area begins. Equals `2P + N` (header + saved_globals). pub b1_scratch_base: u32, @@ -165,6 +190,18 @@ fn zero_const(ptr_ty: ValType) -> ConstExpr { /// the host-visible offset. Computing the B1 plan first and passing /// the size in keeps everything consistent. pub fn inject_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { + inject_runtime_with_frame_storage(module, b1_scratch_size, false) +} + +pub fn inject_linked_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { + inject_runtime_with_frame_storage(module, b1_scratch_size, true) +} + +fn inject_runtime_with_frame_storage( + module: &mut Module, + b1_scratch_size: u32, + linked_frames: bool, +) -> Runtime { let ptr_ty = ptr_type(module); let memory = module.memories.iter().next().map(|m| m.id()); @@ -211,6 +248,12 @@ pub fn inject_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { let b1_scratch_base = next_off; let aligned_b1_size = align_up_8(b1_scratch_size); let frames_start_offset = b1_scratch_base + aligned_b1_size; + let fixed_prefix_size = frames_start_offset + + if linked_frames { + ABORT_SELECTOR_SIZE + } else { + 0 + }; // Invariant: `b1_scratch_base + b1_scratch_size == frames_start_offset` // holds by construction here — `frames_start_offset` is defined as // the sum on the previous line, and `b1_scratch_size` is stored as @@ -236,6 +279,21 @@ pub fn inject_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { zero_const(ptr_ty), ); + let (frame_reserve, frame_commit, frame_next) = 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 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)) + } else { + (None, None, None) + }; + // --- Control functions --- let unwind_begin = emit_unwind_begin( module, @@ -254,19 +312,28 @@ pub fn inject_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { buf_global, memory, &saved_globals, + STATE_REWINDING, ); let rewind_end = emit_end_fn(module, state_global); + let abort_begin = emit_rewind_begin( + module, + ptr_ty, + state_global, + buf_global, + memory, + &saved_globals, + STATE_ABORT_UNWINDING, + ); + let abort_end = emit_end_fn(module, state_global); let state = emit_state_fn(module, state_global); // --- Exports --- - module - .exports - .add(names::EXPORT_UNWIND_BEGIN, unwind_begin); + module.exports.add(names::EXPORT_UNWIND_BEGIN, unwind_begin); module.exports.add(names::EXPORT_UNWIND_END, unwind_end); - module - .exports - .add(names::EXPORT_REWIND_BEGIN, rewind_begin); + module.exports.add(names::EXPORT_REWIND_BEGIN, rewind_begin); module.exports.add(names::EXPORT_REWIND_END, rewind_end); + module.exports.add(names::EXPORT_ABORT_BEGIN, abort_begin); + module.exports.add(names::EXPORT_ABORT_END, abort_end); module.exports.add(names::EXPORT_STATE, state); module.globals.get_mut(state_global).name = Some(names::GLOBAL_STATE.into()); @@ -275,6 +342,8 @@ pub fn inject_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { module.funcs.get_mut(unwind_end).name = Some(names::EXPORT_UNWIND_END.into()); module.funcs.get_mut(rewind_begin).name = Some(names::EXPORT_REWIND_BEGIN.into()); module.funcs.get_mut(rewind_end).name = Some(names::EXPORT_REWIND_END.into()); + module.funcs.get_mut(abort_begin).name = Some(names::EXPORT_ABORT_BEGIN.into()); + module.funcs.get_mut(abort_end).name = Some(names::EXPORT_ABORT_END.into()); module.funcs.get_mut(state).name = Some(names::EXPORT_STATE.into()); Runtime { @@ -285,9 +354,15 @@ pub fn inject_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { unwind_end, rewind_begin, rewind_end, + abort_begin, + abort_end, state, + frame_reserve, + frame_commit, + frame_next, saved_globals, frames_start_offset, + fixed_prefix_size, b1_scratch_base, b1_scratch_size: aligned_b1_size, } @@ -296,8 +371,8 @@ pub fn inject_runtime(module: &mut Module, b1_scratch_size: u32) -> Runtime { /// Emit `wpk_fork_unwind_begin(buf: ptr) -> ()`: /// 1. `_wpk_fork_state := UNWINDING` /// 2. `_wpk_fork_buf := buf` -/// 3. `*(buf + 0) := buf + frames_start_offset` — seed the absolute -/// `current_pos` pointer while keeping the host buffer-geometry-agnostic. +/// 3. `*(buf + 0) := buf + frames_start_offset` — seed the active-frame word; +/// linked postambles replace it with each reserved payload address. /// 4. For each saved global `g` at offset `off`: /// `*(buf + off) = g` /// @@ -325,11 +400,9 @@ fn emit_unwind_begin( .global_set(buf_global); if let Some(mem) = memory { - // Step 3: seed current_pos at buf + 0 with the absolute frame - // start address. Frame save/restore treats current_pos as a - // linear-memory pointer, so storing only the relative offset - // would make every pthread instance share the same low-memory - // frame payload. + // Step 3: seed the active-frame word at buf + 0. The linked + // emitter overwrites it with the host-reserved payload before any + // frame write; legacy direct-runtime tests retain cursor behavior. body.local_get(buf_param); match ptr_ty { ValType::I32 => { @@ -378,13 +451,14 @@ fn emit_rewind_begin( buf_global: GlobalId, memory: Option, saved_globals: &[SavedGlobal], + state: i32, ) -> FunctionId { let mut builder = FunctionBuilder::new(&mut module.types, &[ptr_ty], &[]); let buf_param = module.locals.add(ptr_ty); { let mut body = builder.func_body(); - body.i32_const(STATE_REWINDING) + body.i32_const(state) .global_set(state_global) .local_get(buf_param) .global_set(buf_global); @@ -406,16 +480,14 @@ fn emit_save_globals( saved_globals: &[SavedGlobal], ) { for sg in saved_globals { - body.global_get(buf_global) - .global_get(sg.id) - .store( - memory, - store_kind_for(sg.ty), - MemArg { - align: natural_align(sg.ty), - offset: sg.offset as u64, - }, - ); + body.global_get(buf_global).global_get(sg.id).store( + memory, + store_kind_for(sg.ty), + MemArg { + align: natural_align(sg.ty), + offset: sg.offset as u64, + }, + ); } } diff --git a/crates/fork-instrument/tests/instrument.rs b/crates/fork-instrument/tests/instrument.rs index d69a04bafd..328a34f123 100644 --- a/crates/fork-instrument/tests/instrument.rs +++ b/crates/fork-instrument/tests/instrument.rs @@ -12,16 +12,16 @@ //! if-else guard that fires on `(NORMAL) || (REWIND && call_idx == //! N)`; Phase 4g gates state-mutating ops during REWIND replay. //! -//! Both schemes share the same frame layout and the entry-block shape -//! `[preamble-ifelse, Block($unwind_save), postamble]`. +//! Both schemes share the same frame layout and a result-typed restart loop +//! containing `[preamble-ifelse, Block($unwind_save), postamble]`. use std::collections::HashSet; use fork_instrument::runtime::names as runtime_names; -use fork_instrument::{instrument, Options}; +use fork_instrument::{Options, instrument}; use walrus::{ - ir::{self, Instr, InstrSeqId}, ExportItem, FunctionId, FunctionKind, LocalFunction, Module, + ir::{self, Instr, InstrSeqId}, }; // --- Helpers ---------------------------------------------------------- @@ -57,9 +57,18 @@ 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() + } +} + fn entry_instr_kinds(module: &Module, id: FunctionId) -> Vec { let f = local_func(module, id); - f.block(f.entry_block()) + f.block(logical_entry_seq(f)) .instrs .iter() .map(|(i, _)| InstrKind::of(i)) @@ -75,13 +84,11 @@ fn seq_kinds(module: &Module, func_id: FunctionId, seq_id: InstrSeqId) -> Vec InstrSeqId { let f = local_func(module, id); let blocks: Vec = f - .block(f.entry_block()) + .block(logical_entry_seq(f)) .instrs .iter() .filter_map(|(i, _)| match i { @@ -109,6 +116,7 @@ enum InstrKind { GlobalGet, LocalGet, LocalSet, + Unop, Binop, IfElse, BrIf, @@ -129,6 +137,7 @@ impl InstrKind { Instr::GlobalGet(_) => InstrKind::GlobalGet, Instr::LocalGet(_) => InstrKind::LocalGet, Instr::LocalSet(_) => InstrKind::LocalSet, + Instr::Unop(_) => InstrKind::Unop, Instr::Binop(_) => InstrKind::Binop, Instr::IfElse(_) => InstrKind::IfElse, Instr::BrIf(_) => InstrKind::BrIf, @@ -176,7 +185,7 @@ fn entry_preamble_and_postamble( func_id: FunctionId, ) -> (InstrSeqId, InstrSeqId, usize) { let f = local_func(module, func_id); - let entry = f.block(f.entry_block()); + let entry = f.block(logical_entry_seq(f)); let mut preamble_then: Option = None; let mut wrapper: Option = None; @@ -387,12 +396,12 @@ fn direct_caller_entry_shape_is_preamble_wrapper_postamble() { let caller = func_by_name(&module, "caller"); let kinds = entry_instr_kinds(&module, caller); - // Entry opens with the preamble's `if state == REWINDING` check. + // The restart-loop body opens with the replay-state preamble check. assert!( matches!(kinds.first(), Some(InstrKind::GlobalGet)), - "entry should start with GlobalGet (state) for REWINDING check: {kinds:?}", + "restart loop should start with GlobalGet (state) for replay check: {kinds:?}", ); - // Exactly one wrapper Block ($unwind_save). + // Exactly one wrapper Block ($unwind_save) inside the restart loop. assert_eq!( kinds.iter().filter(|k| **k == InstrKind::Block).count(), 1, @@ -519,7 +528,7 @@ fn multivalue_return_wraps_and_validates() { #[test] fn instrument_functions_returns_rewritten_set() { use fork_instrument::call_graph; - use fork_instrument::instrument::{instrument_functions, B1ScratchPlan}; + use fork_instrument::instrument::{B1ScratchPlan, instrument_functions}; use fork_instrument::runtime::inject_runtime; let bytes = wat::parse_str(FIXTURE_TRANSITIVE).unwrap(); @@ -839,6 +848,15 @@ fn two_calls_assign_sequential_call_idx() { let caller = func_by_name(&module, "caller"); let _unwind_save = entry_wrapper_seq(&module, caller); let f = local_func(&module, caller); + let reserve = module + .imports + .iter() + .find(|import| import.name == "__wpk_fork_frame_reserve") + .and_then(|import| match import.kind { + walrus::ImportKind::Function(id) => Some(id), + _ => None, + }) + .expect("linked frame reserve import"); // Count Const values immediately preceding stores to frame.call_index. fn walk_seqs(f: &LocalFunction, seq: InstrSeqId, visit: &mut F) { @@ -851,8 +869,15 @@ fn two_calls_assign_sequential_call_idx() { } let mut idxs: Vec = Vec::new(); + let mut reserve_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 { @@ -871,7 +896,12 @@ fn two_calls_assign_sequential_call_idx() { // inner $POST_1 body has call 0's post-sequence. Sort before // asserting the set of assigned indices. idxs.sort(); - assert_eq!(idxs, vec![0, 1], "call_idx should count up from 0 per site"); + assert_eq!(reserve_calls, 2, "each call site should reserve one frame"); + assert_eq!( + idxs, + vec![0, 0, 1, 1], + "each call_idx should appear in its committed frame and abort scratch selector", + ); } #[test] @@ -928,17 +958,20 @@ fn preamble_starts_with_rewinding_state_check() { let kinds = entry_instr_kinds(&module, caller); assert_eq!( - &kinds[..4], + &kinds[..7], &[ InstrKind::GlobalGet, InstrKind::Const, InstrKind::Binop, + InstrKind::LocalGet, + InstrKind::Unop, + InstrKind::Binop, InstrKind::IfElse, ], ); let f = local_func(&module, caller); - let entry = f.block(f.entry_block()); + 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:?}"), @@ -950,7 +983,7 @@ fn preamble_starts_with_rewinding_state_check() { } #[test] -fn preamble_then_moves_cursor_to_current_frame() { +fn preamble_then_requests_next_linked_frame() { let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); @@ -961,17 +994,15 @@ fn preamble_then_moves_cursor_to_current_frame() { kinds, vec![ InstrKind::GlobalGet, // buf store address - InstrKind::GlobalGet, // buf load address - InstrKind::Other, // Load current_pos InstrKind::Const, // frame_size - InstrKind::Binop, // sub to current frame base - InstrKind::Other, // Store new current_pos/current frame + InstrKind::Call, // __wpk_fork_frame_next + InstrKind::Other, // Store current frame pointer ], ); } #[test] -fn postamble_writes_frame_header_and_bumps_current_pos() { +fn postamble_writes_and_commits_the_reserved_linked_frame() { let bytes = instrument_wat(FIXTURE_DIRECT_CALLER); let module = Module::from_buffer(&bytes).unwrap(); let caller = func_by_name(&module, "caller"); @@ -990,11 +1021,8 @@ fn postamble_writes_frame_header_and_bumps_current_pos() { InstrKind::Const, InstrKind::Other, // Store packed zero catch_region_id + exnref_slot InstrKind::GlobalGet, - InstrKind::GlobalGet, InstrKind::Other, // Load current frame - InstrKind::Const, - InstrKind::Binop, - InstrKind::Other, // Store new current_pos + InstrKind::Call, // __wpk_fork_frame_commit InstrKind::Const, // default return value ]; assert_eq!(postamble, expected); @@ -1078,15 +1106,16 @@ fn postamble_serializes_user_scalar_locals() { let postamble = &kinds[postamble_start..]; // Postamble with one user local: - // 4 current-frame pointer loads + 4 stores (func_index, - // packed zero catch fields, user_x, new current_pos) = 8 Others. + // 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. let other_count = postamble .iter() .filter(|k| matches!(k, InstrKind::Other)) .count(); assert_eq!( - other_count, 8, - "postamble should have 4 frame loads + 4 stores (header 2 + user 1 + bump 1): {postamble:?}", + other_count, 7, + "postamble should load/store the active payload and serialize its fields: {postamble:?}", ); } diff --git a/crates/fork-instrument/tests/large_dispatcher.rs b/crates/fork-instrument/tests/large_dispatcher.rs index 8702998a58..c1ebe5a005 100644 --- a/crates/fork-instrument/tests/large_dispatcher.rs +++ b/crates/fork-instrument/tests/large_dispatcher.rs @@ -159,11 +159,7 @@ fn dispatcher_call_count(bytes: &[u8]) -> usize { panic!("dispatcher should be local"); }; let mut count = 0usize; - fn walk( - func: &LocalFunction, - seq: walrus::ir::InstrSeqId, - count: &mut usize, - ) { + fn walk(func: &LocalFunction, seq: walrus::ir::InstrSeqId, count: &mut usize) { for (instr, _) in &func.block(seq).instrs { if matches!(instr, Instr::Call(_) | Instr::CallIndirect(_)) { *count += 1; @@ -186,12 +182,16 @@ fn dispatcher_call_count(bytes: &[u8]) -> usize { count } -/// `instrument_one_function_switch` shapes the entry block as -/// `[preamble-if/else, Block($unwind_save), postamble…]`, so the only -/// `Block(_)` at entry level is `$unwind_save` itself. +/// `instrument_one_function_switch` places the preamble, unwind-save block, +/// and postamble inside one result-typed restart loop. 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 blocks: Vec = local - .block(local.entry_block()) + .block(restart) .instrs .iter() .filter_map(|(i, _)| match i { @@ -199,7 +199,11 @@ fn dispatcher_unwind_save(local: &LocalFunction) -> InstrSeqId { _ => None, }) .collect(); - assert_eq!(blocks.len(), 1, "expected one top-level Block in entry"); + assert_eq!( + blocks.len(), + 1, + "expected one unwind-save Block in restart loop" + ); blocks[0] } @@ -382,8 +386,8 @@ fn bucketed_depth_indirect_dispatcher_passes_v8_limit() { /// `$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 no other direct `br`s, so "every Br → $unwind_save" -/// pins the invariant without pattern-matching the surrounding +/// 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. /// /// N=33 straddles `BUCKET_SIZE=32` to force one full leaf + one @@ -407,18 +411,29 @@ 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 targets = collect_br_targets(local); - assert!( - !targets.is_empty(), - "{label} N={n}: dispatcher has no direct Br", + assert_eq!( + targets + .iter() + .filter(|&&target| target == unwind_save) + .count(), + n, + "{label} N={n}: each call site must branch to unwind-save after commit", ); - for (idx, target) in targets.iter().enumerate() { - assert_eq!( - *target, unwind_save, - "{label} N={n}: Br #{idx} targets {target:?}, expected $unwind_save ({unwind_save:?})", - ); - } + assert_eq!( + targets + .iter() + .filter(|&&target| target == restart_loop) + .count(), + n, + "{label} N={n}: each call site must branch to restart on allocation failure", + ); + assert_eq!(targets.len(), 2 * n, "{label} N={n}: unexpected Br target"); } } } diff --git a/crates/fork-instrument/tests/runtime.rs b/crates/fork-instrument/tests/runtime.rs index d2e76dd212..c463af56a4 100644 --- a/crates/fork-instrument/tests/runtime.rs +++ b/crates/fork-instrument/tests/runtime.rs @@ -1,6 +1,6 @@ //! Tests for Phase 4a: runtime injection. //! -//! After instrumentation, every module must expose the five control +//! After instrumentation, every module must expose the seven control //! exports with the documented ABI. We verify this by: //! //! - Re-parsing the instrumented module with walrus. @@ -11,6 +11,9 @@ //! - Independently validating via wasmparser that the emitted module //! is well-formed. +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, @@ -25,9 +28,8 @@ fn instrument_wat(wat_src: &str) -> Vec { } fn validate(bytes: &[u8]) { - 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).expect("valid wasm"); } @@ -73,6 +75,58 @@ fn instrumented_module_validates() { validate(&bytes); } +#[test] +fn linked_runtime_imports_transaction_hooks_and_emits_exact_prefix_metadata() { + let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); + let module = Module::from_buffer(&bytes).unwrap(); + for name in [ + names::IMPORT_FRAME_RESERVE, + names::IMPORT_FRAME_COMMIT, + names::IMPORT_FRAME_NEXT, + ] { + assert!( + module + .imports + .iter() + .any(|import| import.module == "env" && import.name == name), + "missing linked continuation import {name}", + ); + } + + let descriptors: Vec<_> = Parser::new(0) + .parse_all(&bytes) + .filter_map(|payload| match payload.expect("parse payload") { + Payload::CustomSection(section) if section.name() == LINKED_FRAME_FORMAT_SECTION => { + Some(FrameFormatDescriptor::decode(section.data()).unwrap()) + } + _ => None, + }) + .collect(); + assert_eq!( + descriptors, + vec![FrameFormatDescriptor::current(PointerWidth::Wasm32, 24)], + ); +} + +#[test] +fn module_without_fork_seed_does_not_import_linked_storage_hooks() { + let bytes = instrument_wat("(module (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, + ] { + assert!( + !module + .imports + .iter() + .any(|import| import.module == "env" && import.name == name), + "inert module unexpectedly imports linked continuation hook {name}", + ); + } +} + #[test] fn marks_dlopen_main_indirect_boundary_separately() { let wat = r#" @@ -205,6 +259,16 @@ fn exports_rewind_begin_taking_ptr() { assert_eq!(results, Vec::::new()); } +#[test] +fn exports_abort_begin_taking_ptr_and_abort_end_taking_no_args() { + let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); + let module = Module::from_buffer(&bytes).unwrap(); + let begin = export_function_id(&module, names::EXPORT_ABORT_BEGIN); + let end = export_function_id(&module, names::EXPORT_ABORT_END); + assert_eq!(func_signature(&module, begin), (vec![ValType::I32], vec![])); + assert_eq!(func_signature(&module, end), (vec![], vec![])); +} + #[test] fn exports_state_returning_i32() { let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); @@ -216,7 +280,7 @@ fn exports_state_returning_i32() { } #[test] -fn all_five_control_exports_present() { +fn all_seven_control_exports_present() { let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); let module = Module::from_buffer(&bytes).unwrap(); @@ -225,6 +289,8 @@ fn all_five_control_exports_present() { names::EXPORT_UNWIND_END, names::EXPORT_REWIND_BEGIN, names::EXPORT_REWIND_END, + names::EXPORT_ABORT_BEGIN, + names::EXPORT_ABORT_END, names::EXPORT_STATE, ] { assert!( @@ -243,10 +309,7 @@ use walrus::ir::Instr; /// Helper: count `Store` / `Load` instructions in the body of the /// named export by re-parsing the instrumented module. -fn export_body_instr_counts( - module: &Module, - export: &str, -) -> (usize, usize) { +fn export_body_instr_counts(module: &Module, export: &str) -> (usize, usize) { let id = match module .exports .iter() @@ -292,8 +355,7 @@ fn unwind_begin_stores_one_per_saved_global() { // state+buf globals are added *after* the scan so they are also // excluded. Plus Phase 7 Task 1 adds one store for `current_pos` at // buf+0. Expected: 1 (current_pos) + 2 (saved globals) = 3 stores. - let (stores, loads) = - export_body_instr_counts(&module, names::EXPORT_UNWIND_BEGIN); + let (stores, loads) = export_body_instr_counts(&module, names::EXPORT_UNWIND_BEGIN); assert_eq!( stores, 3, "unwind_begin should store current_pos + one per saved global", @@ -306,8 +368,7 @@ fn rewind_begin_loads_one_per_saved_global() { let bytes = instrument_wat(MODULE_WITH_EXTRA_GLOBAL); let module = Module::from_buffer(&bytes).unwrap(); - let (stores, loads) = - export_body_instr_counts(&module, names::EXPORT_REWIND_BEGIN); + let (stores, loads) = export_body_instr_counts(&module, names::EXPORT_REWIND_BEGIN); assert_eq!(loads, 2, "rewind_begin should load each saved global"); assert_eq!(stores, 0, "rewind_begin never writes the save buffer"); } @@ -455,10 +516,9 @@ fn unwind_begin_writes_absolute_frames_start_wasm32() { let offset_instr = &instrs[store_idx - 2]; match offset_instr { Instr::Const(c) => match c.value { - walrus::ir::Value::I32(v) => assert_eq!( - v, 8, - "wasm32 empty-globals frames_start_offset is 2*4 = 8", - ), + walrus::ir::Value::I32(v) => { + assert_eq!(v, 8, "wasm32 empty-globals frames_start_offset is 2*4 = 8",) + } other => panic!("expected I32 const, got {other:?}"), }, other => panic!("expected frame offset const before add, got {other:?}"), diff --git a/crates/fork-instrument/tests/switch_dispatch.rs b/crates/fork-instrument/tests/switch_dispatch.rs index 9c259ead6b..870438911d 100644 --- a/crates/fork-instrument/tests/switch_dispatch.rs +++ b/crates/fork-instrument/tests/switch_dispatch.rs @@ -9,8 +9,8 @@ //! - **posix_spawn-class**: code between call sites must NOT re-execute, //! including shadow-stack manipulation. -use fork_instrument::{instrument, Options}; -use walrus::{ir::*, FunctionId, FunctionKind, ImportKind, LocalFunction, Module}; +use fork_instrument::{Options, instrument}; +use walrus::{FunctionId, FunctionKind, ImportKind, LocalFunction, Module, ir::*}; fn validate(bytes: &[u8]) { let mut validator = @@ -287,8 +287,9 @@ 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, 1, - "no-catch top-level fork path should declare only the original local; \ + 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 \ unconditional catch metadata locals would raise this count:\n{caller}" ); @@ -321,9 +322,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, 0, - "top-level indirect call with a pure table index should need no arg, \ - frame_ptr, or call_idx locals:\n{caller}" + 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}" ); } @@ -338,8 +339,9 @@ 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, 2, - "nested block dispatch should retain only the two source locals; \ + 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}" ); } @@ -366,10 +368,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, 0, + locals, 1, "nested if/else dispatch should replay a pure condition without cond_swap; \ - params are not declared locals, and frame_ptr/call_idx must be loaded from \ - the frame:\n{main}" + 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}" ); } @@ -401,17 +403,17 @@ 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, 0, + locals, 1, "PR701-shaped pure condition and recursive arg should not allocate \ - arg-spill or condition/carryover locals:\n{walk}" + arg-spill or condition/carryover locals beyond abort_live_frame:\n{walk}" ); assert!( - walk.contains("local.get 0\n i32.eqz\n global.get $_wpk_fork_state"), + walk.contains("local.get 0\n i32.eqz\n global.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"), + walk.contains("local.get 0\n i32.const 1\n i32.sub\n call $walk"), "recursive call landing should replay pure depth - 1 argument tail \ before the call:\n{walk}" ); diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index dddf275fa2..70ffc501ed 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -10,7 +10,7 @@ //! - Environment (variable): env var strings //! - CWD (variable): current working directory bytes //! - Rlimits (256 bytes): 16 pairs of u64 -//! - Terminal (56 bytes): flags, control chars, window size +//! - Terminal: flags, control chars, window size, session and foreground pgrp //! - Program break (4 bytes): current brk value //! - Memory layout metadata (20 bytes): initial brk, max addr, brk limit, //! mmap base, reserved prefix @@ -21,7 +21,9 @@ extern crate alloc; use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; use wasm_posix_shared::Errno; -use wasm_posix_shared::fd_flags::{FD_CLOEXEC, FD_CLOFORK}; +#[cfg(test)] +use wasm_posix_shared::fd_flags::FD_CLOEXEC; +use wasm_posix_shared::fd_flags::FD_CLOFORK; use crate::fd::{FdEntry, FdTable, OpenFileDescRef}; use crate::lock::{FileId, KernelFileKind, OfdId}; @@ -33,10 +35,12 @@ use crate::socket::SocketTable; use crate::terminal::{NCCS, TerminalState, WinSize}; const FORK_MAGIC: u32 = 0x464F524B; // "FORK" +#[cfg(test)] const EXEC_MAGIC: u32 = 0x45584543; // "EXEC" -// v12 gives every serialized OFD a machine-wide identity and carries its -// optional stable file-object identity across fork and legacy exec. -const FORK_VERSION: u32 = 12; +// 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; // Bounds for deserialization to prevent OOM from malformed buffers. const MAX_FDS: u32 = 65536; @@ -51,7 +55,9 @@ const MAX_SOCKET_STRING_LEN: usize = 256; const MAX_IPV4_MULTICAST_MEMBERSHIPS: usize = 4096; const MAX_IPV4_MULTICAST_SOURCES: usize = 4096; const MAX_DIRECTED_SIGNAL_QUEUE: u32 = 65536; +#[cfg(test)] const INITIAL_EXEC_STATE_BUFFER_LEN: usize = 64 * 1024; +#[cfg(test)] const MAX_EXEC_STATE_BUFFER_LEN: usize = 4 * 1024 * 1024; // ── Writer helper ─────────────────────────────────────────────────────────── @@ -291,7 +297,8 @@ fn read_directed_signal_state(r: &mut Reader<'_>) -> Result = state .rt_queue .iter() @@ -482,7 +489,7 @@ fn u32_to_file_type(v: u32) -> Result { // ── Advisory-lock identity encoding ─────────────────────────────────────── // Keep the optional FileId representation compact and explicit. These tags -// are part of FORK_VERSION 12 and must not be reinterpreted in place. +// were introduced in FORK_VERSION 12 and must not be reinterpreted in place. const FILE_ID_NONE: u8 = 0; const FILE_ID_HOST: u8 = 1; const FILE_ID_KERNEL_MEMFD: u8 = 2; @@ -888,6 +895,7 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result Result { +fn deserialize_fork_state_into(buf: &[u8], child: &mut Process) -> Result<(), Errno> { + let child_pid = child.pid; let mut r = Reader::new(buf); // ── Header ── @@ -1237,6 +1247,7 @@ pub fn deserialize_fork_state(buf: &[u8], child_pid: u32) -> Result Result Result Result<(), Errno> { + deserialize_fork_state_into(buf, child) +} + +/// Test-only fixture wrapper that permits a caller-selected child PID. +#[cfg(test)] +pub fn deserialize_fork_state(buf: &[u8], child_pid: u32) -> Result { + let mut child = Process::new_empty_for_test(child_pid); + deserialize_fork_state_into(buf, &mut child)?; + Ok(child) } // ── Exec Serialize ────────────────────────────────────────────────────────── @@ -1560,6 +1574,7 @@ pub fn deserialize_fork_state(buf: &[u8], child_pid: u32) -> Result Result { let mut w = Writer::new(buf); @@ -1690,6 +1705,7 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result Result, Errno> { let mut len = INITIAL_EXEC_STATE_BUFFER_LEN; @@ -1731,6 +1748,7 @@ pub fn serialize_exec_state_with_growing_buffer(proc: &Process) -> Result Result { let mut r = Reader::new(buf); @@ -1775,9 +1793,9 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { let pending = r.read_u64()?; let signals = SignalState::from_parts_with_pending(handlers, blocked, pending); let mut main_thread_signals = read_directed_signal_state(&mut r)?; - // POSIX timer objects do not survive exec. The legacy serialized exec path - // must not retain directed notifications that refer to discarded timers. - discard_legacy_exec_timer_notifications(&mut main_thread_signals); + // POSIX timer objects do not survive exec. The serialized test format must + // not retain directed notifications that refer to discarded timers. + discard_serialized_exec_timer_notifications(&mut main_thread_signals); // ── FD table ── let max_fds = r.read_u32()? as usize; @@ -1900,6 +1918,7 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { 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 foreground_pgid = r.read_i32()?; let terminal = TerminalState { c_iflag, @@ -1916,7 +1935,7 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { ws_xpixel, ws_ypixel, }, - foreground_pgid: 1, + foreground_pgid, session_id, line_buffer: Vec::new(), cooked_buffer: Vec::new(), @@ -1933,68 +1952,57 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { let _program_break = r.read_u32()?; let memory = MemoryManager::new(); - Ok(Process { - pid, - ppid, - uid, - gid, - euid, - egid, - pgid, - sid, - is_session_leader, - state: ProcessState::Running, - exit_status: 0, - exit_signal: 0, - // ProcessTable preserves the old process's record after legacy exec. - wait_event: None, - fd_table, - ofd_table, - pipes: Vec::new(), - sockets: SocketTable::new(), - cwd, - dir_streams: Vec::new(), - signals, - main_thread_signals, - memory, - terminal, - environ, - argv, - umask, - nice, - rlimits, - alarm_deadline_ns: 0, - alarm_interval_ns: 0, - thread_name: [0u8; 16], - fork_child: false, - sigsuspend_saved_mask: None, - fork_exec_path: None, - fork_exec_argv: None, - fork_fd_actions: Vec::new(), - next_ephemeral_port: 49152, - threads: Vec::new(), // exec resets to single thread - next_tid: 0, - epolls: Vec::new(), - posix_timers: Vec::new(), - alt_stack_sp: 0, - alt_stack_flags: 2, // SS_DISABLE - alt_stack_size: 0, - alt_stack_depth: 0, - fork_pipe_replay: Vec::new(), - has_exec: false, - // exec wipes any prior framebuffer binding — the new program - // must open and mmap /dev/fb0 itself. - fb_binding: None, - // exec replaces the address space, so every DRI bo binding - // is gone — the new image must re-mmap. - dri_bindings: Vec::new(), - // The fork counter exists as a kernel-side regression guardrail. - // Resetting on exec keeps semantics simple: the next spawn-from-this-pid - // test starts from a clean slate. The plan's regression check inspects - // the *parent* process's counter, not the post-exec child, so this - // reset is safe. - fork_count: 0, - }) + 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.pgid = pgid; + process.sid = sid; + process.is_session_leader = is_session_leader; + process.state = ProcessState::Running; + process.exit_status = 0; + process.exit_signal = 0; + process.wait_event = None; + process.fd_table = fd_table; + process.ofd_table = ofd_table; + process.pipes.clear(); + process.sockets = SocketTable::new(); + process.cwd = cwd; + process.dir_streams.clear(); + process.signals = signals; + process.main_thread_signals = main_thread_signals; + process.memory = memory; + process.terminal = terminal; + process.environ = environ; + process.argv = argv; + process.umask = umask; + process.nice = nice; + process.rlimits = rlimits; + process.alarm_deadline_ns = 0; + process.alarm_interval_ns = 0; + process.thread_name = [0u8; 16]; + process.fork_child = false; + process.sigsuspend_saved_mask = None; + 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(); + process.posix_timers.clear(); + process.alt_stack_sp = 0; + process.alt_stack_flags = 2; // SS_DISABLE + process.alt_stack_size = 0; + process.alt_stack_depth = 0; + process.fork_pipe_replay.clear(); + process.has_exec = false; + process.fb_binding = None; + process.dri_bindings.clear(); + process.fork_count = 0; + Ok(process) } #[cfg(test)] @@ -2005,7 +2013,8 @@ mod tests { #[test] fn test_roundtrip_default_process() { - let proc = Process::new(1); + let mut proc = Process::new(1); + proc.terminal.foreground_pgid = 313; let mut buf = vec![0u8; 64 * 1024]; let written = serialize_fork_state(&proc, &mut buf).unwrap(); assert!(written > 12); @@ -2022,6 +2031,7 @@ mod tests { assert_eq!(child.cwd, proc.cwd); assert_eq!(child.signals.pending, 0); assert_eq!(child.main_thread_signals.pending, 0); + assert_eq!(child.terminal.foreground_pgid, 313); } #[test] @@ -2264,7 +2274,8 @@ mod tests { #[test] fn test_exec_roundtrip_default_process() { - let proc = Process::new(1); + let mut proc = Process::new(1); + proc.terminal.foreground_pgid = 919; let mut buf = vec![0u8; 64 * 1024]; let written = serialize_exec_state(&proc, &mut buf).unwrap(); assert!(written > 12); @@ -2275,6 +2286,7 @@ mod tests { assert_eq!(restored.ppid, 0); // default ppid assert_eq!(restored.signals.pending, 0); assert_eq!(restored.main_thread_signals.pending, 0); + assert_eq!(restored.terminal.foreground_pgid, 919); } #[test] @@ -2790,8 +2802,8 @@ mod tests { use crate::process::ThreadInfo; let mut proc = Process::new(1); // Parent has 2 threads - let t1 = proc.alloc_tid(); - let t2 = proc.alloc_tid(); + let t1 = 2; + let t2 = 3; proc.add_thread(ThreadInfo::new(t1, 0, 0x1000, 0)); proc.add_thread(ThreadInfo::new(t2, 0, 0x2000, 0)); assert_eq!(proc.threads.len(), 2); @@ -2802,14 +2814,13 @@ mod tests { // POSIX: child has a single thread (the calling thread) assert_eq!(child.threads.len(), 0); - assert_eq!(child.next_tid, 0); } #[test] fn test_exec_resets_threads() { use crate::process::ThreadInfo; let mut proc = Process::new(1); - let t1 = proc.alloc_tid(); + let t1 = 2; proc.add_thread(ThreadInfo::new(t1, 0, 0x1000, 0)); let mut buf = vec![0u8; 64 * 1024]; @@ -2817,7 +2828,6 @@ mod tests { let child = deserialize_exec_state(&buf[..written], 1).unwrap(); assert_eq!(child.threads.len(), 0); - assert_eq!(child.next_tid, 0); } #[test] diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 0312e66756..f84796b0ef 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -1,6 +1,7 @@ extern crate alloc; use alloc::vec::Vec; +use core::ops::Deref; use wasm_posix_shared::{Errno, KernelRusage, WasmStat, WasmStatfs}; use crate::fd::FdTable; @@ -70,7 +71,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_kill(&mut self, pid: i32, sig: 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. @@ -151,9 +151,6 @@ pub trait HostIO { Err(Errno::ENETUNREACH) } fn host_getaddrinfo(&mut self, name: &[u8], result: &mut [u8]) -> Result; - /// Request the host to fork the current process. - /// Returns child PID (>= 0) on success, or negative errno on error. - fn host_fork(&self) -> i32; /// Futex wait: block if `*addr == expected`, with optional timeout in nanoseconds. /// timeout_ns < 0 means infinite wait. /// Returns 0 on wake, negative errno on error. @@ -165,15 +162,6 @@ pub trait HostIO { ) -> Result; /// Futex wake: wake up to `count` waiters on addr. Returns number woken. fn host_futex_wake(&mut self, addr: usize, count: u32) -> Result; - /// Clone: spawn a new thread worker. Returns child TID on success. - fn host_clone( - &mut self, - fn_ptr: usize, - arg: usize, - stack_ptr: usize, - tls_ptr: usize, - ctid_ptr: usize, - ) -> Result; /// Notify the host that process `pid` has mapped its `/dev/fb0` /// framebuffer at `[addr, addr+len)` within its wasm `Memory`. The host /// should mirror that byte range to whatever display surface it owns. @@ -411,10 +399,36 @@ pub struct DriBoBinding { pub bo_id: crate::dri::BoId, } -/// Per-thread state within a process. -#[derive(Debug, Clone)] -pub struct ThreadInfo { +/// Read-only identity of a thread owned by [`crate::process_table::ProcessTable`]. +/// +/// [`ThreadInfo`] dereferences to this view so existing `thread.tid` reads stay +/// ergonomic. It deliberately does not implement `DerefMut`: only the process +/// table may assign a TID. +/// +/// ```compile_fail +/// use kandelo_kernel::process::ThreadInfo; +/// fn rewrite_tid(thread: &mut ThreadInfo) { +/// thread.tid = 999; +/// } +/// ``` +#[doc(hidden)] +#[derive(Debug)] +pub struct ThreadIdentity { pub tid: u32, + state: ThreadState, +} + +impl Deref for ThreadIdentity { + type Target = ThreadState; + + fn deref(&self) -> &Self::Target { + &self.state + } +} + +/// Mutable non-identity state for a retained thread. +#[derive(Debug, Clone)] +pub struct ThreadState { pub ctid_ptr: usize, // CLONE_CHILD_CLEARTID address (futex wake on exit) pub stack_ptr: usize, pub tls_ptr: usize, @@ -424,17 +438,75 @@ pub struct ThreadInfo { pub signals: PerThreadSignalState, } +/// A kernel-owned thread identity paired with its mutable non-identity state. +/// +/// Identity-bearing records cannot be duplicated into detached owned values: +/// +/// ```compile_fail +/// use kandelo_kernel::process::ThreadInfo; +/// fn duplicate(thread: &ThreadInfo) -> ThreadInfo { +/// ThreadInfo::clone(thread) +/// } +/// ``` +#[derive(Debug)] +pub struct ThreadInfo { + identity: ThreadIdentity, +} + +impl Deref for ThreadInfo { + type Target = ThreadIdentity; + + fn deref(&self) -> &Self::Target { + &self.identity + } +} + impl ThreadInfo { - pub fn new(tid: u32, ctid_ptr: usize, stack_ptr: usize, tls_ptr: usize) -> Self { + fn new_inner(tid: u32, ctid_ptr: usize, stack_ptr: usize, tls_ptr: usize) -> Self { ThreadInfo { - tid, - ctid_ptr, - stack_ptr, - tls_ptr, - tidptr: 0, - signals: PerThreadSignalState::new(), + identity: ThreadIdentity { + tid, + state: ThreadState { + ctid_ptr, + stack_ptr, + tls_ptr, + tidptr: 0, + signals: PerThreadSignalState::new(), + }, + }, } } + + fn state_mut(&mut self) -> &mut ThreadState { + &mut self.identity.state + } + + #[cfg(test)] + pub(crate) fn state_mut_for_test(&mut self) -> &mut ThreadState { + self.state_mut() + } + + fn into_state(self) -> ThreadState { + self.identity.state + } + + /// Construct a thread record by consuming an identity allocated by + /// `ProcessTable`. + fn new_allocated( + task_id: crate::process_table::AllocatedTaskId, + ctid_ptr: usize, + stack_ptr: usize, + tls_ptr: usize, + ) -> Self { + let tid = task_id.into_raw(); + Self::new_inner(tid, ctid_ptr, stack_ptr, tls_ptr) + } + + /// Construct an isolated thread fixture with a caller-selected TID. + #[cfg(test)] + pub(crate) fn new(tid: u32, ctid_ptr: usize, stack_ptr: usize, tls_ptr: usize) -> Self { + Self::new_inner(tid, ctid_ptr, stack_ptr, tls_ptr) + } } /// Per-eventfd state: a u64 counter with optional semaphore semantics. @@ -563,9 +635,41 @@ pub enum FdAction { }, } +/// Read-only identity and task-membership view of a [`Process`]. +/// +/// `Process` dereferences to this type so callers can inspect `process.pid` and +/// `process.threads`, but the absence of `DerefMut` prevents them from +/// rewriting the PID or injecting/remapping thread identities. +/// +/// ```compile_fail +/// use kandelo_kernel::process::Process; +/// fn rewrite_pid(process: &mut Process) { +/// process.pid = 999; +/// } +/// ``` +/// +/// ```compile_fail +/// use kandelo_kernel::process::{Process, ThreadInfo}; +/// fn inject_thread(process: &mut Process, thread: ThreadInfo) { +/// process.threads.push(thread); +/// } +/// ``` +/// +/// Production callers also cannot construct a process with a selected PID: +/// +/// ```compile_fail +/// use kandelo_kernel::process::Process; +/// let _ = Process::new(999); +/// ``` +#[doc(hidden)] +pub struct ProcessIdentity { + pub pid: u32, + pub threads: Vec, +} + /// Per-process kernel state: file descriptor table, OFD table, pipes, cwd, and directory streams. pub struct Process { - pub pid: u32, + identity: ProcessIdentity, pub ppid: u32, pub uid: u32, pub gid: u32, @@ -623,12 +727,15 @@ 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, - /// Threads created by this process. - pub threads: Vec, - /// Next thread ID to allocate. - pub next_tid: u32, /// Epoll instances owned by this process. pub epolls: Vec>, /// POSIX timers (timer_create / timer_settime). @@ -661,6 +768,14 @@ pub struct Process { pub(crate) fork_count: u64, } +impl Deref for Process { + type Target = ProcessIdentity; + + fn deref(&self) -> &Self::Target { + &self.identity + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum StdioKind { HostPipe, @@ -722,45 +837,84 @@ pub(crate) const PROCESS_METADATA_ARGV: u32 = 0; pub(crate) const PROCESS_METADATA_ENVIRONMENT: u32 = 1; impl Process { - /// Create a new process with captured, pipe-backed stdio. - pub fn new(pid: u32) -> Self { - Self::new_with_stdio(pid, StdioConfig::captured()) + /// Create a process for an identity allocated by `ProcessTable`. + pub(crate) fn new_allocated(task_id: crate::process_table::AllocatedTaskId) -> Self { + Self::new_allocated_with_stdio(task_id, StdioConfig::captured()) + } + + /// Create a process with caller-selected stdio for a `ProcessTable` ID. + pub(crate) fn new_allocated_with_stdio( + task_id: crate::process_table::AllocatedTaskId, + stdio: StdioConfig, + ) -> Self { + let pid = task_id.into_raw(); + Self::new_inner(pid, Some(stdio)) + } + + /// Create an empty process record for fork-state restoration. The caller + /// must hold the `ProcessTable` identity capability and install state + /// before publishing the record. + pub(crate) fn new_allocated_empty(task_id: crate::process_table::AllocatedTaskId) -> Self { + let pid = task_id.into_raw(); + Self::new_inner(pid, None) } - /// Create a new process with fds 0, 1, and 2 wired according to the - /// caller-supplied stdio configuration. - pub fn new_with_stdio(pid: u32, stdio: StdioConfig) -> Self { + /// Construct an isolated process fixture with a caller-selected PID. + #[cfg(test)] + pub(crate) fn new(pid: u32) -> Self { + Self::new_inner(pid, Some(StdioConfig::captured())) + } + + /// Construct an isolated process fixture with caller-selected stdio. + #[cfg(test)] + pub(crate) fn new_with_stdio(pid: u32, stdio: StdioConfig) -> Self { + Self::new_inner(pid, Some(stdio)) + } + + /// Construct an empty isolated fixture for deserialization tests. + #[cfg(test)] + pub(crate) fn new_empty_for_test(pid: u32) -> Self { + Self::new_inner(pid, None) + } + + fn new_inner(pid: u32, stdio: Option) -> Self { use wasm_posix_shared::flags::{O_RDONLY, O_WRONLY}; let mut ofd_table = OfdTable::new(); - ofd_table.create( - stdio.kind_for_fd(0).file_type(), - O_RDONLY, - 0, - b"/dev/stdin".to_vec(), - ); - ofd_table.create( - stdio.kind_for_fd(1).file_type(), - O_WRONLY, - 1, - b"/dev/stdout".to_vec(), - ); - ofd_table.create( - stdio.kind_for_fd(2).file_type(), - O_WRONLY, - 2, - b"/dev/stderr".to_vec(), - ); - let mut fd_table = FdTable::new(); - fd_table.preopen_stdio(); // fds 0,1,2 → OFD refs 0,1,2 + if let Some(stdio) = stdio { + ofd_table.create( + stdio.kind_for_fd(0).file_type(), + O_RDONLY, + 0, + b"/dev/stdin".to_vec(), + ); + ofd_table.create( + stdio.kind_for_fd(1).file_type(), + O_WRONLY, + 1, + b"/dev/stdout".to_vec(), + ); + ofd_table.create( + stdio.kind_for_fd(2).file_type(), + O_WRONLY, + 2, + b"/dev/stderr".to_vec(), + ); + fd_table.preopen_stdio(); // fds 0,1,2 → OFD refs 0,1,2 + } let mut rlimits = [[u64::MAX; 2]; 16]; // Default: infinity for all rlimits[7] = [1024, 4096]; // RLIMIT_NOFILE: soft=1024, hard=4096 rlimits[3] = [8 * 1024 * 1024, u64::MAX]; // RLIMIT_STACK: soft=8MB, hard=infinity + let mut terminal = TerminalState::new(); + terminal.foreground_pgid = pid as i32; Process { - pid, + identity: ProcessIdentity { + pid, + 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 @@ -785,7 +939,7 @@ impl Process { signals: SignalState::new(), main_thread_signals: PerThreadSignalState::new(), memory: MemoryManager::new(), - terminal: TerminalState::new(), + terminal, environ: Vec::new(), argv: Vec::new(), umask: 0o022, @@ -799,9 +953,8 @@ impl Process { fork_exec_path: None, fork_exec_argv: None, fork_fd_actions: Vec::new(), + exec_prepared_tid: None, next_ephemeral_port: 49152, - threads: Vec::new(), - next_tid: 0, // will be set to pid + 1 after pid is known epolls: Vec::new(), posix_timers: Vec::new(), alt_stack_sp: 0, @@ -816,13 +969,24 @@ impl Process { } } + /// Return the immutable process identity assigned by `ProcessTable`. + pub fn pid(&self) -> u32 { + self.identity.pid + } + + /// Override a fixture identity without exposing a production mutation API. + #[cfg(test)] + pub(crate) fn set_pid_for_test(&mut self, pid: u32) { + self.identity.pid = pid; + } + /// Returns how many times this process has successfully forked (parent side). pub fn fork_count(&self) -> u64 { self.fork_count } - /// Increment the fork counter. Called by `ProcessTable::fork_process` on - /// the parent after a child is successfully created. + /// Increment the fork counter. Called by + /// `ProcessTable::fork_process_for_caller` after child creation. pub(crate) fn increment_fork_count(&mut self) { self.fork_count += 1; } @@ -957,6 +1121,23 @@ impl Process { self.signals.raise_with_value(signum, si_value) } + /// Queue a process-directed signal with the generation metadata exposed + /// through `siginfo_t`. Plain `kill()` uses `SI_USER` (0), while + /// `rt_sigqueueinfo()` uses `SI_QUEUE` (-1). + pub(crate) fn raise_signal_with_metadata( + &mut self, + signum: u32, + si_value: i32, + si_code: i32, + ) -> 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) + } + } + /// Compatibility helper for the legacy pipe slot vector, reusing the first /// free slot. Runtime pipe operations use the kernel-global pipe table. pub fn alloc_pipe(&mut self, pipe: PipeBuffer) -> usize { @@ -988,26 +1169,33 @@ impl Process { (idx, idx + 1) } - /// Allocate a new thread ID for this process. - pub fn alloc_tid(&mut self) -> u32 { - // First thread TID starts at pid + 1 - if self.next_tid == 0 { - self.next_tid = self.pid + 1; - } - let tid = self.next_tid; - self.next_tid += 1; - tid + /// Consume a `ProcessTable`-allocated identity and attach its thread record. + pub(crate) fn add_allocated_thread( + &mut self, + task_id: crate::process_table::AllocatedTaskId, + ctid_ptr: usize, + stack_ptr: usize, + tls_ptr: usize, + ) -> &mut ThreadState { + let info = ThreadInfo::new_allocated(task_id, ctid_ptr, stack_ptr, tls_ptr); + self.identity.threads.push(info); + self.identity + .threads + .last_mut() + .expect("just-added thread must exist") + .state_mut() } - /// Add a thread to this process. - pub fn add_thread(&mut self, info: ThreadInfo) { - self.threads.push(info); + /// Add an isolated caller-constructed thread fixture. + #[cfg(test)] + pub(crate) fn add_thread(&mut self, info: ThreadInfo) { + self.identity.threads.push(info); } /// Remove a thread by TID. - pub fn remove_thread(&mut self, tid: u32) -> Option { - if let Some(idx) = self.threads.iter().position(|t| t.tid == tid) { - Some(self.threads.swap_remove(idx)) + pub fn remove_thread(&mut self, tid: u32) -> Option { + if let Some(idx) = self.identity.threads.iter().position(|t| t.tid == tid) { + Some(self.identity.threads.swap_remove(idx).into_state()) } else { None } @@ -1019,8 +1207,23 @@ impl Process { } /// Find a thread by TID (mutable). - pub fn get_thread_mut(&mut self, tid: u32) -> Option<&mut ThreadInfo> { - self.threads.iter_mut().find(|t| t.tid == tid) + pub fn get_thread_mut(&mut self, tid: u32) -> Option<&mut ThreadState> { + self.identity + .threads + .iter_mut() + .find(|t| t.tid == tid) + .map(ThreadInfo::state_mut) + } + + /// Mutably visit retained thread state without exposing identity records or + /// vector membership. + pub(crate) fn thread_states_mut(&mut self) -> impl Iterator { + self.identity.threads.iter_mut().map(ThreadInfo::state_mut) + } + + /// Remove all non-leader tasks during exec replacement. + pub(crate) fn clear_threads(&mut self) { + self.identity.threads.clear(); } /// True if `tid` names the process's main thread. The main thread's TID @@ -1028,45 +1231,81 @@ impl Process { /// [`Process::threads`]; its blocked mask lives in [`Process::signals`] /// and its directed pending queue in [`Process::main_thread_signals`]. /// - /// `tid == 0` is also treated as "main thread" because the host uses 0 - /// for syscalls from the main channel (no thread worker is involved). + /// `tid == 0` remains an internal main-thread sentinel for isolated + /// syscall unit tests. Host dispatch binds the explicit leader PID. pub fn is_main_thread(&self, tid: u32) -> bool { tid == 0 || tid == self.pid } /// True when a nonzero TID explicitly names the live process leader or a /// retained worker. Kernel-internal TID 0 aliases the leader but is not a - /// valid user-supplied exact-thread target. + /// valid user-supplied exact-thread target. Synthetic PID 1 has no worker + /// and therefore can never be an executing caller task. pub fn is_live_explicit_tid(&self, tid: u32) -> bool { - tid != 0 && (tid == self.pid || self.get_thread(tid).is_some()) + self.pid != 1 + && matches!(self.state, ProcessState::Running | ProcessState::Stopped) + && tid != 0 + && (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) { self.signals.blocked + } else if let Some(thread) = self.get_thread(tid) { + thread.signals.blocked } else { - self.get_thread(tid) - .map(|t| t.signals.blocked) - .unwrap_or(self.signals.blocked) + // Unknown tasks must not inherit leader state. Treat every signal + // as blocked so stale internal callers fail closed. + u64::MAX } } - /// Replace the blocked mask for the given TID. Returns the old value. - pub fn set_blocked_for(&mut self, tid: u32, new_blocked: u64) -> u64 { + /// Replace the blocked mask for an exact retained TID. + /// Returns false without mutation when the task is unknown. + pub fn set_blocked_for(&mut self, tid: u32, new_blocked: u64) -> bool { if self.is_main_thread(tid) { - let old = self.signals.blocked; self.signals.blocked = new_blocked; - old + true } else if let Some(t) = self.get_thread_mut(tid) { - let old = t.signals.blocked; t.signals.blocked = new_blocked; - old + true } else { - // Unknown thread — fall back to process-level. - let old = self.signals.blocked; - self.signals.blocked = new_blocked; - old + false } } @@ -1076,9 +1315,10 @@ impl Process { pub fn pending_for(&self, tid: u32) -> u64 { if self.is_main_thread(tid) { self.signals.pending | self.main_thread_signals.pending + } else if let Some(thread) = self.get_thread(tid) { + self.signals.pending | thread.signals.pending } else { - let thread_pending = self.get_thread(tid).map(|t| t.signals.pending).unwrap_or(0); - self.signals.pending | thread_pending + 0 } } @@ -1088,6 +1328,9 @@ impl Process { if sig == 0 || sig >= wasm_posix_shared::signal::NSIG { return false; } + if !self.is_main_thread(tid) && self.get_thread(tid).is_none() { + return false; + } let bit = crate::signal::sig_bit(sig); let shared = (self.signals.pending & bit) != 0; if self.is_main_thread(tid) { @@ -1123,7 +1366,10 @@ impl Process { /// Returns `None` if every thread blocks `sig`; the signal stays queued /// in the shared pending set until some thread unblocks it. pub fn pick_thread_for_shared_signal(&self, sig: u32) -> Option { - if sig == 0 || sig >= wasm_posix_shared::signal::NSIG { + if !self.is_live_explicit_tid(self.pid) + || sig == 0 + || sig >= wasm_posix_shared::signal::NSIG + { return None; } let bit = crate::signal::sig_bit(sig); @@ -1150,6 +1396,9 @@ impl Process { /// Stopped processes retain every pending signal except SIGKILL; SIGCONT /// resumes at generation time and reaches this method as Running. pub fn next_deliverable_signal(&self, tid: u32) -> Option { + if !self.is_main_thread(tid) && self.get_thread(tid).is_none() { + return None; + } if self.state == ProcessState::Stopped { let sigkill = wasm_posix_shared::signal::SIGKILL; return self.signal_pending_anywhere(sigkill).then_some(sigkill); @@ -1180,10 +1429,13 @@ impl Process { /// Queue a signal for one exact thread. Main-thread-directed signals have /// their own queue because `SignalState::pending` is process-shared. pub fn raise_for_thread(&mut self, tid: u32, signum: u32) -> bool { - self.prepare_signal_generation(signum); 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; @@ -1204,10 +1456,13 @@ impl Process { signum: u32, si_value: i32, ) -> bool { - self.prepare_signal_generation(signum); 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; @@ -1231,10 +1486,13 @@ impl Process { si_value: i32, timer_id: u32, ) -> bool { - self.prepare_signal_generation(signum); 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; @@ -1253,8 +1511,8 @@ impl Process { /// disposition requires pending instances to be discarded. pub fn clear_directed_signal(&mut self, signum: u32) { self.main_thread_signals.clear_pending(signum); - for thread in &mut self.threads { - thread.signals.clear_pending(signum); + for thread in &mut self.identity.threads { + thread.state_mut().signals.clear_pending(signum); } } @@ -1265,6 +1523,9 @@ impl Process { tid: u32, signum: u32, ) -> Option { + if !self.is_main_thread(tid) && self.get_thread(tid).is_none() { + return None; + } let directed = if self.is_main_thread(tid) { self.main_thread_signals .is_pending(signum) @@ -1342,8 +1603,8 @@ impl Process { } self.signals.clear_pending(signum); self.main_thread_signals.clear_pending(signum); - for thread in &mut self.threads { - thread.signals.clear_pending(signum); + for thread in &mut self.identity.threads { + thread.state_mut().signals.clear_pending(signum); } for timer_id in timer_ids { self.accept_posix_timer_notification(timer_id); @@ -1356,8 +1617,11 @@ impl Process { removed |= self .main_thread_signals .remove_timer_notification(timer_id); - for thread in &mut self.threads { - removed |= thread.signals.remove_timer_notification(timer_id); + for thread in &mut self.identity.threads { + removed |= thread + .state_mut() + .signals + .remove_timer_notification(timer_id); } removed } @@ -1536,9 +1800,6 @@ pub(crate) mod test_host { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_kill(&mut self, _p: i32, _s: u32) -> Result<(), Errno> { - Ok(()) - } fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { Err(Errno::ENOSYS) } @@ -1606,25 +1867,12 @@ pub(crate) mod test_host { fn host_getaddrinfo(&mut self, _n: &[u8], _r: &mut [u8]) -> Result { Err(Errno::ENOENT) } - fn host_fork(&self) -> i32 { - -(Errno::ENOSYS as i32) - } fn host_futex_wait(&mut self, _a: usize, _e: u32, _t: i64) -> Result { Err(Errno::EAGAIN) } fn host_futex_wake(&mut self, _a: usize, _c: u32) -> Result { Ok(0) } - fn host_clone( - &mut self, - _f: usize, - _a: usize, - _s: usize, - _t: usize, - _c: usize, - ) -> Result { - Err(Errno::ENOSYS) - } fn bind_framebuffer( &mut self, _p: i32, @@ -1723,8 +1971,8 @@ mod tests { proc.add_thread(ThreadInfo::new(99, 0, 0, 0)); proc.signals.raise(SIGSTOP); proc.main_thread_signals.raise(SIGTSTP); - proc.threads[0].signals.raise(SIGTTIN); - proc.threads[0].signals.raise(SIGTTOU); + proc.get_thread_mut(99).unwrap().signals.raise(SIGTTIN); + proc.get_thread_mut(99).unwrap().signals.raise(SIGTTOU); assert!(proc.raise_signal(SIGCONT)); let stop_bits = [SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU] @@ -1735,7 +1983,7 @@ mod tests { assert_eq!(proc.threads[0].signals.pending & stop_bits, 0); proc.main_thread_signals.raise(SIGCONT); - proc.threads[0].signals.raise(SIGCONT); + proc.get_thread_mut(99).unwrap().signals.raise(SIGCONT); assert!(proc.raise_signal(SIGSTOP)); assert!(!proc.signals.is_pending(SIGCONT)); assert_eq!(proc.main_thread_signals.pending & sig_bit(SIGCONT), 0); @@ -1803,6 +2051,39 @@ mod tests { assert!(!proc.is_live_explicit_tid(43)); proc.remove_thread(42); assert!(!proc.is_live_explicit_tid(42)); + + let synthetic_init = Process::new(1); + assert!(!synthetic_init.is_live_explicit_tid(1)); + assert_eq!(synthetic_init.pick_thread_for_shared_signal(15), None); + + proc.state = ProcessState::Exited; + 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] @@ -1901,6 +2182,7 @@ mod tests { assert_eq!(ofd.file_type, FileType::CharDevice); assert_eq!(ofd.host_handle, fd as i64); } + assert_eq!(proc.terminal.foreground_pgid, 1); } #[test] @@ -1908,13 +2190,13 @@ mod tests { use crate::process_table::ProcessTable; use crate::spawn::SpawnAttrs; let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); - table.processes.get_mut(&100).unwrap().cwd = b"/tmp".to_vec(); + let parent_pid = table.create_process().unwrap(); + table.processes.get_mut(&parent_pid).unwrap().cwd = b"/tmp".to_vec(); let mut host = test_host::NoopHost; let child_pid = table - .spawn_child( - 100, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"/bin/echo".as_slice(), b"hi".as_slice()], &[b"PATH=/bin".as_slice()], &[], @@ -1923,11 +2205,14 @@ mod tests { ) .expect("spawn_child"); - assert_ne!(child_pid, 100, "child pid must differ from parent"); + assert_ne!(child_pid, parent_pid, "child pid must differ from parent"); let child = table.get(child_pid).expect("child in table"); assert_eq!(child.cwd, b"/tmp", "child inherits parent cwd"); - assert_eq!(child.ppid, 100, "child ppid is parent pid"); - assert!(child.wait_event.is_none(), "spawn child starts without status"); + assert_eq!(child.ppid, parent_pid, "child ppid is parent pid"); + assert!( + child.wait_event.is_none(), + "spawn child starts without status" + ); assert_eq!( child.argv, alloc::vec![b"/bin/echo".to_vec(), b"hi".to_vec()], @@ -1935,7 +2220,7 @@ mod tests { ); // The whole point of non-forking spawn: the parent's fork counter // must NOT bump. - assert_eq!(table.get(100).unwrap().fork_count(), 0); + assert_eq!(table.get(parent_pid).unwrap().fork_count(), 0); } #[test] @@ -1949,7 +2234,7 @@ mod tests { use crate::spawn::SpawnAttrs; let mut table = ProcessTable::new(); - table.create_process(200).unwrap(); + let parent_pid = table.create_process().unwrap(); // Allocate a backlog slot (starts with ref_count=1) and attach it // to a parent-owned listener socket. @@ -1958,7 +2243,7 @@ mod tests { listener.shared_backlog_idx = Some(backlog_idx); let _sock_idx = table .processes - .get_mut(&200) + .get_mut(&parent_pid) .unwrap() .sockets .alloc(listener); @@ -1968,8 +2253,8 @@ mod tests { let mut host = test_host::NoopHost; let _child_pid = table - .spawn_child( - 200, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], @@ -1985,7 +2270,7 @@ mod tests { ); // Same slot should also bump on fork — the helper is shared. - table.fork_process(200, 999).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, @@ -2004,14 +2289,19 @@ mod tests { use crate::spawn::SpawnAttrs; let mut table = ProcessTable::new(); - table.create_process(300).unwrap(); + let parent_pid = table.create_process().unwrap(); // Pretend the parent connected an AF_INET socket; the host returned // handle 42. const HANDLE: i32 = 42; let mut sock = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); sock.host_net_handle = Some(HANDLE); - table.processes.get_mut(&300).unwrap().sockets.alloc(sock); + table + .processes + .get_mut(&parent_pid) + .unwrap() + .sockets + .alloc(sock); // The handle isn't in the cross-process table yet — single-owner. assert_eq!(host_net_handle_ref_count(HANDLE), 0); @@ -2020,8 +2310,8 @@ mod tests { // (child) = 2". let mut host = test_host::NoopHost; let _child = table - .spawn_child( - 300, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], @@ -2036,7 +2326,7 @@ mod tests { ); // Forking again bumps once more. - table.fork_process(300, 999).expect("fork_process"); + table.fork_process_for_caller(parent_pid, parent_pid).expect("fork_process"); assert_eq!( host_net_handle_ref_count(HANDLE), 3, @@ -2056,7 +2346,7 @@ mod tests { use crate::spawn::SpawnAttrs; let mut table = ProcessTable::new(); - table.create_process(400).unwrap(); + let parent_pid = table.create_process().unwrap(); // Parent has a UDP socket with a pending datagram and a TCP socket // with a pending OOB byte. @@ -2070,21 +2360,21 @@ mod tests { src_port: 12345, src_sock_idx: None, ipv6_tclass: 0, - src_pid: 400, + src_pid: parent_pid, src_uid: 0, src_gid: 0, ancillary_fds: Vec::new(), }); let mut tcp = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); tcp.oob_byte = Some(0xAB); - let parent = table.processes.get_mut(&400).unwrap(); + let parent = table.processes.get_mut(&parent_pid).unwrap(); let udp_idx = parent.sockets.alloc(udp); let tcp_idx = parent.sockets.alloc(tcp); // Sanity: parent still has the consume-once data. assert_eq!( table - .get(400) + .get(parent_pid) .unwrap() .sockets .get(udp_idx) @@ -2095,7 +2385,7 @@ mod tests { ); assert_eq!( table - .get(400) + .get(parent_pid) .unwrap() .sockets .get(tcp_idx) @@ -2106,8 +2396,8 @@ mod tests { let mut host = test_host::NoopHost; let child_pid = table - .spawn_child( - 400, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], @@ -2129,7 +2419,7 @@ mod tests { ); // Parent's pending data is intact (consume-once stayed with parent). - let parent = table.get(400).unwrap(); + let parent = table.get(parent_pid).unwrap(); assert_eq!(parent.sockets.get(udp_idx).unwrap().dgram_queue.len(), 1); assert_eq!(parent.sockets.get(tcp_idx).unwrap().oob_byte, Some(0xAB)); } @@ -2146,20 +2436,20 @@ mod tests { use crate::spawn::SpawnAttrs; let mut table = ProcessTable::new(); - table.create_process(500).unwrap(); + let parent_pid = table.create_process().unwrap(); // Parent has a listening AF_UNIX socket with pending pre-accepted // connections. let mut listener = SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); listener.listen_backlog.push(7); listener.listen_backlog.push(11); - let parent = table.processes.get_mut(&500).unwrap(); + let parent = table.processes.get_mut(&parent_pid).unwrap(); let listener_idx = parent.sockets.alloc(listener); // Sanity: parent has both pending entries. assert_eq!( table - .get(500) + .get(parent_pid) .unwrap() .sockets .get(listener_idx) @@ -2172,8 +2462,8 @@ mod tests { // Spawn child must NOT inherit them. let mut host = test_host::NoopHost; let spawn_child = table - .spawn_child( - 500, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], @@ -2194,10 +2484,10 @@ mod tests { ); // Fork child must NOT inherit them either. - table.fork_process(500, 998).expect("fork_process"); + let fork_child = table.fork_process_for_caller(parent_pid, parent_pid).expect("fork_process"); assert!( table - .get(998) + .get(fork_child) .unwrap() .sockets .get(listener_idx) @@ -2210,7 +2500,7 @@ mod tests { // Parent retains them. assert_eq!( table - .get(500) + .get(parent_pid) .unwrap() .sockets .get(listener_idx) @@ -2234,16 +2524,21 @@ mod tests { const HANDLE: i32 = 84; let mut table = ProcessTable::new(); - table.create_process(600).unwrap(); + 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(&600).unwrap().sockets.alloc(sock); + let _sock_idx = table + .processes + .get_mut(&parent_pid) + .unwrap() + .sockets + .alloc(sock); // Spawn a child → bump the refcount to (parent=1, child=2). let mut host = test_host::NoopHost; let child_pid = table - .spawn_child( - 600, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], @@ -2262,7 +2557,7 @@ mod tests { assert_eq!(host_net_handle_ref_count(HANDLE), 1); // Removing the parent now: IS the last reference → emit close. - let r2 = table.remove_process(600).expect("remove parent"); + let r2 = table.remove_process(parent_pid).expect("remove parent"); assert_eq!( r2.host_net_closes, alloc::vec![HANDLE], @@ -2283,8 +2578,8 @@ mod tests { const HANDLE: i64 = 900_000_091; let mut table = ProcessTable::new(); - table.create_process(610).unwrap(); - let parent = table.processes.get_mut(&610).unwrap(); + let parent_pid = table.create_process().unwrap(); + let parent = table.processes.get_mut(&parent_pid).unwrap(); // Keep the assertion independent of globally-numbered stdio handles, // which other ProcessTable tests may share while the test runner is // executing in parallel. @@ -2301,14 +2596,14 @@ mod tests { .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) .unwrap(); - table.fork_process(610, 611).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(611).expect("remove child"); + let child = table.remove_process(child_pid).expect("remove child"); assert!(child.host_closes.is_empty()); assert_eq!(host_handle_ref_count(HANDLE), 1); - let parent = table.remove_process(610).expect("remove parent"); + let parent = table.remove_process(parent_pid).expect("remove parent"); assert_eq!(parent.host_closes, alloc::vec![HANDLE]); assert_eq!(host_handle_ref_count(HANDLE), 0); } @@ -2317,12 +2612,12 @@ mod tests { fn remove_process_emits_all_uninherited_directory_handles() { use crate::fd::FdTable; use crate::ofd::{FileType, OfdTable}; - use crate::process::{DirStream, Process}; + use crate::process::DirStream; use crate::process_table::ProcessTable; let mut table = ProcessTable::new(); - table.processes.insert(620, Process::new(620)); - let process = table.processes.get_mut(&620).unwrap(); + let pid = table.create_process().unwrap(); + let process = table.processes.get_mut(&pid).unwrap(); process.fd_table = FdTable::new(); process.ofd_table = OfdTable::new(); let ofd_idx = process.ofd_table.create( @@ -2343,7 +2638,7 @@ mod tests { synth_dot_state: 0, })); - let removed = table.remove_process(620).expect("remove process"); + let removed = table.remove_process(pid).expect("remove process"); assert_eq!(removed.host_dir_closes, alloc::vec![7, 8]); assert_eq!(removed.host_closes, alloc::vec![92]); } @@ -2357,11 +2652,11 @@ mod tests { use crate::spawn::{FileAction, SpawnAttrs}; let mut table = ProcessTable::new(); - table.create_process(700).unwrap(); + let parent_pid = table.create_process().unwrap(); // Inject an OFD + fd 5 into parent. Use a file_type+host_handle that // won't trigger any host call on close-after-spawn. - let parent = table.processes.get_mut(&700).unwrap(); + let parent = table.processes.get_mut(&parent_pid).unwrap(); let ofd_idx = parent.ofd_table.create( crate::ofd::FileType::Regular, wasm_posix_shared::flags::O_RDONLY, @@ -2374,12 +2669,12 @@ mod tests { .alloc_at_min(crate::fd::OpenFileDescRef(ofd_idx), 0, 5) .unwrap(); // Sanity: parent has fd 5. - assert!(table.get(700).unwrap().fd_table.get(5).is_ok()); + assert!(table.get(parent_pid).unwrap().fd_table.get(5).is_ok()); let mut host = test_host::NoopHost; let child_pid = table - .spawn_child( - 700, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[FileAction::Close { fd: 5 }], @@ -2395,7 +2690,7 @@ mod tests { ); // Parent: fd 5 still open. assert!( - table.get(700).unwrap().fd_table.get(5).is_ok(), + table.get(parent_pid).unwrap().fd_table.get(5).is_ok(), "parent fd 5 must be unaffected" ); } @@ -2408,9 +2703,9 @@ mod tests { use crate::spawn::{FileAction, SpawnAttrs}; let mut table = ProcessTable::new(); - table.create_process(701).unwrap(); + let parent_pid = table.create_process().unwrap(); - let parent = table.processes.get_mut(&701).unwrap(); + let parent = table.processes.get_mut(&parent_pid).unwrap(); let ofd_idx = parent.ofd_table.create( crate::ofd::FileType::Regular, wasm_posix_shared::flags::O_RDONLY, @@ -2421,12 +2716,19 @@ mod tests { .fd_table .alloc_at_min(crate::fd::OpenFileDescRef(ofd_idx), 0, 5) .unwrap(); - let parent_fd1_ofd = table.get(701).unwrap().fd_table.get(1).unwrap().ofd_ref.0; + let parent_fd1_ofd = table + .get(parent_pid) + .unwrap() + .fd_table + .get(1) + .unwrap() + .ofd_ref + .0; let mut host = test_host::NoopHost; let child_pid = table - .spawn_child( - 701, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[FileAction::Dup2 { srcfd: 5, fd: 1 }], @@ -2442,7 +2744,14 @@ mod tests { assert_eq!(child_fd1_ofd, child_fd5_ofd, "child fd 1 dup2'd from fd 5"); // Parent fd 1 unchanged. assert_eq!( - table.get(701).unwrap().fd_table.get(1).unwrap().ofd_ref.0, + table + .get(parent_pid) + .unwrap() + .fd_table + .get(1) + .unwrap() + .ofd_ref + .0, parent_fd1_ofd, "parent fd 1 unaffected" ); @@ -2457,14 +2766,14 @@ mod tests { use wasm_posix_shared::Errno; let mut table = ProcessTable::new(); - table.create_process(702).unwrap(); + let parent_pid = table.create_process().unwrap(); let pids_before: Vec = table.all_pids(); - let parent_fork_count_before = table.get(702).unwrap().fork_count(); + let parent_fork_count_before = table.get(parent_pid).unwrap().fork_count(); let mut host = test_host::NoopHost; let err = table - .spawn_child( - 702, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[FileAction::Dup2 { srcfd: 999, fd: 1 }], @@ -2479,7 +2788,7 @@ mod tests { assert_eq!(pids_before, pids_after, "no partial child must remain"); // fork_count still 0. assert_eq!( - table.get(702).unwrap().fork_count(), + table.get(parent_pid).unwrap().fork_count(), parent_fork_count_before ); } @@ -2490,10 +2799,10 @@ mod tests { use crate::spawn::{SpawnAttrs, attr_flags}; let mut table = ProcessTable::new(); - table.create_process(800).unwrap(); + let parent_pid = table.create_process().unwrap(); // Parent's identity to confirm child diverges. - table.processes.get_mut(&800).unwrap().sid = 50; - table.processes.get_mut(&800).unwrap().pgid = 60; + table.processes.get_mut(&parent_pid).unwrap().sid = 50; + table.processes.get_mut(&parent_pid).unwrap().pgid = 60; let attrs = SpawnAttrs { flags: attr_flags::SETSID, @@ -2503,7 +2812,7 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child(800, &[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(); @@ -2521,7 +2830,7 @@ mod tests { use crate::spawn::{SpawnAttrs, attr_flags}; let mut table = ProcessTable::new(); - table.create_process(801).unwrap(); + let parent_pid = table.create_process().unwrap(); let attrs = SpawnAttrs { flags: attr_flags::SETPGROUP, @@ -2531,7 +2840,7 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child(801, &[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, @@ -2546,7 +2855,7 @@ mod tests { use crate::spawn::{SpawnAttrs, attr_flags}; let mut table = ProcessTable::new(); - table.create_process(802).unwrap(); + let parent_pid = table.create_process().unwrap(); let attrs = SpawnAttrs { flags: attr_flags::SETPGROUP, @@ -2556,7 +2865,7 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child(802, &[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); } @@ -2567,9 +2876,14 @@ mod tests { use crate::spawn::{SpawnAttrs, attr_flags}; let mut table = ProcessTable::new(); - table.create_process(803).unwrap(); + let parent_pid = table.create_process().unwrap(); // Parent has SIGINT (bit 0) blocked. - table.processes.get_mut(&803).unwrap().signals.blocked = 0x1; + table + .processes + .get_mut(&parent_pid) + .unwrap() + .signals + .blocked = 0x1; let attrs = SpawnAttrs { flags: attr_flags::SETSIGMASK, @@ -2579,7 +2893,7 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child(803, &[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, @@ -2596,12 +2910,17 @@ mod tests { use crate::spawn::SpawnAttrs; let mut table = ProcessTable::new(); - table.create_process(804).unwrap(); - table.processes.get_mut(&804).unwrap().signals.blocked = 0xAAu64; + let parent_pid = table.create_process().unwrap(); + table + .processes + .get_mut(&parent_pid) + .unwrap() + .signals + .blocked = 0xAAu64; let mut host = test_host::NoopHost; let cpid = table - .spawn_child( - 804, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], @@ -2622,8 +2941,8 @@ mod tests { use wasm_posix_shared::signal::{SIGUSR1, SIGUSR2}; let mut table = ProcessTable::new(); - table.create_process(805).unwrap(); - let parent = table.processes.get_mut(&805).unwrap(); + let parent_pid = table.create_process().unwrap(); + let parent = table.processes.get_mut(&parent_pid).unwrap(); parent .signals .set_handler(SIGUSR1, SignalHandler::Ignore) @@ -2635,8 +2954,8 @@ mod tests { let mut host = test_host::NoopHost; let cpid = table - .spawn_child( - 805, + .spawn_child_for_caller( + parent_pid, parent_pid, &[b"a".as_slice()], &[], &[], @@ -2667,8 +2986,8 @@ mod tests { use wasm_posix_shared::signal::{SIGUSR1, SIGUSR2}; let mut table = ProcessTable::new(); - table.create_process(806).unwrap(); - let parent = table.processes.get_mut(&806).unwrap(); + let parent_pid = table.create_process().unwrap(); + let parent = table.processes.get_mut(&parent_pid).unwrap(); parent .signals .set_handler(SIGUSR1, SignalHandler::Ignore) @@ -2688,7 +3007,7 @@ mod tests { }; let mut host = test_host::NoopHost; let cpid = table - .spawn_child(806, &[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(); @@ -2700,14 +3019,14 @@ mod tests { fn fork_count_bumps_on_successful_fork() { use crate::process_table::ProcessTable; let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); + assert_eq!(table.create_process().unwrap(), 100); // Sanity: counter starts at 0. assert_eq!(table.get(100).unwrap().fork_count(), 0); - table.fork_process(100, 101).expect("first fork"); + assert_eq!(table.fork_process_for_caller(100, 100).expect("first fork"), 101); assert_eq!(table.get(100).unwrap().fork_count(), 1); - table.fork_process(100, 102).expect("second fork"); + 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 @@ -2777,4 +3096,20 @@ mod tests { assert_eq!((a, b), (4, 5)); assert_eq!(proc.pipes.len(), 6); } + + #[test] + fn process_signal_metadata_distinguishes_kill_from_sigqueue() { + use wasm_posix_shared::signal::SIGUSR1; + + let mut proc = Process::new(100); + proc.raise_signal_with_metadata(SIGUSR1, 0, 0); + let plain = proc.consume_signal_for(proc.pid, SIGUSR1).unwrap(); + assert_eq!(plain.si_code, 0); + assert_eq!(plain.si_value, 0); + + proc.raise_signal_with_metadata(SIGUSR1, 0x1234, -1); + let queued = proc.consume_signal_for(proc.pid, SIGUSR1).unwrap(); + assert_eq!(queued.si_code, -1); + assert_eq!(queued.si_value, 0x1234); + } } diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 5cd10afe6e..794040ebd5 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -6,9 +6,9 @@ //! //! Operations: //! - `create_process` — create a new empty process -//! - `fork_process` — clone a parent process via serialize/deserialize +//! - `fork_process_for_caller` — clone a parent after exact-task validation //! - `remove_process` — remove a process from the table -//! - `set_current_pid` — select which process is being serviced +//! - `bind_current_tid` — validate the exact task being serviced extern crate alloc; @@ -17,15 +17,37 @@ use alloc::vec::Vec; use core::cell::UnsafeCell; use core::sync::atomic::AtomicI32; -use wasm_posix_shared::flags::O_ACCMODE; use wasm_posix_shared::Errno; +use wasm_posix_shared::flags::O_ACCMODE; use crate::lock::AdvisoryLockManager; use crate::ofd::FileType; +#[cfg(test)] +use crate::process::ThreadInfo; use crate::process::{ChildWaitEvent, Process, ProcessState, StdioConfig}; const INITIAL_FORK_STATE_BUFFER_LEN: usize = 64 * 1024; const MAX_FORK_STATE_BUFFER_LEN: usize = 4 * 1024 * 1024; +pub(crate) const SYNTHETIC_INIT_PID: u32 = 1; +const FIRST_TASK_ID: u32 = 100; +const MAX_TASK_ID: u32 = i32::MAX as u32; + +/// A fresh machine-wide task identity minted by [`ProcessTable`]. +/// +/// The private field and absence of `Copy`/`Clone` make this a linear safe-Rust +/// capability: production process/thread constructors must consume it, while +/// code outside this module cannot manufacture or duplicate one. +pub(crate) struct AllocatedTaskId(u32); + +impl AllocatedTaskId { + pub(crate) fn as_raw(&self) -> u32 { + self.0 + } + + pub(crate) fn into_raw(self) -> u32 { + self.0 + } +} /// Owning pid of `/dev/fb0`, or `-1` if no process holds it. /// @@ -37,36 +59,60 @@ pub static FB0_OWNER: AtomicI32 = AtomicI32::new(-1); /// Table of all processes managed by the kernel. /// -/// Each process is identified by its pid. The `current_pid` field tracks -/// which process is currently being serviced (set by the JS host before -/// calling `kernel_handle_channel`). +/// Each process is identified by its pid. The current pid/tid pair is a +/// short-lived, validated dispatch binding for one syscall channel; it is not +/// an alternate process selector or identity authority. +/// +/// Production callers cannot create a second task-ID allocator: +/// +/// ```compile_fail +/// use kandelo_kernel::process_table::ProcessTable; +/// let _ = ProcessTable::new(); +/// ``` pub struct ProcessTable { + #[cfg(not(test))] + processes: BTreeMap, + // Cross-module unit tests build Process fixtures directly. Production + // code cannot bypass the guarded accessors below. + #[cfg(test)] pub(crate) processes: BTreeMap, /// Sole machine-wide authority for POSIX, OFD, and flock advisory locks. advisory_locks: AdvisoryLockManager, current_pid: u32, - next_spawn_pid: u32, + /// Next machine-wide process or thread identity to consider. + /// + /// This cursor is monotonic and remains ahead of every identity ever + /// allocated by this kernel instance. `MAX_TASK_ID + 1` is the exhausted + /// sentinel, so successful IDs always fit in the positive `i32` ABI. + next_task_id: u32, /// Kernel/libc thread id for the syscall currently being serviced. /// /// The host already selected a syscall channel by its `channelOffset`; this /// field supplies the POSIX thread identity that cannot be inferred from the - /// `kernel_handle_channel(pid)` call. `0` means "main thread" and is also - /// the fallback for callers that do not bind a pthread TID. + /// `kernel_handle_channel(pid)` call. Production bindings always use an + /// explicit positive task ID; `0` remains an internal unit-test sentinel. /// /// This is ambient dispatch context for the current serialized kernel call. /// If a single kernel instance ever services channels concurrently or /// reentrantly, the TID should move into the syscall header or be passed as /// an explicit `kernel_handle_channel` argument. current_tid: u32, + /// Process that owns `current_tid` for the pending serialized dispatch. + /// Keeping the pair prevents a stale or misrouted host dispatch from + /// applying one process's valid TID to another process. + current_tid_pid: u32, } -/// Outcome of `ProcessTable::remove_process`. Bundles the removed -/// `Process` with side-effect lists the caller must drain: file, directory, -/// and AF_INET host handles released during cleanup. The caller is -/// `kernel_remove_process`, which has access to the raw host-close externs; -/// this layer doesn't. +/// Outcome of `ProcessTable::remove_process`. Bundles the side effects the +/// caller must drain after the removed process has been consumed here. The +/// caller is `kernel_remove_process`, which has access to the raw host-close +/// externs; this layer doesn't. pub struct RemoveProcessResult { - pub process: Process, + /// Whether the removed process had a live framebuffer mapping that the + /// host must unbind. The owned `Process` deliberately does not escape the + /// table: otherwise it could replace a different table entry wholesale + /// and smuggle its immutable PID under the wrong map key. + pub had_framebuffer_binding: bool, /// Host file handles whose cross-process refcount reached 0 during /// teardown. The caller must invoke `host_close(h)` on each. pub host_closes: Vec, @@ -81,15 +127,6 @@ pub struct RemoveProcessResult { pub host_net_closes: Vec, } -/// Host resources released while the retained legacy exec ABI replaces a -/// Process in place. The Wasm export layer drains these through the normal -/// host close imports after the infallible replacement commit. -#[derive(Debug, Default, PartialEq, Eq)] -pub(crate) struct LegacyExecCleanup { - pub host_closes: Vec, - pub host_dir_closes: Vec, -} - /// Subset of parent state inherited by a `posix_spawn` child. Captured up /// front under an immutable `&parent` borrow so the rest of `spawn_child` /// can mutate `self.processes` freely. @@ -292,16 +329,29 @@ fn serialize_fork_state_with_growing_buffer(parent: &Process) -> Result, } impl ProcessTable { - pub const fn new() -> Self { + const fn new_inner() -> Self { ProcessTable { processes: BTreeMap::new(), advisory_locks: AdvisoryLockManager::new(), current_pid: 0, - next_spawn_pid: 2, + next_task_id: FIRST_TASK_ID, current_tid: 0, + current_tid_pid: 0, } } + /// Construct the one production process table owned by the kernel. + #[cfg(not(test))] + const fn new() -> Self { + Self::new_inner() + } + + /// Construct an isolated process-table fixture. + #[cfg(test)] + pub(crate) const fn new() -> Self { + Self::new_inner() + } + /// Create a new process with captured, pipe-backed stdio and add it to /// the table. /// @@ -309,27 +359,36 @@ impl ProcessTable { /// no worker — it exists so that `kill(1, ...)` and `sched_*(1, ...)` from /// user processes resolve to a real target owned by root, enabling EPERM /// checks to fire instead of ESRCH. - pub fn create_process(&mut self, pid: u32) -> Result<(), ()> { - self.create_process_with_stdio(pid, StdioConfig::captured()) + pub fn create_process(&mut self) -> Result { + self.create_process_with_stdio(StdioConfig::captured()) } /// Create a new process with explicit stdio wiring and add it to the table. - pub fn create_process_with_stdio(&mut self, pid: u32, stdio: StdioConfig) -> Result<(), ()> { + pub fn create_process_with_stdio(&mut self, stdio: StdioConfig) -> Result { self.ensure_init(); - if self.processes.contains_key(&pid) { - return Err(()); - } - self.processes.insert(pid, Process::new_with_stdio(pid, stdio)); - Ok(()) + let task_id = self.allocate_task_id()?; + let pid = task_id.as_raw(); + self.processes + .insert(pid, Process::new_allocated_with_stdio(task_id, stdio)); + Ok(pid) } /// Ensure the virtual init process (pid 1) is present. Idempotent. pub fn ensure_init(&mut self) { - if !self.processes.contains_key(&1) { - let mut init = Process::new(1); + if !self.processes.contains_key(&SYNTHETIC_INIT_PID) { + // PID 1 is a reserved kernel identity rather than an allocation + // from the user task sequence, so mint its capability here at the + // sole identity-authority boundary. + let mut init = Process::new_allocated(AllocatedTaskId(SYNTHETIC_INIT_PID)); init.ppid = 0; init.argv.push(alloc::vec::Vec::from(b"init".as_slice())); - self.processes.insert(1, init); + // PID 1 is an addressable kernel identity, not a schedulable + // process. It must not own normal-process descriptors or terminal + // state that could later be mutated or cleaned up by a host path. + init.fd_table = crate::fd::FdTable::new(); + init.ofd_table = crate::ofd::OfdTable::new(); + init.terminal.foreground_pgid = 0; + self.processes.insert(SYNTHETIC_INIT_PID, init); } } @@ -357,6 +416,12 @@ impl ProcessTable { pid: u32, retain_limbo_leader: bool, ) -> Option { + // PID 1 is the kernel-reserved synthetic init identity. It is outside + // the allocatable task sequence and must remain present for the entire + // kernel instance rather than being removed and lazily recreated. + if pid == SYNTHETIC_INIT_PID { + return None; + } let 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(); @@ -599,7 +664,7 @@ impl ProcessTable { } Some(RemoveProcessResult { - process: proc, + had_framebuffer_binding: proc.fb_binding.is_some(), host_closes, host_dir_closes, host_net_closes, @@ -613,7 +678,7 @@ impl ProcessTable { } fn limbo_process_from(proc: &Process) -> Process { - let mut limbo = Process::new(proc.pid); + let mut limbo = Process::new_allocated(AllocatedTaskId(proc.pid)); limbo.ppid = proc.ppid; limbo.uid = proc.uid; limbo.gid = proc.gid; @@ -657,34 +722,111 @@ impl ProcessTable { } } - /// Set the current pid for syscall dispatch. - pub fn set_current_pid(&mut self, pid: u32) { - self.current_pid = pid; - } - /// Get the current pid. pub fn current_pid(&self) -> u32 { - self.current_pid + if self.has_current_tid_binding(self.current_pid) { + self.current_pid + } else { + 0 + } } - /// Set the current kernel/libc thread id for the next serialized dispatch. + /// Bind the current kernel/libc thread id for the next serialized dispatch. /// - /// The host calls this after selecting a pthread channel and before - /// `kernel_handle_channel` so gettid, set_tid_address, pthread signal masks, - /// directed signal delivery, and clear-TID cleanup all refer to the calling - /// thread. `0` means "main thread" and is the default. - pub fn set_current_tid(&mut self, tid: u32) { + /// The host transports the channel-to-TID association, but it cannot mint + /// that identity: a non-main TID must already belong to the addressed live + /// Process. The process PID explicitly names the main thread; zero is not + /// accepted at this host-callable boundary. + pub fn bind_current_tid(&mut self, pid: u32, tid: u32) -> Result<(), Errno> { + // Every bind attempt supersedes any earlier ambient authority, even + // when validation fails. Otherwise a stale same-PID binding could + // authorize the next mailbox after a rejected replacement attempt. + self.clear_current_tid_binding(); + self.validate_task(pid, tid)?; + self.current_pid = pid; + self.current_tid = tid; + self.current_tid_pid = pid; + Ok(()) + } + + /// Validate an exact live task without installing ambient dispatch state. + /// + /// Host registration uses this read-only query before attaching transport + /// metadata. Only `bind_current_tid` may create the one-shot authority used + /// by `kernel_handle_channel`. + pub fn validate_task(&self, pid: u32, tid: u32) -> Result<(), Errno> { + if pid == SYNTHETIC_INIT_PID { + return Err(Errno::ESRCH); + } + let process = self.processes.get(&pid).ok_or(Errno::ESRCH)?; + if !matches!(process.state, ProcessState::Running | ProcessState::Stopped) + || !process.is_live_explicit_tid(tid) + { + return Err(Errno::ESRCH); + } + Ok(()) + } + + /// Whether the next channel dispatch has an explicit, live task binding + /// for exactly `pid`. + pub fn has_current_tid_binding(&self, pid: u32) -> bool { + if self.current_tid_pid != pid || self.current_tid == 0 { + return false; + } + self.processes.get(&pid).is_some_and(|process| { + matches!(process.state, ProcessState::Running | ProcessState::Stopped) + && process.is_live_explicit_tid(self.current_tid) + }) + } + + /// 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) { + self.current_pid = 0; + self.current_tid = 0; + self.current_tid_pid = 0; + } + + /// Set synthetic dispatch state in unit tests that exercise a standalone + /// `Process` without installing it in the global ProcessTable. + #[cfg(test)] + pub(crate) fn set_current_tid_for_test(&mut self, tid: u32) { self.current_tid = tid; + self.current_tid_pid = 0; } /// Get the current kernel/libc thread id (0 for main thread). pub fn current_tid(&self) -> u32 { - self.current_tid + // `current_tid_pid == 0` is reserved for isolated unit-test dispatch + // state installed by `set_current_tid_for_test`. + if self.current_tid_pid == 0 { + return self.current_tid; + } + if self.current_tid_pid != self.current_pid { + return 0; + } + let Some(process) = self.processes.get(&self.current_pid) else { + return 0; + }; + if matches!(process.state, ProcessState::Exited | ProcessState::Limbo) { + return 0; + } + if process.is_main_thread(self.current_tid) + || process.get_thread(self.current_tid).is_some() + { + self.current_tid + } else { + 0 + } } /// Get a mutable reference to the current process. pub fn current_process(&mut self) -> Option<&mut Process> { - self.processes.get_mut(&self.current_pid) + let pid = self.current_pid; + if !self.has_current_tid_binding(pid) { + return None; + } + self.processes.get_mut(&pid) } /// Borrow the current process and machine-wide lock manager together. @@ -694,6 +836,9 @@ impl ProcessTable { &mut self, ) -> Option<(&mut Process, &mut AdvisoryLockManager)> { let pid = self.current_pid; + if !self.has_current_tid_binding(pid) { + return None; + } let processes = &mut self.processes; let advisory_locks = &mut self.advisory_locks; processes @@ -706,6 +851,9 @@ impl ProcessTable { &mut self, pid: u32, ) -> Option<(&mut Process, &mut AdvisoryLockManager)> { + if pid == SYNTHETIC_INIT_PID { + return None; + } let processes = &mut self.processes; let advisory_locks = &mut self.advisory_locks; processes @@ -713,6 +861,27 @@ impl ProcessTable { .map(|process| (process, advisory_locks)) } + /// Borrow an ordinary process only when `tid` names one of its exact live + /// kernel-owned tasks. This is the mutation boundary for host operations + /// that carry explicit `(pid, tid)` transport metadata instead of using a + /// channel dispatch binding. + pub fn task_and_advisory_locks( + &mut self, + pid: u32, + tid: u32, + ) -> Option<(&mut Process, &mut AdvisoryLockManager)> { + if pid == SYNTHETIC_INIT_PID { + return None; + } + let processes = &mut self.processes; + let advisory_locks = &mut self.advisory_locks; + let process = processes.get_mut(&pid)?; + if !process.is_live_explicit_tid(tid) { + return None; + } + Some((process, advisory_locks)) + } + #[cfg(test)] pub fn advisory_locks(&self) -> &AdvisoryLockManager { &self.advisory_locks @@ -725,17 +894,19 @@ impl ProcessTable { /// Get a mutable reference to a process by pid. pub fn get_mut(&mut self, pid: u32) -> Option<&mut Process> { + if pid == SYNTHETIC_INIT_PID { + return None; + } self.processes.get_mut(&pid) } - /// Fork a process: serialize the parent's state and deserialize it as the child. - /// Uses the existing fork serialization infrastructure to deep-copy Process state. - /// Returns Ok(()) on success, Err(errno) on failure. - pub fn fork_process(&mut self, parent_pid: u32, child_pid: u32) -> Result<(), Errno> { - if self.processes.contains_key(&child_pid) { - return Err(Errno::EEXIST); - } - let serialized_parent = { + /// Fork a process on behalf of a kernel-validated task in that process. + pub fn fork_process_for_caller( + &mut self, + parent_pid: u32, + caller_tid: u32, + ) -> Result { + let (serialized_parent, caller_blocked) = { let parent = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?; if matches!( parent.state, @@ -743,11 +914,26 @@ impl ProcessTable { ) { return Err(Errno::ESRCH); } - serialize_fork_state_with_growing_buffer(parent)? + if !parent.is_live_explicit_tid(caller_tid) { + return Err(Errno::ESRCH); + } + ( + serialize_fork_state_with_growing_buffer(parent)?, + parent.blocked_for(caller_tid), + ) }; - // Deserialize as child - let mut child = crate::fork::deserialize_fork_state(&serialized_parent, child_pid)?; + let child_task_id = self.allocate_task_id()?; + let child_pid = child_task_id.as_raw(); + + // Install fork state into a record whose identity capability was + // 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)?; + // 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; // Bump cross-process refcounts on inherited fd state (host handles, // global pipes, PTYs, socket-pipes). Identical to spawn's needs — @@ -768,224 +954,14 @@ impl ProcessTable { parent.increment_fork_count(); } - Ok(()) - } - - /// Insert a process produced by the retained legacy fork-state ABI. - /// Unlike a raw map insert, this refuses to replace an existing pid and - /// either establishes same-instance inherited refs or preserves fresh- - /// kernel sole ownership before moving the process into the table. - #[cfg_attr( - not(any(target_arch = "wasm32", target_arch = "wasm64")), - allow(dead_code) - )] - pub(crate) fn insert_legacy_fork_process(&mut self, child: Process) -> Result<(), Errno> { - if self.processes.contains_key(&child.pid) { - return Err(Errno::EEXIST); - } - - if self.processes.contains_key(&child.ppid) { - // Same-instance legacy install: the parent still owns every - // inherited resource, so establish the child's additional refs. - bump_inherited_resource_refcounts(child.ppid, &child)?; - } else { - // The retained ABI also initializes a fresh kernel instance where - // the parent Process is intentionally absent. Ordinary host-backed - // handles are sole-owned by that child and must not receive a - // phantom parent ref. Kernel-global descriptor backings are not - // serialized, however, so accepting one here could alias a reused - // slot; fail truthfully instead. - if child.ofd_table.iter().any(|(_, ofd)| { - crate::descriptor_backing::manages_ofd(ofd.file_type, ofd.host_handle) - }) { - return Err(Errno::EBADF); - } - } - self.processes.insert(child.pid, child); - Ok(()) - } - - /// Replace an existing process through the retained legacy exec-state - /// ABI, transferring one ownership reference for surviving descriptor - /// backings and releasing old CLOEXEC-only/orphaned OFDs exactly once. - #[cfg_attr( - not(any(target_arch = "wasm32", target_arch = "wasm64")), - allow(dead_code) - )] - pub(crate) fn replace_legacy_exec_process( - &mut self, - pid: u32, - mut replacement: Process, - ) -> Result { - let mut cleanup = LegacyExecCleanup::default(); - if replacement.pid != pid { - return Err(Errno::EINVAL); - } - if let Some(old) = self.processes.get(&pid) { - if matches!(old.state, ProcessState::Exited | ProcessState::Limbo) { - return Err(Errno::ESRCH); - } - // Exec replaces the image, not the process identity or its - // job-control state or unconsumed parent-visible status record. - replacement.state = old.state; - replacement.wait_event = old.wait_event; - - // The exec wire format preserves OFD table indices. Validate that - // every replacement entry is transferring the exact old entry at - // that index. OfdId alone is deliberately insufficient here: - // SCM_RIGHTS can install multiple process-local OFD entries that - // share one machine-wide identity, and CLOEXEC may remove only one - // of those ownership references. - for (index, new_ofd) in replacement.ofd_table.iter() { - let Some(old_ofd) = old.ofd_table.get(index) else { - return Err(Errno::EBADF); - }; - if new_ofd.ofd_id != old_ofd.ofd_id - || new_ofd.file_id != old_ofd.file_id - || new_ofd.file_type != old_ofd.file_type - || new_ofd.host_handle != old_ofd.host_handle - { - return Err(Errno::EBADF); - } - } - let removed = crate::descriptor_backing::removed_backings_for_exec(old, &replacement)?; - - // The retained exec wire format omits CLOEXEC descriptors. Track - // their file identities before replacing the Process so POSIX's - // "close any descriptor for this file" rule is applied to the - // process-owned namespace. Stable OfdIds independently identify - // descriptions that have no surviving machine reference. - let mut closed_file_ids = Vec::new(); - for (fd, entry) in old.fd_table.iter() { - let old_ofd = match old.ofd_table.get(entry.ofd_ref.0) { - Some(ofd) => ofd, - None => continue, - }; - let survived = replacement - .fd_table - .get(fd) - .ok() - .and_then(|new_entry| replacement.ofd_table.get(new_entry.ofd_ref.0)) - .is_some_and(|new_ofd| new_ofd.ofd_id == old_ofd.ofd_id); - if !survived { - if let Some(file_id) = old_ofd.file_id { - if !closed_file_ids.contains(&file_id) { - closed_file_ids.push(file_id); - } - } - } - } - - let mut orphaned_ofd_ids = Vec::new(); - let mut removed_host_handles = Vec::new(); - let mut removed_host_dir_handles = Vec::new(); - for (old_index, old_ofd) in old.ofd_table.iter() { - // Resource ownership belongs to each process-local OFD entry, - // while advisory locks belong to the stable machine-wide - // OfdId. Keep those two survival questions separate. - let resource_survives_exec = replacement - .ofd_table - .get(old_index) - .is_some_and(|new_ofd| new_ofd.ofd_id == old_ofd.ofd_id); - let identity_survives_exec = replacement - .ofd_table - .iter() - .any(|(_, new_ofd)| new_ofd.ofd_id == old_ofd.ofd_id); - // Directory iteration handles are deliberately not serialized - // across exec; even a surviving OFD restarts with -1. - if old_ofd.dir_host_handle >= 0 { - removed_host_dir_handles.push(old_ofd.dir_host_handle); - } - if !resource_survives_exec { - if old_ofd.host_handle >= 0 - && matches!( - old_ofd.file_type, - FileType::Regular - | FileType::Directory - | FileType::CharDevice - | FileType::Pipe - ) - { - removed_host_handles.push(old_ofd.host_handle); - } - } - let referenced_by_peer = self.processes.iter().any(|(&other_pid, peer)| { - other_pid != pid - && peer - .ofd_table - .iter() - .any(|(_, peer_ofd)| peer_ofd.ofd_id == old_ofd.ofd_id) - }) || crate::ofd::has_in_flight_ofd(old_ofd.ofd_id); - if !identity_survives_exec - && !referenced_by_peer - && !orphaned_ofd_ids.contains(&old_ofd.ofd_id) - { - orphaned_ofd_ids.push(old_ofd.ofd_id); - } - } - // POSIX directory streams are also discarded across exec, but - // they live outside the OFD table and are intentionally absent - // from the legacy exec payload. Return their host iterator - // handles alongside the per-OFD iterators before dropping the - // old Process. - for stream in old.dir_streams.iter().flatten() { - removed_host_dir_handles.push(stream.host_handle); - } - - let old = self.processes.insert(pid, replacement).unwrap(); - crate::descriptor_backing::release_backings(&removed); - drop(old); - - cleanup.host_dir_closes = removed_host_dir_handles; - for host_handle in removed_host_handles { - if crate::ofd::host_handle_close_ref(host_handle) { - cleanup.host_closes.push(host_handle); - } - } - - let mut locks_changed = false; - for file_id in closed_file_ids { - locks_changed |= self.advisory_locks.remove_process_file(pid, file_id).changed; - } - for ofd_id in orphaned_ofd_ids { - locks_changed |= self.advisory_locks.remove_ofd(ofd_id).changed; - } - if locks_changed { - crate::wakeup::push_advisory_lock(); - } - } else { - // A fresh kernel instance has no old Process from which to - // transfer global backing ownership, and the retained wire format - // does not serialize those backing values. Reject them instead of - // letting a stale stable index alias this instance's current or - // future allocation at the same slot. - if replacement.ofd_table.iter().any(|(_, ofd)| { - crate::descriptor_backing::manages_ofd(ofd.file_type, ofd.host_handle) - }) { - return Err(Errno::EBADF); - } - self.processes.insert(pid, replacement); - } - Ok(cleanup) + Ok(child_pid) } - /// Non-forking spawn: build a child process for `posix_spawn` without - /// going through fork continuation at all. The child is constructed from a - /// fresh `Process::new(child_pid)` and selectively inherits only what - /// POSIX requires (identity, cwd, umask, rlimits, signal mask, fd - /// state); everything else (signal handlers, threads, mmap, alt-stack, - /// terminal state, pending signals, alarms) is left at the - /// `Process::new` defaults — exec semantics would reset those anyway. - /// - /// `argv` and `envp` come from the spawn caller, not the parent. - /// - /// Critically, `fork_count` on the parent is **not** incremented. - /// - /// File actions and spawn attributes are accepted but not yet applied - /// (Tasks 8 / 9). - pub fn spawn_child( + /// Non-forking spawn on behalf of a kernel-validated task in the parent. + pub fn spawn_child_for_caller( &mut self, parent_pid: u32, + caller_tid: u32, argv: &[&[u8]], envp: &[&[u8]], file_actions: &[crate::spawn::FileAction], @@ -1001,6 +977,9 @@ impl ProcessTable { ) { return Err(Errno::ESRCH); } + if !parent.is_live_explicit_tid(caller_tid) { + return Err(Errno::ESRCH); + } // Compute the SIG_IGN-disposition bitmask for signals 1..=64. let mut ignored_signals: u64 = 0; for sig in 1u32..=64 { @@ -1019,7 +998,7 @@ impl ProcessTable { nice: parent.nice, rlimits: parent.rlimits, cwd: parent.cwd.clone(), - blocked_signals: parent.signals.blocked, + blocked_signals: parent.blocked_for(caller_tid), ignored_signals, fd_table: parent.fd_table.clone(), ofd_table: parent.ofd_table.clone(), @@ -1027,8 +1006,9 @@ impl ProcessTable { } }; - let child_pid = self.allocate_spawn_pid(); - let mut child = Process::new(child_pid); + let child_task_id = self.allocate_task_id()?; + let child_pid = child_task_id.as_raw(); + let mut child = Process::new_allocated(child_task_id); // ── POSIX-required inheritance ───────────────────────────────── child.ppid = parent_pid; @@ -1252,19 +1232,55 @@ impl ProcessTable { Ok(()) } - /// Next unused spawn pid >= 2 (pid 1 is reserved for init). + /// Allocate the sole machine-wide POSIX task identity. /// - /// Keep this monotonic instead of reusing the smallest recently-reaped pid. - /// The JS host can still be asynchronously terminating pthread workers for - /// the old process generation after waitpid reaps it; avoiding immediate - /// pid reuse prevents stale host cleanup from targeting the new child. - fn allocate_spawn_pid(&mut self) -> u32 { - let mut pid = self.next_spawn_pid.max(2); - while self.processes.contains_key(&pid) { - pid += 1; + /// Process IDs and pthread thread IDs share this monotonically increasing + /// namespace. IDs are never reused within a kernel instance, including + /// after process reaping or thread exit. Exhaustion is reported instead of + /// wrapping into reserved IDs or the negative half of the `i32` ABI. + fn allocate_task_id(&mut self) -> Result { + let mut candidate = self.next_task_id; + while candidate <= MAX_TASK_ID { + let in_use = self.processes.contains_key(&candidate) + || self + .processes + .values() + .any(|process| process.get_thread(candidate).is_some()); + if !in_use { + self.next_task_id = candidate + 1; + return Ok(AllocatedTaskId(candidate)); + } + candidate += 1; } - self.next_spawn_pid = pid.saturating_add(1).max(2); - pid + self.next_task_id = MAX_TASK_ID + 1; + Err(Errno::EAGAIN) + } + + /// Create a pthread task in an existing live process. + pub(crate) fn create_thread( + &mut self, + pid: u32, + caller_tid: u32, + stack_ptr: usize, + tls_ptr: usize, + ctid_ptr: usize, + ) -> Result { + let inherited_blocked = { + let process = self.processes.get(&pid).ok_or(Errno::ESRCH)?; + if matches!(process.state, ProcessState::Exited | ProcessState::Limbo) { + return Err(Errno::ESRCH); + } + if !process.is_live_explicit_tid(caller_tid) { + return Err(Errno::ESRCH); + } + process.blocked_for(caller_tid) + }; + let task_id = self.allocate_task_id()?; + let tid = task_id.as_raw(); + let process = self.processes.get_mut(&pid).ok_or(Errno::ESRCH)?; + let thread_info = process.add_allocated_thread(task_id, ctid_ptr, stack_ptr, tls_ptr); + thread_info.signals.blocked = inherited_blocked; + Ok(tid) } /// Get a reference to a process by pid. @@ -1272,12 +1288,34 @@ impl ProcessTable { self.processes.get(&pid) } + /// Iterate live, ordinary processes from newest to oldest identity. + /// + /// 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 { + self.processes.iter().rev().filter_map(|(&pid, process)| { + if pid == SYNTHETIC_INIT_PID + || matches!(process.state, ProcessState::Exited | ProcessState::Limbo) + { + None + } else { + Some((pid, process)) + } + }) + } + /// Find the process record that owns a retained Linux-style task ID. /// /// A process leader's TID is its PID; pthread TIDs live in the owning /// Process record. Exited leaders remain addressable until reaped, while a /// Limbo record is only an internal process-group/session placeholder. pub fn get_process_containing_task(&self, tid: u32) -> Option<&Process> { + if tid == SYNTHETIC_INIT_PID { + return None; + } if let Some(leader) = self .processes .get(&tid) @@ -1351,7 +1389,10 @@ impl ProcessTable { let mut saw_matching_child = false; for (&child_pid, child) in &mut self.processes { - if child.ppid != parent_pid || child.state == ProcessState::Limbo { + if child_pid == SYNTHETIC_INIT_PID + || child.ppid != parent_pid + || child.state == ProcessState::Limbo + { continue; } if !Self::child_matches_wait_target(child_pid, child, target_pid, parent_pgid) { @@ -1413,26 +1454,268 @@ mod wait_tests { use super::*; #[test] - fn spawn_pid_allocation_does_not_reuse_reaped_pid() { + fn task_ids_are_shared_by_create_clone_fork_and_spawn() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + 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 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(); + let top_level_pid = table.create_process().unwrap(); + + assert_eq!( + [parent_pid, tid, fork_pid, spawn_pid, top_level_pid], + [100, 101, 102, 103, 104] + ); + for pid in [parent_pid, fork_pid, spawn_pid, top_level_pid] { + assert_eq!( + table.get(pid).unwrap().pid, + pid, + "ProcessTable key and immutable process identity diverged" + ); + } + assert_eq!( + table.get(parent_pid).unwrap().get_thread(tid).unwrap().tid, + tid + ); + assert_eq!(table.get(fork_pid).unwrap().ppid, parent_pid); + assert_eq!(table.get(spawn_pid).unwrap().ppid, parent_pid); + } + + #[test] + fn fork_and_spawn_inherit_the_kernel_validated_callers_signal_mask() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + 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 parent = table.get_mut(parent_pid).unwrap(); + parent.signals.blocked = 0x11; + parent.get_thread_mut(caller_tid).unwrap().signals.blocked = 0x22; - let first_pid = table.allocate_spawn_pid(); - table.processes.insert(first_pid, Process::new(first_pid)); - table.processes.remove(&first_pid); + let fork_pid = table + .fork_process_for_caller(parent_pid, caller_tid) + .unwrap(); + assert_eq!(table.get(fork_pid).unwrap().signals.blocked, 0x22); - let second_pid = table.allocate_spawn_pid(); - assert!( - second_pid > first_pid, - "spawn pid allocation must not immediately reuse a reaped pid" + 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(); + assert_eq!(table.get(spawn_pid).unwrap().signals.blocked, 0x22); + } + + #[test] + fn fork_and_spawn_reject_unallocated_caller_task_ids() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + let parent_pid = table.create_process().unwrap(); + let unknown_tid = parent_pid + 1; + + assert_eq!( + table.fork_process_for_caller(parent_pid, 0), + Err(Errno::ESRCH) + ); + assert_eq!( + table.fork_process_for_caller(parent_pid, unknown_tid), + Err(Errno::ESRCH) + ); + let mut host = NoopHost; + assert_eq!( + table.spawn_child_for_caller( + parent_pid, + unknown_tid, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ), + Err(Errno::ESRCH) + ); + assert_eq!( + table.create_process(), + Ok(unknown_tid), + "rejected identities must not consume a kernel task ID" + ); + } + + #[test] + fn task_id_allocation_skips_retained_processes_and_threads() { + let mut table = ProcessTable::new(); + let mut zombie = Process::new(100); + zombie.state = ProcessState::Exited; + zombie.add_thread(ThreadInfo::new(101, 0, 0, 0)); + table.processes.insert(100, zombie); + + assert_eq!(table.allocate_task_id().map(|id| id.into_raw()), Ok(102)); + } + + #[test] + fn task_ids_are_not_reused_and_exhaustion_is_reported() { + let mut table = ProcessTable::new(); + let first_pid = table.create_process().unwrap(); + table.remove_process(first_pid).unwrap(); + assert_eq!(table.create_process(), Ok(first_pid + 1)); + + table.next_task_id = MAX_TASK_ID; + assert_eq!(table.create_process(), Ok(MAX_TASK_ID)); + assert_eq!(table.create_process(), Err(Errno::EAGAIN)); + table.remove_process(MAX_TASK_ID).unwrap(); + assert_eq!(table.create_process(), Err(Errno::EAGAIN)); + } + + #[test] + fn synthetic_init_reservation_cannot_be_removed_or_reaped() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + let first_pid = table.create_process().unwrap(); + assert!(table.get(1).is_some()); + assert!(table.get_process_containing_task(1).is_none()); + assert!(table.get_mut(1).is_none()); + assert!(table.process_and_advisory_locks(1).is_none()); + assert!(table.task_and_advisory_locks(1, 1).is_none()); + assert!(table.current_process().is_none()); + assert!(table.current_process_and_advisory_locks().is_none()); + + assert_eq!(table.bind_current_tid(1, 1), Err(Errno::ESRCH)); + assert_eq!(table.create_thread(1, 1, 0, 0, 0), Err(Errno::ESRCH)); + assert_eq!(table.fork_process_for_caller(1, 1), Err(Errno::ESRCH)); + let mut host = NoopHost; + assert_eq!( + table.spawn_child_for_caller( + 1, + 1, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ), + Err(Errno::ESRCH), + ); + assert!(table.remove_process(1).is_none()); + assert!(table.reap_process(1).is_none()); + assert!(table.get(1).is_some()); + assert_eq!(table.create_process(), Ok(first_pid + 1)); + } + + #[test] + fn dispatch_tid_binding_accepts_only_kernel_owned_tasks() { + let mut table = ProcessTable::new(); + let pid = table.create_process().unwrap(); + let tid = table.create_thread(pid, pid, 0, 0, 0).unwrap(); + + assert_eq!(table.bind_current_tid(pid, 0), Err(Errno::ESRCH)); + assert_eq!(table.bind_current_tid(pid, pid), Ok(())); + 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.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.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!(table.current_process().is_none()); + assert!(table.current_process_and_advisory_locks().is_none()); + + assert_eq!(table.bind_current_tid(pid + 99, 0), Err(Errno::ESRCH)); + assert_eq!(table.current_pid(), 0); + + assert!(table.task_and_advisory_locks(pid, 0).is_none()); + assert!(table.task_and_advisory_locks(pid, pid + 99).is_none()); + assert!(table.task_and_advisory_locks(pid + 99, pid).is_none()); + assert!(table.task_and_advisory_locks(pid, pid).is_some()); + assert!(table.task_and_advisory_locks(pid, tid).is_some()); + + let other_pid = table.create_process().unwrap(); + table.bind_current_tid(other_pid, other_pid).unwrap(); + assert_eq!(table.current_tid(), other_pid); + table.clear_current_tid_binding(); + assert_eq!(table.current_tid(), 0); + + table.bind_current_tid(pid, tid).unwrap(); + table.get_mut(pid).unwrap().state = ProcessState::Exited; + assert_eq!(table.current_pid(), 0); + assert!(table.current_process().is_none()); + assert!(table.current_process_and_advisory_locks().is_none()); + assert!(table.task_and_advisory_locks(pid, pid).is_none()); + assert!(table.task_and_advisory_locks(pid, tid).is_none()); + assert_eq!(table.bind_current_tid(pid, 0), Err(Errno::ESRCH)); + assert_eq!(table.bind_current_tid(pid, tid), Err(Errno::ESRCH)); + assert_eq!(table.current_tid(), 0); + } + + #[test] + fn thread_creation_accepts_only_a_live_caller_owned_by_the_process() { + let mut table = ProcessTable::new(); + let pid = table.create_process().unwrap(); + let other_pid = table.create_process().unwrap(); + + assert_eq!(table.create_thread(pid, 9_999, 0, 0, 0), Err(Errno::ESRCH)); + assert_eq!( + table.create_thread(pid, other_pid, 0, 0, 0), + Err(Errno::ESRCH) + ); + + assert_eq!(table.create_thread(pid, 0, 0, 0, 0), Err(Errno::ESRCH)); + let creator_tid = table.create_thread(pid, pid, 0, 0, 0).unwrap(); + assert_eq!(creator_tid, other_pid + 1); + let child_tid = table.create_thread(pid, creator_tid, 0, 0, 0).unwrap(); + assert_eq!(child_tid, creator_tid + 1); + + table.get_mut(pid).unwrap().remove_thread(creator_tid); + assert_eq!( + table.create_thread(pid, creator_tid, 0, 0, 0), + Err(Errno::ESRCH), + ); + assert_eq!( + table.create_thread(pid, pid, 0, 0, 0).unwrap(), + child_tid + 1, + "rejected caller identities must not consume a task ID", ); } #[test] fn reap_retains_group_leader_as_limbo_until_group_empties() { let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); - table.fork_process(100, 101).unwrap(); - table.fork_process(100, 102).unwrap(); + assert_eq!(table.create_process().unwrap(), 100); + assert_eq!(table.fork_process_for_caller(100, 100).unwrap(), 101); + assert_eq!(table.fork_process_for_caller(100, 100).unwrap(), 102); table.processes.get_mut(&101).unwrap().pgid = 101; table.processes.get_mut(&102).unwrap().pgid = 101; table.processes.get_mut(&101).unwrap().state = ProcessState::Exited; @@ -1462,9 +1745,9 @@ mod wait_tests { #[test] fn remove_process_does_not_create_limbo_record() { let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); - table.fork_process(100, 101).unwrap(); - table.fork_process(100, 102).unwrap(); + assert_eq!(table.create_process().unwrap(), 100); + assert_eq!(table.fork_process_for_caller(100, 100).unwrap(), 101); + assert_eq!(table.fork_process_for_caller(100, 100).unwrap(), 102); table.processes.get_mut(&101).unwrap().pgid = 101; table.processes.get_mut(&102).unwrap().pgid = 101; table.processes.get_mut(&101).unwrap().state = ProcessState::Exited; @@ -1506,147 +1789,6 @@ pub fn current_pid() -> u32 { mod tests { use super::*; - #[test] - fn legacy_state_install_rejects_collisions_but_allows_fresh_kernel_tables() { - let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); - table.get_mut(100).unwrap().argv = alloc::vec![b"original".to_vec()]; - - let mut colliding_fork = Process::new(100); - colliding_fork.ppid = 100; - assert_eq!( - table.insert_legacy_fork_process(colliding_fork), - Err(Errno::EEXIST) - ); - assert_eq!(table.get(100).unwrap().argv[0], b"original"); - - let mut child_without_local_parent = Process::new(101); - child_without_local_parent.ppid = 999; - table - .insert_legacy_fork_process(child_without_local_parent) - .unwrap(); - assert_eq!(table.get(101).unwrap().ppid, 999); - - table - .replace_legacy_exec_process(777, Process::new(777)) - .unwrap(); - assert!(table.get(777).is_some()); - assert_eq!( - table.replace_legacy_exec_process(100, Process::new(102)), - Err(Errno::EINVAL) - ); - assert_eq!(table.get(100).unwrap().argv[0], b"original"); - } - - #[test] - fn legacy_exec_preserves_stopped_state_and_parent_visible_status_record() { - use wasm_posix_shared::signal::SIGTSTP; - use wasm_posix_shared::wait::EVENT_STOPPED; - - let mut table = ProcessTable::new(); - table.create_process(200).unwrap(); - assert!(table.get_mut(200).unwrap().record_stop(SIGTSTP)); - - let serialized = crate::fork::serialize_exec_state_with_growing_buffer( - table.get(200).unwrap(), - ) - .unwrap(); - let replacement = crate::fork::deserialize_exec_state(&serialized, 200).unwrap(); - - table - .replace_legacy_exec_process(200, replacement) - .unwrap(); - - assert_eq!(table.get(200).unwrap().state, ProcessState::Stopped); - let event = table.get(200).unwrap().wait_event.unwrap(); - assert_eq!(event.event_mask, EVENT_STOPPED); - assert_eq!(event.si_status, SIGTSTP as i32); - } - - #[test] - fn legacy_exec_returns_unserialized_directory_stream_handles() { - use crate::process::DirStream; - - let mut table = ProcessTable::new(); - table.create_process(202).unwrap(); - table - .get_mut(202) - .unwrap() - .dir_streams - .push(Some(DirStream { - host_handle: 8_202, - path: b"/tmp".to_vec(), - position: 3, - synth_dot_state: 2, - })); - - let serialized = crate::fork::serialize_exec_state_with_growing_buffer( - table.get(202).unwrap(), - ) - .unwrap(); - let replacement = crate::fork::deserialize_exec_state(&serialized, 202).unwrap(); - assert!(replacement.dir_streams.is_empty()); - - let cleanup = table - .replace_legacy_exec_process(202, replacement) - .unwrap(); - assert_eq!(cleanup.host_dir_closes, vec![8_202]); - } - - #[test] - fn legacy_exec_releases_cloexec_process_and_final_ofd_locks() { - use crate::fd::OpenFileDescRef; - use crate::lock::{AdvisoryLockType, FileId, LockOwner, LockRange}; - use wasm_posix_shared::fd_flags::FD_CLOEXEC; - use wasm_posix_shared::flags::O_RDWR; - - let mut table = ProcessTable::new(); - table.create_process(201).unwrap(); - let file = FileId::Host { dev: 8, ino: 9 }; - let (ofd_id, fd) = { - let process = table.get_mut(201).unwrap(); - let ofd_index = process.ofd_table.create( - FileType::Regular, - O_RDWR, - -50, - b"/cloexec".to_vec(), - ); - process.ofd_table.get_mut(ofd_index).unwrap().file_id = Some(file); - let ofd_id = process.ofd_table.get(ofd_index).unwrap().ofd_id; - let fd = process - .fd_table - .alloc(OpenFileDescRef(ofd_index), FD_CLOEXEC) - .unwrap(); - (ofd_id, fd) - }; - assert!(table.get(201).unwrap().fd_table.get(fd).is_ok()); - table - .advisory_locks - .set_lock( - file, - LockOwner::Process(201), - Some(AdvisoryLockType::Write), - LockRange::normalize(0, 1).unwrap(), - ) - .unwrap(); - table - .advisory_locks - .set_lock( - file, - LockOwner::OpenFileDescription(ofd_id), - Some(AdvisoryLockType::Write), - LockRange::normalize(2, 1).unwrap(), - ) - .unwrap(); - - let serialized = - crate::fork::serialize_exec_state_with_growing_buffer(table.get(201).unwrap()) - .unwrap(); - let replacement = crate::fork::deserialize_exec_state(&serialized, 201).unwrap(); - table.replace_legacy_exec_process(201, replacement).unwrap(); - assert!(table.advisory_locks.is_empty()); - } - #[test] fn fork_pipe_replay_includes_fds_above_default_nofile_limit() { use crate::fd::OpenFileDescRef; @@ -1678,13 +1820,14 @@ mod tests { use crate::spawn::SpawnAttrs; let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); + assert_eq!(table.create_process().unwrap(), 100); table.get_mut(100).unwrap().state = crate::process::ProcessState::Exited; - assert_eq!(table.fork_process(100, 101), Err(Errno::ESRCH)); + assert_eq!(table.fork_process_for_caller(100, 100), Err(Errno::ESRCH)); let mut host = NoopHost; assert_eq!( - table.spawn_child( + table.spawn_child_for_caller( + 100, 100, &[b"/bin/child".as_slice()], &[], @@ -1703,7 +1846,7 @@ mod tests { use wasm_posix_shared::signal::SIGSTOP; let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); + assert_eq!(table.create_process().unwrap(), 100); assert!(table.get_mut(100).unwrap().record_stop(SIGSTOP)); // The host resolves a posix_spawn executable asynchronously. A stop @@ -1711,7 +1854,8 @@ mod tests { // the resolved continuation must still be allowed to create its child. let mut host = NoopHost; let child_pid = table - .spawn_child( + .spawn_child_for_caller( + 100, 100, &[b"/bin/child".as_slice()], &[], @@ -1734,14 +1878,13 @@ mod tests { use crate::spawn::SpawnAttrs; use wasm_posix_shared::flags::O_RDONLY; - const PARENT: u32 = 945_001; const BACKING_HANDLE: i64 = 9_450_010; const ITERATOR_HANDLE: i64 = 9_450_011; let mut table = ProcessTable::new(); - table.create_process(PARENT).unwrap(); + let parent_pid = table.create_process().unwrap(); let inherited_fd = { - let parent = table.get_mut(PARENT).unwrap(); + let parent = table.get_mut(parent_pid).unwrap(); let ofd_idx = parent.ofd_table.create( FileType::Directory, O_RDONLY, @@ -1766,8 +1909,9 @@ mod tests { let mut host = NoopHost; let child_pid = table - .spawn_child( - PARENT, + .spawn_child_for_caller( + parent_pid, + parent_pid, &[b"/bin/child".as_slice()], &[], &[], @@ -1789,9 +1933,14 @@ mod tests { assert_eq!(child_ofd.dir_host_handle, -1); assert!(child_ofd.dir_pending_entry.is_none()); - let parent_entry = table.get(PARENT).unwrap().fd_table.get(inherited_fd).unwrap(); + let parent_entry = table + .get(parent_pid) + .unwrap() + .fd_table + .get(inherited_fd) + .unwrap(); let parent_ofd = table - .get(PARENT) + .get(parent_pid) .unwrap() .ofd_table .get(parent_entry.ofd_ref.0) @@ -1803,7 +1952,7 @@ mod tests { // remains owned by the parent. Parent cleanup releases it exactly once. let child_cleanup = table.remove_process(child_pid).unwrap(); assert!(!child_cleanup.host_dir_closes.contains(&ITERATOR_HANDLE)); - let parent_cleanup = table.remove_process(PARENT).unwrap(); + let parent_cleanup = table.remove_process(parent_pid).unwrap(); assert_eq!( parent_cleanup .host_dir_closes @@ -1820,15 +1969,13 @@ mod tests { use crate::ofd::PendingDirEntry; use wasm_posix_shared::flags::O_RDONLY; - const PARENT: u32 = 945_101; - const CHILD: u32 = 945_102; const BACKING_HANDLE: i64 = 9_451_010; const ITERATOR_HANDLE: i64 = 9_451_011; let mut table = ProcessTable::new(); - table.create_process(PARENT).unwrap(); + let parent_pid = table.create_process().unwrap(); let inherited_fd = { - let parent = table.get_mut(PARENT).unwrap(); + let parent = table.get_mut(parent_pid).unwrap(); let ofd_idx = parent.ofd_table.create( FileType::Directory, O_RDONLY, @@ -1851,11 +1998,18 @@ mod tests { .unwrap() }; - table.fork_process(PARENT, CHILD).unwrap(); + let child_pid = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); - let child_entry = table.get(CHILD).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) + .get(child_pid) .unwrap() .ofd_table .get(child_entry.ofd_ref.0) @@ -1865,9 +2019,9 @@ mod tests { assert_eq!(child_ofd.dir_host_handle, -1); assert!(child_ofd.dir_pending_entry.is_none()); - let child_cleanup = table.remove_process(CHILD).unwrap(); + let child_cleanup = table.remove_process(child_pid).unwrap(); assert!(!child_cleanup.host_dir_closes.contains(&ITERATOR_HANDLE)); - let parent_cleanup = table.remove_process(PARENT).unwrap(); + let parent_cleanup = table.remove_process(parent_pid).unwrap(); assert_eq!( parent_cleanup .host_dir_closes @@ -1888,8 +2042,8 @@ mod tests { let recv_idx = pipe_table.alloc(PipeBuffer::new(DEFAULT_PIPE_CAPACITY)); let mut table = ProcessTable::new(); - table.create_process(950_001).unwrap(); - let proc = table.processes.get_mut(&950_001).unwrap(); + let pid = table.create_process().unwrap(); + let proc = table.processes.get_mut(&pid).unwrap(); let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 6); socket.state = SocketState::Connected; socket.send_buf_idx = Some(send_idx); @@ -1906,7 +2060,7 @@ mod tests { .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) .unwrap(); - table.remove_process(950_001).unwrap(); + table.remove_process(pid).unwrap(); let send_pipe = pipe_table.get_mut(send_idx).unwrap(); assert!(!send_pipe.is_write_end_open()); @@ -1953,7 +2107,7 @@ mod tests { const LARGE_PATH_LEN: usize = 1024; let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); + assert_eq!(table.create_process().unwrap(), 100); let last_fd = { let parent = table.processes.get_mut(&100).unwrap(); @@ -1981,9 +2135,12 @@ mod tests { ); } - table - .fork_process(100, 101) - .expect("fork should grow its process-state buffer"); + assert_eq!( + table + .fork_process_for_caller(100, 100) + .expect("fork should grow its process-state buffer"), + 101 + ); let child = table.processes.get(&101).unwrap(); let child_fd = child.fd_table.get(last_fd).unwrap(); @@ -2003,10 +2160,11 @@ mod tests { crate::socket::udp_cleanup_process(PARENT); crate::socket::udp_cleanup_process(CHILD); let mut table = ProcessTable::new(); - table.create_process(PARENT).unwrap(); + table.next_task_id = PARENT; + assert_eq!(table.create_process().unwrap(), PARENT); let sock_idx = install_bound_udp4_socket(&mut table, PARENT, PORT); - table.fork_process(PARENT, CHILD).unwrap(); + 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); @@ -2042,13 +2200,14 @@ mod tests { crate::socket::udp_cleanup_process(PARENT); let mut table = ProcessTable::new(); - table.create_process(PARENT).unwrap(); + table.next_task_id = PARENT; + assert_eq!(table.create_process().unwrap(), PARENT); let sock_idx = install_bound_udp4_socket(&mut table, PARENT, PORT); let mut host = NoopHost; let child_pid = table - .spawn_child( - PARENT, + .spawn_child_for_caller( + PARENT, PARENT, &[b"/bin/child".as_slice()], &[], &[], @@ -2072,22 +2231,25 @@ mod tests { use wasm_posix_shared::wait::{CLD_EXITED, EVENT_EXITED}; let mut table = ProcessTable::new(); - table.create_process(10).unwrap(); - table.create_process(11).unwrap(); - let child = table.processes.get_mut(&11).unwrap(); - child.ppid = 10; + let parent_pid = table.create_process().unwrap(); + let child_pid = table.create_process().unwrap(); + let child = table.processes.get_mut(&child_pid).unwrap(); + child.ppid = parent_pid; assert!(child.record_normal_exit(7)); let (pid, event) = table - .poll_wait_event(10, -1, EVENT_EXITED, 0) + .poll_wait_event(parent_pid, -1, EVENT_EXITED, 0) .unwrap() .unwrap(); - assert_eq!(pid, 11); + assert_eq!(pid, child_pid); assert_eq!(event.wait_status, 7 << 8); assert_eq!(event.si_code, CLD_EXITED); assert_eq!(event.si_status, 7); - assert!(table.get(11).unwrap().wait_event.is_none()); - assert_eq!(table.poll_wait_event(10, -1, EVENT_EXITED, 0), Ok(None)); + assert!(table.get(child_pid).unwrap().wait_event.is_none()); + assert_eq!( + table.poll_wait_event(parent_pid, -1, EVENT_EXITED, 0), + Ok(None) + ); } #[test] @@ -2095,21 +2257,21 @@ mod tests { use wasm_posix_shared::wait::{CLD_KILLED, EVENT_EXITED, WNOWAIT}; let mut table = ProcessTable::new(); - table.create_process(10).unwrap(); - table.create_process(11).unwrap(); - table.processes.get_mut(&11).unwrap().ppid = 10; - table.get_mut(11).unwrap().record_signal_exit(15); + let parent_pid = table.create_process().unwrap(); + let child_pid = table.create_process().unwrap(); + table.processes.get_mut(&child_pid).unwrap().ppid = parent_pid; + table.get_mut(child_pid).unwrap().record_signal_exit(15); for _ in 0..2 { let (_, event) = table - .poll_wait_event(10, 11, EVENT_EXITED, WNOWAIT) + .poll_wait_event(parent_pid, child_pid as i32, EVENT_EXITED, WNOWAIT) .unwrap() .unwrap(); assert_eq!(event.wait_status, 15); assert_eq!(event.si_code, CLD_KILLED); assert_eq!(event.si_status, 15); } - assert!(table.get(11).unwrap().wait_event.is_some()); + assert!(table.get(child_pid).unwrap().wait_event.is_some()); } #[test] @@ -2118,20 +2280,23 @@ mod tests { use wasm_posix_shared::wait::{EVENT_EXITED, EVENT_STOPPED}; let mut table = ProcessTable::new(); - table.create_process(10).unwrap(); - table.create_process(11).unwrap(); - let child = table.processes.get_mut(&11).unwrap(); - child.ppid = 10; + let parent_pid = table.create_process().unwrap(); + let child_pid = table.create_process().unwrap(); + let child = table.processes.get_mut(&child_pid).unwrap(); + child.ppid = parent_pid; assert!(child.record_stop(SIGTSTP)); - assert_eq!(table.poll_wait_event(10, -1, EVENT_EXITED, 0), Ok(None)); assert_eq!( - table.get(11).unwrap().wait_event.unwrap().event_mask, + table.poll_wait_event(parent_pid, -1, EVENT_EXITED, 0), + Ok(None) + ); + assert_eq!( + table.get(child_pid).unwrap().wait_event.unwrap().event_mask, EVENT_STOPPED ); assert!( table - .poll_wait_event(10, -1, EVENT_STOPPED, 0) + .poll_wait_event(parent_pid, -1, EVENT_STOPPED, 0) .unwrap() .is_some() ); @@ -2142,18 +2307,24 @@ mod tests { use wasm_posix_shared::wait::{EVENT_EXITED, WNOWAIT}; let mut table = ProcessTable::new(); - table.create_process(10).unwrap(); - table.create_process(11).unwrap(); - table.processes.get_mut(&11).unwrap().ppid = 10; + let parent_pid = table.create_process().unwrap(); + let child_pid = table.create_process().unwrap(); + table.processes.get_mut(&child_pid).unwrap().ppid = parent_pid; - assert_eq!(table.poll_wait_event(10, -1, EVENT_EXITED, 0), Ok(None)); assert_eq!( - table.poll_wait_event(10, 12, EVENT_EXITED, 0), + table.poll_wait_event(parent_pid, -1, EVENT_EXITED, 0), + Ok(None) + ); + assert_eq!( + table.poll_wait_event(parent_pid, 999, EVENT_EXITED, 0), Err(Errno::ECHILD) ); - assert_eq!(table.poll_wait_event(10, -1, 0, 0), Err(Errno::EINVAL)); assert_eq!( - table.poll_wait_event(10, -1, EVENT_EXITED, WNOWAIT | 2), + table.poll_wait_event(parent_pid, -1, 0, 0), + Err(Errno::EINVAL) + ); + assert_eq!( + table.poll_wait_event(parent_pid, -1, EVENT_EXITED, WNOWAIT | 2), Err(Errno::EINVAL) ); } @@ -2163,49 +2334,47 @@ mod tests { use wasm_posix_shared::wait::EVENT_EXITED; let mut table = ProcessTable::new(); - table.create_process(10).unwrap(); - table.processes.get_mut(&10).unwrap().pgid = 20; - table.create_process(11).unwrap(); + let parent_pid = table.create_process().unwrap(); + table.processes.get_mut(&parent_pid).unwrap().pgid = 20; + let same_group_child = table.create_process().unwrap(); { - let child = table.processes.get_mut(&11).unwrap(); - child.ppid = 10; + let child = table.processes.get_mut(&same_group_child).unwrap(); + child.ppid = parent_pid; child.pgid = 20; child.record_normal_exit(0); } - table.create_process(12).unwrap(); + let other_group_child = table.create_process().unwrap(); { - let child = table.processes.get_mut(&12).unwrap(); - child.ppid = 10; + let child = table.processes.get_mut(&other_group_child).unwrap(); + child.ppid = parent_pid; child.pgid = 30; child.record_normal_exit(1); } assert_eq!( table - .poll_wait_event(10, 0, EVENT_EXITED, 0) + .poll_wait_event(parent_pid, 0, EVENT_EXITED, 0) .unwrap() .unwrap() .0, - 11 + same_group_child ); assert_eq!( table - .poll_wait_event(10, -30, EVENT_EXITED, 0) + .poll_wait_event(parent_pid, -30, EVENT_EXITED, 0) .unwrap() .unwrap() .0, - 12 + other_group_child ); } #[test] fn remove_process_releases_process_and_final_ofd_locks() { - use crate::lock::{ - AdvisoryLockType, FileId, LockOwner, LockRange, OfdId, - }; + use crate::lock::{AdvisoryLockType, FileId, LockOwner, LockRange, OfdId}; let mut table = ProcessTable::new(); - table.create_process(20).unwrap(); + let pid = table.create_process().unwrap(); let file = FileId::Host { dev: 3, ino: 9 }; let process_range = LockRange::normalize(0, 10).unwrap(); let ofd_range = LockRange::normalize(20, 10).unwrap(); @@ -2215,7 +2384,7 @@ mod tests { .advisory_locks_mut() .set_lock( file, - LockOwner::Process(20), + LockOwner::Process(pid), Some(AdvisoryLockType::Write), process_range, ) @@ -2230,7 +2399,7 @@ mod tests { ) .unwrap(); - let proc = table.processes.get_mut(&20).unwrap(); + let proc = table.processes.get_mut(&pid).unwrap(); let idx = proc.ofd_table.create( FileType::Regular, wasm_posix_shared::flags::O_RDWR, @@ -2242,39 +2411,49 @@ mod tests { .alloc(crate::fd::OpenFileDescRef(idx), 0) .unwrap(); - table.remove_process(20).expect("process removed"); + table.remove_process(pid).expect("process removed"); assert!(table.advisory_locks().is_empty()); } #[test] - fn task_lookup_prefers_leaders_and_excludes_dead_worker_threads() { + fn task_lookup_resolves_unique_leaders_and_excludes_dead_worker_threads() { let mut table = ProcessTable::new(); - table.create_process(100).unwrap(); - table.create_process(200).unwrap(); - table - .get_mut(100) - .unwrap() - .add_thread(crate::process::ThreadInfo::new(900, 0, 0, 0)); - table - .get_mut(100) - .unwrap() - .add_thread(crate::process::ThreadInfo::new(200, 0, 0, 0)); - - assert_eq!(table.get_process_containing_task(100).unwrap().pid, 100); - // An exact process leader wins over a numerically colliding worker TID. - assert_eq!(table.get_process_containing_task(200).unwrap().pid, 200); - assert_eq!(table.get_process_containing_task(900).unwrap().pid, 100); - - table.get_mut(100).unwrap().state = ProcessState::Stopped; - assert_eq!(table.get_process_containing_task(900).unwrap().pid, 100); - table.get_mut(100).unwrap().state = ProcessState::Exited; - assert_eq!(table.get_process_containing_task(100).unwrap().pid, 100); - assert!(table.get_process_containing_task(900).is_none()); - - table.get_mut(200).unwrap().state = ProcessState::Exited; - assert_eq!(table.get_process_containing_task(200).unwrap().pid, 200); - table.get_mut(200).unwrap().state = ProcessState::Limbo; - assert!(table.get_process_containing_task(200).is_none()); + let first_pid = table.create_process().unwrap(); + let second_pid = table.create_process().unwrap(); + let tid = table.create_thread(first_pid, first_pid, 0, 0, 0).unwrap(); + + assert_eq!( + table.get_process_containing_task(first_pid).unwrap().pid, + first_pid + ); + assert_eq!( + table.get_process_containing_task(second_pid).unwrap().pid, + second_pid + ); + assert_eq!( + table.get_process_containing_task(tid).unwrap().pid, + first_pid + ); + + table.get_mut(first_pid).unwrap().state = ProcessState::Stopped; + assert_eq!( + table.get_process_containing_task(tid).unwrap().pid, + first_pid + ); + table.get_mut(first_pid).unwrap().state = ProcessState::Exited; + assert_eq!( + table.get_process_containing_task(first_pid).unwrap().pid, + first_pid + ); + assert!(table.get_process_containing_task(tid).is_none()); + + table.get_mut(second_pid).unwrap().state = ProcessState::Exited; + assert_eq!( + table.get_process_containing_task(second_pid).unwrap().pid, + second_pid + ); + table.get_mut(second_pid).unwrap().state = ProcessState::Limbo; + assert!(table.get_process_containing_task(second_pid).is_none()); assert!(table.get_process_containing_task(9999).is_none()); } diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 4d66ca0d8b..4eb39710e6 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -1111,14 +1111,15 @@ mod tests { #[test] fn foreign_procfs_directory_preserves_specific_errors() { let mut table = crate::process_table::ProcessTable::new(); - table.create_process(42).unwrap(); + let pid = table.create_process().unwrap(); + assert_eq!(pid, 100); assert_eq!( - procfs_getdents64_for_pid(&table, 42, b"/proc/42/fd", &mut [], 0), + procfs_getdents64_for_pid(&table, pid, b"/proc/100/fd", &mut [], 0), Err(Errno::EINVAL), ); assert_eq!( - procfs_getdents64_for_pid(&table, 43, b"/proc/43/fd", &mut [], 0), + procfs_getdents64_for_pid(&table, pid + 1, b"/proc/101/fd", &mut [], 0), Err(Errno::ENOENT), ); } diff --git a/crates/kernel/src/signal.rs b/crates/kernel/src/signal.rs index 3678d04a2c..98d282c199 100644 --- a/crates/kernel/src/signal.rs +++ b/crates/kernel/src/signal.rs @@ -1,4 +1,4 @@ -use wasm_posix_shared::signal::NSIG; +use wasm_posix_shared::{Errno, signal::NSIG}; extern crate alloc; use alloc::collections::VecDeque; @@ -106,7 +106,7 @@ fn terminate_process_by_signal_impl( signum: u32, ) { proc.sigsuspend_saved_mask = None; - for thread in &mut proc.threads { + for thread in proc.thread_states_mut() { thread.signals.sigsuspend_saved_mask = None; } match locks { @@ -181,7 +181,7 @@ 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); + deliver_pending_signals_impl(proc, None, host, crate::process_table::current_tid()); } pub(crate) fn deliver_pending_signals_with_locks( @@ -189,15 +189,34 @@ 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); + deliver_pending_signals_impl(proc, Some(locks), host, crate::process_table::current_tid()); +} + +/// Consume default/ignored signals for one exact kernel-owned task. +/// +/// Cross-process generation must not interpret the sender's ambient TID in +/// the target process. Callers select a target from the target Process and +/// pass it here; stale, foreign, synthetic, or exited task IDs fail without +/// consuming pending state. +pub(crate) fn deliver_pending_signals_for_tid_with_locks( + proc: &mut Process, + locks: &mut crate::lock::AdvisoryLockManager, + host: &mut dyn HostIO, + tid: u32, +) -> Result<(), Errno> { + if !proc.is_live_explicit_tid(tid) { + return Err(Errno::ESRCH); + } + deliver_pending_signals_impl(proc, Some(locks), host, tid); + Ok(()) } fn deliver_pending_signals_impl( proc: &mut Process, mut locks: Option<&mut crate::lock::AdvisoryLockManager>, host: &mut dyn HostIO, + tid: u32, ) { - let tid = crate::process_table::current_tid(); loop { let Some(signum) = proc.next_deliverable_signal(tid) else { break; @@ -927,6 +946,41 @@ mod tests { assert_eq!(event.si_status, SIGKILL as i32); } + #[test] + fn exact_target_delivery_never_uses_a_foreign_ambient_tid() { + use crate::lock::AdvisoryLockManager; + use crate::process::{Process, ProcessState, ThreadInfo, test_host::NoopHost}; + + let mut proc = Process::new(60); + proc.signals.blocked = sig_bit(SIGTERM); + proc.add_thread(ThreadInfo::new(61, 0, 0, 0)); + assert_eq!(proc.pick_thread_for_shared_signal(SIGTERM), Some(61)); + assert!(proc.raise_signal(SIGTERM)); + + 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, + ), + 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(); + assert_eq!(proc.state, ProcessState::Exited); + } + #[test] fn test_from_parts_clears_pending() { let handlers = [SignalHandler::Default; 65]; diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 0526fe9fb3..73fffebe9d 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -728,12 +728,7 @@ fn commit_exec_state_impl( // executable, so retain it through the irreversible commit point. let lifecycle_state = proc.state; if caller_tid != 0 && caller_tid != proc.pid { - let thread_index = proc - .threads - .iter() - .position(|thread| thread.tid == caller_tid) - .ok_or(Errno::ESRCH)?; - let caller = proc.threads.remove(thread_index); + let caller = proc.remove_thread(caller_tid).ok_or(Errno::ESRCH)?; proc.signals.blocked = caller.signals.blocked; proc.main_thread_signals = caller.signals; } @@ -772,8 +767,7 @@ fn commit_exec_state_impl( proc.exit_status = 0; proc.exit_signal = 0; proc.thread_name = [0; 16]; - proc.threads.clear(); - proc.next_tid = 0; + proc.clear_threads(); proc.sigsuspend_saved_mask = None; proc.alt_stack_sp = 0; proc.alt_stack_flags = 2; // SS_DISABLE @@ -787,6 +781,7 @@ 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; @@ -3862,7 +3857,14 @@ 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, fd, None, buf.len())?; + let writable_len = write_operation_budget( + proc, + host, + crate::process_table::current_tid(), + fd, + None, + buf.len(), + )?; // memfd: write to the shared in-memory backing. Apply O_APPEND // only at the actual non-empty mutation boundary. @@ -4089,7 +4091,11 @@ 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); } @@ -4257,14 +4263,10 @@ pub fn sys_pread( /// Queue a synchronous file-size-limit signal for the thread that issued the /// write. A worker thread must not redirect SIGXFSZ through the process-shared /// pending set to a different thread that happens to have it unblocked. -fn raise_fsize_signal_for_caller(proc: &mut Process) { - let tid = crate::process_table::current_tid(); - if !proc.raise_for_thread(tid, SIGXFSZ) { - // A stale host TID must not lose the required signal. The shared - // queue is the conservative fallback used by the existing signal - // entry points for unknown thread identities. - proc.signals.raise(SIGXFSZ); - } +fn raise_fsize_signal_for_caller(proc: &mut Process, tid: u32) -> Result<(), Errno> { + proc.raise_for_thread(tid, SIGXFSZ) + .then_some(()) + .ok_or(Errno::ESRCH) } /// Apply POSIX RLIMIT_FSIZE semantics to one regular-file write operation. @@ -4274,6 +4276,7 @@ fn raise_fsize_signal_for_caller(proc: &mut Process) { /// starting offset fails with EFBIG and generates SIGXFSZ. fn fsize_limited_write_len( proc: &mut Process, + caller_tid: u32, offset: u64, requested_len: usize, ) -> Result { @@ -4285,7 +4288,7 @@ fn fsize_limited_write_len( return Ok(requested_len); } if offset >= fsize_limit { - raise_fsize_signal_for_caller(proc); + raise_fsize_signal_for_caller(proc, caller_tid)?; return Err(Errno::EFBIG); } // Convert only after comparing in u64. The kernel itself is wasm32 even @@ -4308,6 +4311,7 @@ fn fsize_limited_write_len( pub(crate) fn write_operation_budget( proc: &mut Process, host: &mut dyn HostIO, + caller_tid: u32, fd: i32, offset: Option, requested_len: usize, @@ -4355,7 +4359,7 @@ pub(crate) fn write_operation_budget( } }; - fsize_limited_write_len(proc, start, requested_len) + fsize_limited_write_len(proc, caller_tid, start, requested_len) } /// Validate a transfer source without consuming data or changing its cursor. @@ -4428,7 +4432,14 @@ pub fn sys_pwrite( 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, fd, Some(offset), buf.len())?; + let writable_len = write_operation_budget( + proc, + host, + crate::process_table::current_tid(), + fd, + Some(offset), + buf.len(), + )?; if file_type == FileType::MemFd { let memfd_idx = (-(host_handle + 1)) as usize; @@ -4503,7 +4514,14 @@ pub fn sys_pwritev( ) -> Result { let requested_len = checked_iovec_len(iovecs)?; let writable_len = - write_operation_budget(proc, host, fd, Some(offset), requested_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 { @@ -4544,7 +4562,14 @@ pub fn sys_sendfile( count: usize, ) -> Result { validate_transfer_input(proc, in_fd, (offset >= 0).then_some(offset))?; - let writable_len = write_operation_budget(proc, host, out_fd, None, count)?; + let writable_len = write_operation_budget( + proc, + host, + crate::process_table::current_tid(), + out_fd, + None, + count, + )?; if count == 0 { return Ok(0); } @@ -4615,7 +4640,14 @@ pub fn sys_copy_file_range( len: usize, ) -> Result { validate_transfer_input(proc, fd_in, off_in)?; - let writable_len = write_operation_budget(proc, host, fd_out, off_out, len)?; + let writable_len = write_operation_budget( + proc, + host, + crate::process_table::current_tid(), + fd_out, + off_out, + len, + )?; if len == 0 { return Ok(0); } @@ -6891,9 +6923,9 @@ pub fn sys_setpgid(proc: &mut Process, pid: u32, pgid: u32) -> Result<(), Errno> return Err(Errno::ESRCH); } // POSIX: a session leader cannot change its process group. Check the - // explicit flag — `sid == pid` alone is wrong because a forked child - // inherits the parent's sid and if that ever equals the child's pid - // (e.g. PID reuse) we'd wrongly classify the child as a session leader. + // explicit flag is the authoritative state; numeric identity equality is + // not a substitute for the recorded setsid transition (and must remain + // correct if a future allocation policy changes). if proc.is_session_leader { return Err(Errno::EPERM); } @@ -6928,53 +6960,25 @@ pub fn sys_setsid(proc: &mut Process) -> Result { Ok(proc.sid) } -/// Send a signal to a process. -/// If pid matches current process, is 0 (process group), or is the negative of our pgid, -/// raises locally. Otherwise delegates to host for cross-process delivery. -pub fn sys_kill( - proc: &mut Process, - host: &mut dyn HostIO, - pid: i32, - sig: u32, -) -> Result<(), Errno> { +/// Send a signal to this exact process. Machine-wide target selection belongs +/// to `ProcessTable` at the Wasm boundary and is never delegated to a host. +pub fn sys_kill(proc: &mut Process, pid: i32, sig: u32) -> Result<(), Errno> { if sig >= NSIG && sig != 0 { return Err(Errno::EINVAL); } - let is_local = pid == proc.pid as i32 || pid == 0 || pid == -(proc.pgid as i32); - if sig == 0 { - // sig=0 is a validity/existence check. Local always succeeds. - // For remote pids, delegate to host so it can return ESRCH if needed. - if is_local { - return Ok(()); - } else { - return host.host_kill(pid, sig); - } + if pid != proc.pid as i32 { + return Err(Errno::ESRCH); } - if is_local { - proc.raise_signal(sig); - Ok(()) - } else { - host.host_kill(pid, sig) + if sig == 0 { + return Ok(()); } + proc.raise_signal(sig); + Ok(()) } /// Send a signal to the current process. -pub fn sys_raise(proc: &mut Process, host: &mut dyn HostIO, sig: u32) -> Result<(), Errno> { - sys_kill(proc, host, proc.pid as i32, sig) -} - -/// Fork the current process. Delegates to host for worker creation. -/// Returns child PID in parent (> 0), 0 in child, or error. -pub fn sys_fork(_proc: &mut Process, host: &dyn HostIO) -> Result { - let result = host.host_fork(); - if result < 0 { - match Errno::from_u32((-result) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(result as u32) - } +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. @@ -7323,8 +7327,8 @@ pub fn sys_signal(proc: &mut Process, signum: u32, handler_val: u32) -> Result Result { let tid = crate::process_table::current_tid(); let old_mask = proc.blocked_for(tid); @@ -11427,11 +11431,12 @@ pub fn sys_recvfrom( return Ok((0, 0)); } + let pid = proc.pid; let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; let datagram_idx = sock .dgram_queue .iter() - .position(|d| dgram_matches_connected_peer(sock, proc.pid, d)); + .position(|d| dgram_matches_connected_peer(sock, pid, d)); if datagram_idx.is_none() { if sock.state == SocketState::Connected && sock.connect_error != 0 { let err = sock.connect_error; @@ -12940,9 +12945,9 @@ pub fn sys_prctl(proc: &mut Process, option: u32, _arg2: u32, buf: &mut [u8]) -> // // The host selects the syscall mailbox by channelOffset first, then binds the // corresponding TID in process_table::current_tid before entering the kernel. -// That TID is what musl's gettid-based pthread implementation observes. A -// current_tid of 0 is the host/kernel sentinel for the process main thread, so -// the syscall returns the process pid for main-thread callers. +// That TID is what musl's gettid-based pthread implementation observes. Host +// 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(); if proc.is_main_thread(tid) { @@ -13042,8 +13047,7 @@ pub fn sys_futex( /// Allocates a TID, stores thread state, and returns the TID. The host's /// handleClone then spawns the actual thread Worker. pub fn sys_clone( - proc: &mut Process, - _host: &mut dyn HostIO, + table: &mut crate::process_table::ProcessTable, _fn_ptr: usize, stack_ptr: usize, flags: u32, @@ -13052,8 +13056,6 @@ pub fn sys_clone( tls_ptr: usize, ctid_ptr: usize, ) -> Result { - use crate::process::ThreadInfo; - const CLONE_VM: u32 = 0x00000100; const CLONE_THREAD: u32 = 0x00010000; const CLONE_PARENT_SETTID: u32 = 0x00100000; @@ -13067,7 +13069,6 @@ pub fn sys_clone( return Err(Errno::ENOSYS); } - let tid = proc.alloc_tid(); let effective_tls = if flags & CLONE_SETTLS != 0 { tls_ptr } else { @@ -13081,11 +13082,9 @@ pub fn sys_clone( // POSIX: new threads inherit the creator's signal mask. The creator is // identified by current_tid because clone arrives through the caller's // channel mailbox but the kernel stores masks by TID. - let caller_tid = crate::process_table::current_tid(); - let inherited_blocked = proc.blocked_for(caller_tid); - let mut thread_info = ThreadInfo::new(tid, effective_ctid, stack_ptr, effective_tls); - thread_info.signals.blocked = inherited_blocked; - proc.add_thread(thread_info); + let caller_tid = table.current_tid(); + let pid = table.current_pid(); + let tid = table.create_thread(pid, caller_tid, stack_ptr, effective_tls, effective_ctid)?; let _ = flags & CLONE_PARENT_SETTID; Ok(tid as i32) @@ -14033,7 +14032,7 @@ pub fn sys_ftruncate( && (length as u64) > current_size && (length as u64) > fsize_limit { - raise_fsize_signal_for_caller(proc); + raise_fsize_signal_for_caller(proc, crate::process_table::current_tid())?; return Err(Errno::EFBIG); } @@ -14232,7 +14231,14 @@ pub fn sys_writev( buffers: &[&[u8]], ) -> Result { let requested_len = checked_iovec_len(buffers)?; - let writable_len = write_operation_budget(proc, host, fd, None, requested_len)?; + 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 { @@ -15347,7 +15353,7 @@ mod tests { fn set_test_current_tid(tid: u32) { unsafe { - (*crate::process_table::GLOBAL_PROCESS_TABLE.0.get()).set_current_tid(tid); + (*crate::process_table::GLOBAL_PROCESS_TABLE.0.get()).set_current_tid_for_test(tid); } } @@ -16009,10 +16015,6 @@ mod tests { Ok(()) } - fn host_kill(&mut self, _pid: i32, _sig: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_exec(&mut self, _path: &[u8]) -> Result<(), Errno> { Ok(()) } @@ -16137,9 +16139,6 @@ mod tests { fn host_getaddrinfo(&mut self, _name: &[u8], _result: &mut [u8]) -> Result { Err(Errno::ENOENT) } - fn host_fork(&self) -> i32 { - -(Errno::ENOSYS as i32) - } fn host_futex_wait( &mut self, _addr: usize, @@ -16151,16 +16150,6 @@ mod tests { fn host_futex_wake(&mut self, _addr: usize, _count: u32) -> Result { Ok(0) } - fn host_clone( - &mut self, - _fn_ptr: usize, - _arg: usize, - _stack_ptr: usize, - _tls_ptr: usize, - _ctid_ptr: usize, - ) -> Result { - Err(Errno::ENOSYS) - } fn bind_framebuffer( &mut self, _pid: i32, @@ -19421,61 +19410,53 @@ mod tests { #[test] fn test_kill_marks_signal_pending() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - sys_kill(&mut proc, &mut host, 1, 2).unwrap(); // SIGINT=2 + sys_kill(&mut proc, 1, 2).unwrap(); // SIGINT=2 assert!(proc.signals.is_pending(2)); } #[test] fn test_kill_sig_zero_is_noop() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - sys_kill(&mut proc, &mut host, 1, 0).unwrap(); + sys_kill(&mut proc, 1, 0).unwrap(); assert_eq!(proc.signals.pending, 0); } #[test] fn test_kill_invalid_signal() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_kill(&mut proc, &mut host, 1, 100); + let result = sys_kill(&mut proc, 1, 100); assert_eq!(result, Err(Errno::EINVAL)); } #[test] - fn test_kill_remote_pid_calls_host_kill() { + fn test_process_local_kill_rejects_remote_pid() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_kill(&mut proc, &mut host, 2, 15); - assert!(result.is_ok()); + let result = sys_kill(&mut proc, 2, 15); + assert_eq!(result, Err(Errno::ESRCH)); assert!(!proc.signals.is_pending(15)); // NOT pending locally } #[test] fn test_kill_self_raises_locally() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_kill(&mut proc, &mut host, 1, 2); + let result = sys_kill(&mut proc, 1, 2); assert!(result.is_ok()); assert!(proc.signals.is_pending(2)); } #[test] - fn test_kill_pid_zero_raises_locally() { + fn test_process_local_kill_does_not_infer_group_from_pid_zero() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_kill(&mut proc, &mut host, 0, 2); - assert!(result.is_ok()); - assert!(proc.signals.is_pending(2)); + let result = sys_kill(&mut proc, 0, 2); + assert_eq!(result, Err(Errno::ESRCH)); + assert!(!proc.signals.is_pending(2)); } #[test] - fn test_kill_sig_zero_remote_delegates_to_host() { + fn test_process_local_kill_sig_zero_rejects_remote_pid() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // sig=0 to remote pid should delegate to host for existence check - let result = sys_kill(&mut proc, &mut host, 2, 0); - assert!(result.is_ok()); // MockHostIO returns Ok(()) + let result = sys_kill(&mut proc, 2, 0); + assert_eq!(result, Err(Errno::ESRCH)); assert_eq!(proc.signals.pending, 0); // no signal raised locally } @@ -19593,15 +19574,14 @@ mod tests { #[test] fn test_raise_is_kill_to_self() { let mut proc = Process::new(42); - let mut host = MockHostIO::new(); - sys_raise(&mut proc, &mut host, 15).unwrap(); // SIGTERM + sys_raise(&mut proc, 15).unwrap(); // SIGTERM assert!(proc.signals.is_pending(15)); } #[test] fn test_deliver_signal_marks_pending() { - let mut proc = Process::new(1); - // Simulate what kernel_deliver_signal does + let mut proc = Process::new(42); + // Direct signal generation records a pending instance. proc.signals.raise(15); // SIGTERM assert!(proc.signals.is_pending(15)); } @@ -19638,8 +19618,7 @@ mod tests { ); // Step 3: Raise SIGINT - let mut host = MockHostIO::new(); - let result = sys_raise(&mut proc, &mut host, SIGINT); + let result = sys_raise(&mut proc, SIGINT); assert!(result.is_ok()); assert!(proc.signals.is_pending(SIGINT), "SIGINT should be pending"); @@ -20047,19 +20026,19 @@ mod tests { use crate::process_table::ProcessTable; use crate::spawn::SpawnAttrs; - const PARENT: u32 = 970_100; - const FORK_CHILD: u32 = 970_101; + const PARENT: u32 = 100; + const FORK_CHILD: u32 = 101; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); let inherited_fd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 0, O_NONBLOCK).unwrap(); let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); let backing_generation = descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); - table.fork_process(PARENT, FORK_CHILD).unwrap(); + assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); let spawn_child = table - .spawn_child(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), @@ -20137,20 +20116,20 @@ mod tests { use crate::process_table::ProcessTable; use crate::spawn::SpawnAttrs; - const PARENT: u32 = 970_200; - const FORK_CHILD: u32 = 970_201; + const PARENT: u32 = 100; + const FORK_CHILD: u32 = 101; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); host.clock_time = (100, 0); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); let inherited_fd = sys_timerfd_create(table.get_mut(PARENT).unwrap(), 0, O_NONBLOCK).unwrap(); let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); let backing_generation = descriptor_backing_generation(FileType::TimerFd, backing_idx).unwrap(); - table.fork_process(PARENT, FORK_CHILD).unwrap(); + assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); let spawn_child = table - .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) .unwrap(); sys_timerfd_settime( @@ -20208,11 +20187,11 @@ mod tests { use crate::spawn::SpawnAttrs; use wasm_posix_shared::signal::{SIGINT, SIGTERM, SIGUSR1}; - const PARENT: u32 = 970_300; - const FORK_CHILD: u32 = 970_301; + const PARENT: u32 = 100; + const FORK_CHILD: u32 = 101; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); let inherited_fd = sys_signalfd4( table.get_mut(PARENT).unwrap(), -1, @@ -20223,9 +20202,9 @@ 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(); - table.fork_process(PARENT, FORK_CHILD).unwrap(); + assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); let spawn_child = table - .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) .unwrap(); let usr1_mask = 1u64 << (SIGUSR1 - 1); @@ -20290,11 +20269,11 @@ mod tests { use crate::process_table::ProcessTable; use crate::spawn::SpawnAttrs; - const PARENT: u32 = 970_400; - const FORK_CHILD: u32 = 970_401; + const PARENT: u32 = 100; + const FORK_CHILD: u32 = 101; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); let inherited_fd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"shared", 0).unwrap(); let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); let backing_generation = @@ -20314,9 +20293,9 @@ mod tests { SEEK_SET, ) .unwrap(); - table.fork_process(PARENT, FORK_CHILD).unwrap(); + assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); let spawn_child = table - .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .spawn_child_for_caller(PARENT, PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) .unwrap(); let mut pair = [0u8; 2]; @@ -20423,15 +20402,15 @@ mod tests { fn inherited_memfd_seek_cur_lock_and_fdinfo_use_peer_advanced_cursor() { use crate::process_table::ProcessTable; - const PARENT: u32 = 970_450; - const CHILD: u32 = 970_451; + const PARENT: u32 = 100; + const CHILD: u32 = 101; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); 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(); - table.fork_process(PARENT, CHILD).unwrap(); + 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(); @@ -20490,11 +20469,11 @@ mod tests { use crate::process_table::ProcessTable; use crate::spawn::SpawnAttrs; - const PARENT: u32 = 970_500; - const FORK_CHILD: u32 = 970_501; + const PARENT: u32 = 100; + const FORK_CHILD: u32 = 101; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); table.get_mut(PARENT).unwrap().argv = vec![b"parent-program".to_vec()]; let expected = crate::procfs::generate_stat(table.get(PARENT).unwrap()); let inherited_fd = crate::procfs::procfs_open( @@ -20517,10 +20496,10 @@ mod tests { ) .unwrap(); assert_eq!(&first, &expected[..7]); - table.fork_process(PARENT, FORK_CHILD).unwrap(); + assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), FORK_CHILD); let spawn_child = table - .spawn_child( - PARENT, + .spawn_child_for_caller( + PARENT, PARENT, &[b"spawn-program"], &[], &[], @@ -20597,11 +20576,11 @@ mod tests { fn fork_clofork_filter_recomputes_ofd_refs_and_backing_lifetime() { use crate::process_table::ProcessTable; - const PARENT: u32 = 970_600; - const CHILD: u32 = 970_601; + const PARENT: u32 = 100; + const CHILD: u32 = 101; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); let clo_fork_fd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 1, 0).unwrap(); let inherited_alias = sys_dup(table.get_mut(PARENT).unwrap(), clo_fork_fd).unwrap(); let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), clo_fork_fd); @@ -20615,7 +20594,7 @@ mod tests { .unwrap() .fd_flags |= FD_CLOFORK; - table.fork_process(PARENT, CHILD).unwrap(); + 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(); @@ -20656,10 +20635,10 @@ mod tests { use crate::spawn::{FileAction, SpawnAttrs}; use wasm_posix_shared::signal::SIGINT; - const PARENT: u32 = 970_700; + const PARENT: u32 = 100; let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - table.create_process(PARENT).unwrap(); + assert_eq!(table.create_process().unwrap(), PARENT); let eventfd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 0, O_CLOEXEC).unwrap(); let timerfd = sys_timerfd_create(table.get_mut(PARENT).unwrap(), 0, O_CLOEXEC).unwrap(); @@ -20696,7 +20675,7 @@ mod tests { }); let child = table - .spawn_child(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()); @@ -20704,8 +20683,8 @@ mod tests { } let err = table - .spawn_child( - PARENT, + .spawn_child_for_caller( + PARENT, PARENT, &[], &[], &[FileAction::Dup2 { srcfd: 999, fd: 1 }], @@ -20732,317 +20711,6 @@ mod tests { table.remove_process(PARENT).unwrap(); } - #[test] - fn legacy_exec_transfers_survivor_and_releases_cloexec_only_backing() { - use crate::process_table::ProcessTable; - - const PID: u32 = 970_800; - let mut old = Process::new(PID); - let mut host = MockHostIO::new(); - let removed_fd = sys_eventfd2(&mut old, 11, O_CLOEXEC).unwrap(); - let retained_fd = sys_eventfd2(&mut old, 22, 0).unwrap(); - let filtered_alias = sys_dup(&mut old, retained_fd).unwrap(); - old.fd_table.get_mut(filtered_alias).unwrap().fd_flags |= FD_CLOEXEC; - let removed_idx = descriptor_backing_idx(&old, removed_fd); - let removed_generation = - descriptor_backing_generation(FileType::EventFd, removed_idx).unwrap(); - let retained_idx = descriptor_backing_idx(&old, retained_fd); - - let serialized = crate::fork::serialize_exec_state_with_growing_buffer(&old).unwrap(); - let replacement = crate::fork::deserialize_exec_state(&serialized, PID).unwrap(); - assert!(replacement.fd_table.get(removed_fd).is_err()); - assert!(replacement.fd_table.get(filtered_alias).is_err()); - let retained_ofd_idx = replacement.fd_table.get(retained_fd).unwrap().ofd_ref.0; - assert_eq!( - replacement - .ofd_table - .get(retained_ofd_idx) - .unwrap() - .ref_count, - 1 - ); - - let mut table = ProcessTable::new(); - table.processes.insert(PID, old); - table.replace_legacy_exec_process(PID, replacement).unwrap(); - assert_descriptor_backing_released(FileType::EventFd, removed_idx, removed_generation); - assert_eq!( - descriptor_backing_ref_count(FileType::EventFd, retained_idx), - Some(1), - "the replacement must transfer, not duplicate, the survivor's ownership ref" - ); - - let mut value = [0u8; 8]; - sys_read( - table.get_mut(PID).unwrap(), - &mut host, - retained_fd, - &mut value, - ) - .unwrap(); - assert_eq!(u64::from_le_bytes(value), 22); - sys_close(table.get_mut(PID).unwrap(), &mut host, retained_fd).unwrap(); - table.remove_process(PID).unwrap(); - } - - #[test] - fn legacy_exec_decrements_shared_host_ofd_before_peer_final_close() { - use crate::process_table::ProcessTable; - - const PARENT: u32 = 970_805; - const CHILD: u32 = 970_806; - const HOST_HANDLE: i64 = 9_708_050; - let file = FileId::Host { dev: 97, ino: 805 }; - - let mut parent = Process::new(PARENT); - let ofd_idx = parent.ofd_table.create( - FileType::Regular, - O_RDWR, - HOST_HANDLE, - b"/legacy-exec-shared-lock".to_vec(), - ); - parent.ofd_table.get_mut(ofd_idx).unwrap().file_id = Some(file); - let ofd_id = parent.ofd_table.get(ofd_idx).unwrap().ofd_id; - let fd = parent - .fd_table - .alloc(OpenFileDescRef(ofd_idx), FD_CLOEXEC) - .unwrap(); - - let mut table = ProcessTable::new(); - table.processes.insert(PARENT, parent); - table - .advisory_locks_mut() - .set_lock( - file, - LockOwner::OpenFileDescription(ofd_id), - Some(AdvisoryLockType::Write), - LockRange::normalize(0, 1).unwrap(), - ) - .unwrap(); - table.fork_process(PARENT, CHILD).unwrap(); - assert_eq!(crate::ofd::host_handle_ref_count(HOST_HANDLE), 2); - - let serialized = crate::fork::serialize_exec_state_with_growing_buffer( - table.get(PARENT).unwrap(), - ) - .unwrap(); - let replacement = crate::fork::deserialize_exec_state(&serialized, PARENT).unwrap(); - - let cleanup = table - .replace_legacy_exec_process(PARENT, replacement) - .unwrap(); - assert!(cleanup.host_closes.is_empty()); - assert_eq!(crate::ofd::host_handle_ref_count(HOST_HANDLE), 1); - assert!(!table.advisory_locks().is_empty()); - - let mut host = MockHostIO::new(); - let (child, locks) = table.process_and_advisory_locks(CHILD).unwrap(); - sys_close_with_locks(child, locks, &mut host, fd).unwrap(); - assert_eq!(host.closed_handles, vec![HOST_HANDLE]); - assert!(table.advisory_locks().is_empty()); - assert_eq!(crate::ofd::host_handle_ref_count(HOST_HANDLE), 0); - - table.remove_process(CHILD).unwrap(); - table.remove_process(PARENT).unwrap(); - } - - #[test] - fn legacy_exec_counts_self_transferred_ofd_entries_independently() { - use crate::process_table::ProcessTable; - - const PID: u32 = 970_807; - const HOST_HANDLE: i64 = 9_708_070; - let _guard = SCM_RIGHTS_LIFETIME_LOCK - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let file = FileId::Host { dev: 97, ino: 807 }; - - let mut old = Process::new(PID); - let original_ofd = old.ofd_table.create( - FileType::Regular, - O_RDWR, - HOST_HANDLE, - b"/legacy-exec-self-transfer".to_vec(), - ); - old.ofd_table.get_mut(original_ofd).unwrap().file_id = Some(file); - let ofd_id = old.ofd_table.get(original_ofd).unwrap().ofd_id; - let original_fd = old - .fd_table - .alloc(OpenFileDescRef(original_ofd), 0) - .unwrap(); - - // Receiving our own SCM_RIGHTS payload creates another local OFD - // entry with the same global OfdId and one additional host-resource - // ownership reference. - let queued = retain_fd_for_scm_rights(&old, original_fd); - let received = install_scm_rights_fds(&mut old, vec![queued]); - assert_eq!(received.len(), 1); - let received_fd = received[0]; - let received_ofd = old.fd_table.get(received_fd).unwrap().ofd_ref.0; - assert_ne!(received_ofd, original_ofd); - assert_eq!(old.ofd_table.get(received_ofd).unwrap().ofd_id, ofd_id); - assert_eq!(crate::ofd::host_handle_ref_count(HOST_HANDLE), 2); - - old.fd_table.get_mut(original_fd).unwrap().fd_flags |= FD_CLOEXEC; - let serialized = crate::fork::serialize_exec_state_with_growing_buffer(&old).unwrap(); - let replacement = crate::fork::deserialize_exec_state(&serialized, PID).unwrap(); - assert!(replacement.ofd_table.get(original_ofd).is_none()); - assert_eq!(replacement.ofd_table.get(received_ofd).unwrap().ofd_id, ofd_id); - - let mut table = ProcessTable::new(); - table.processes.insert(PID, old); - table - .advisory_locks_mut() - .set_lock( - file, - LockOwner::OpenFileDescription(ofd_id), - Some(AdvisoryLockType::Write), - LockRange::normalize(0, 1).unwrap(), - ) - .unwrap(); - - let cleanup = table - .replace_legacy_exec_process(PID, replacement) - .unwrap(); - assert!(cleanup.host_closes.is_empty()); - assert_eq!(crate::ofd::host_handle_ref_count(HOST_HANDLE), 1); - assert_eq!(table.advisory_locks().len(), 1); - - let mut host = MockHostIO::new(); - let (process, locks) = table.process_and_advisory_locks(PID).unwrap(); - sys_close_with_locks(process, locks, &mut host, received_fd).unwrap(); - assert_eq!(host.closed_handles, vec![HOST_HANDLE]); - assert!(table.advisory_locks().is_empty()); - assert_eq!(crate::ofd::host_handle_ref_count(HOST_HANDLE), 0); - table.remove_process(PID).unwrap(); - } - - #[test] - fn legacy_fork_into_fresh_table_keeps_host_handle_single_owned() { - use crate::process_table::ProcessTable; - - const PARENT: u32 = 970_810; - const CHILD: u32 = 970_811; - const HOST_HANDLE: i64 = 9_708_110; - - let mut source = Process::new(PARENT); - let ofd_idx = source.ofd_table.create( - FileType::Regular, - O_RDWR, - HOST_HANDLE, - b"/fresh-kernel-handle".to_vec(), - ); - let fd = source - .fd_table - .alloc(OpenFileDescRef(ofd_idx), 0) - .unwrap(); - let mut serialized = vec![0u8; 64 * 1024]; - let written = crate::fork::serialize_fork_state(&source, &mut serialized).unwrap(); - let child = crate::fork::deserialize_fork_state(&serialized[..written], CHILD).unwrap(); - assert_eq!(child.ppid, PARENT); - - let mut table = ProcessTable::new(); - table.insert_legacy_fork_process(child).unwrap(); - let mut host = MockHostIO::new(); - sys_close(table.get_mut(CHILD).unwrap(), &mut host, fd).unwrap(); - assert_eq!(host.closed_handles, vec![HOST_HANDLE]); - } - - #[test] - fn legacy_fork_into_fresh_table_rejects_reused_special_backing() { - use crate::process_table::ProcessTable; - - const OWNER: u32 = 970_820; - const CHILD: u32 = 970_821; - let mut owner = Process::new(OWNER); - let owner_fd = sys_eventfd2(&mut owner, 37, 0).unwrap(); - let backing_idx = descriptor_backing_idx(&owner, owner_fd); - let generation = - descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); - - // Model a stale serialized child whose stable index now names another - // process's live object. A fresh-table legacy install has no parent - // ownership to transfer and must reject rather than add a reference. - let mut child = Process::new(CHILD); - child.ppid = 999_999; - let stale_handle = -((backing_idx as i64) + 1); - let stale_ofd = child.ofd_table.create( - FileType::EventFd, - O_RDWR, - stale_handle, - b"/dev/eventfd".to_vec(), - ); - child - .fd_table - .alloc(OpenFileDescRef(stale_ofd), 0) - .unwrap(); - - let mut table = ProcessTable::new(); - assert_eq!(table.insert_legacy_fork_process(child), Err(Errno::EBADF)); - assert!(table.get(CHILD).is_none()); - assert_eq!( - descriptor_backing_ref_count(FileType::EventFd, backing_idx), - Some(1) - ); - assert_eq!( - descriptor_backing_generation(FileType::EventFd, backing_idx), - Some(generation) - ); - - let mut host = MockHostIO::new(); - let mut value = [0u8; 8]; - sys_read(&mut owner, &mut host, owner_fd, &mut value).unwrap(); - assert_eq!(u64::from_le_bytes(value), 37); - sys_close(&mut owner, &mut host, owner_fd).unwrap(); - } - - #[test] - fn legacy_exec_into_fresh_table_rejects_reused_special_backing() { - use crate::process_table::ProcessTable; - - const OWNER: u32 = 970_830; - const EXEC_PID: u32 = 970_831; - let mut owner = Process::new(OWNER); - let owner_fd = sys_eventfd2(&mut owner, 41, 0).unwrap(); - let backing_idx = descriptor_backing_idx(&owner, owner_fd); - let generation = - descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); - - let mut replacement = Process::new(EXEC_PID); - let stale_handle = -((backing_idx as i64) + 1); - let stale_ofd = replacement.ofd_table.create( - FileType::EventFd, - O_RDWR, - stale_handle, - b"/dev/eventfd".to_vec(), - ); - replacement - .fd_table - .alloc(OpenFileDescRef(stale_ofd), 0) - .unwrap(); - - let mut table = ProcessTable::new(); - assert_eq!( - table.replace_legacy_exec_process(EXEC_PID, replacement), - Err(Errno::EBADF) - ); - assert!(table.get(EXEC_PID).is_none()); - assert_eq!( - descriptor_backing_ref_count(FileType::EventFd, backing_idx), - Some(1) - ); - assert_eq!( - descriptor_backing_generation(FileType::EventFd, backing_idx), - Some(generation) - ); - - let mut host = MockHostIO::new(); - let mut value = [0u8; 8]; - sys_read(&mut owner, &mut host, owner_fd, &mut value).unwrap(); - assert_eq!(u64::from_le_bytes(value), 41); - sys_close(&mut owner, &mut host, owner_fd).unwrap(); - } - #[test] fn process_lock_close_and_exit_cleanup_use_machine_manager() { let mut proc = Process::new(1); @@ -21248,15 +20916,12 @@ mod tests { let _guard = SCM_RIGHTS_LIFETIME_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - const PARENT: u32 = 70_903; - const FORK_CHILD: u32 = 70_904; - let mut table = ProcessTable::new(); - table.create_process(PARENT).unwrap(); + let parent_pid = table.create_process().unwrap(); let mut host = MockHostIO::new(); host.dir_entry_count = 3; let parent_fd = sys_open( - table.get_mut(PARENT).unwrap(), + table.get_mut(parent_pid).unwrap(), &mut host, b"/tmp", O_RDONLY | O_DIRECTORY, @@ -21264,7 +20929,7 @@ mod tests { ) .unwrap(); let parent_ofd_idx = table - .get(PARENT) + .get(parent_pid) .unwrap() .fd_table .get(parent_fd) @@ -21274,7 +20939,7 @@ mod tests { let mut prefix = [0u8; 80]; assert_eq!( sys_getdents64( - table.get_mut(PARENT).unwrap(), + table.get_mut(parent_pid).unwrap(), &mut host, parent_fd, &mut prefix, @@ -21283,7 +20948,7 @@ mod tests { ); assert_eq!( table - .get(PARENT) + .get(parent_pid) .unwrap() .ofd_table .get(parent_ofd_idx) @@ -21292,10 +20957,13 @@ mod tests { 200, ); - table.fork_process(PARENT, FORK_CHILD).unwrap(); + let fork_child = table + .fork_process_for_caller(parent_pid, parent_pid) + .unwrap(); let spawn_child = table - .spawn_child( - PARENT, + .spawn_child_for_caller( + parent_pid, + parent_pid, &[b"/bin/child".as_slice()], &[], &[], @@ -21304,7 +20972,7 @@ mod tests { ) .unwrap(); - for pid in [FORK_CHILD, spawn_child] { + for pid in [fork_child, spawn_child] { let child_entry = table.get(pid).unwrap().fd_table.get(parent_fd).unwrap(); let child_ofd = table .get(pid) @@ -21331,7 +20999,7 @@ mod tests { // Both child iterators resumed independently, while the parent kept // its live pending record at the same snapshot cookie. let parent_ofd = table - .get(PARENT) + .get(parent_pid) .unwrap() .ofd_table .get(parent_ofd_idx) @@ -21340,7 +21008,7 @@ mod tests { assert_eq!(parent_ofd.dir_entry_offset, 3); assert_eq!(parent_ofd.dir_pending_entry.as_ref().unwrap().name, b"foo.txt"); - for pid in [FORK_CHILD, spawn_child, PARENT] { + 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]); @@ -21351,9 +21019,9 @@ mod tests { .count(), 1, ); - table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(fork_child).unwrap(); table.remove_process(spawn_child).unwrap(); - table.remove_process(PARENT).unwrap(); + table.remove_process(parent_pid).unwrap(); } #[test] @@ -21482,13 +21150,13 @@ mod tests { #[test] fn scm_rights_in_flight_reference_survives_sender_crash_removal() { - const SENDER_PID: u32 = 76; + 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(); - table.create_process(SENDER_PID).unwrap(); + assert_eq!(table.create_process().unwrap(), SENDER_PID); let mut host = MockHostIO::new(); let sender_fd = sys_memfd_create(table.get_mut(SENDER_PID).unwrap(), b"scm-crash", 0).unwrap(); @@ -22480,9 +22148,15 @@ mod tests { let mut proc = Process::new(10); let mut host = MockHostIO::new(); let mut caller = ThreadInfo::new(11, 0, 0, 0); - caller.signals.blocked = crate::signal::sig_bit(SIGTERM); - caller.signals.raise_with_value(32, 101); - caller.signals.raise_with_value(32, 202); + caller.state_mut_for_test().signals.blocked = crate::signal::sig_bit(SIGTERM); + caller + .state_mut_for_test() + .signals + .raise_with_value(32, 101); + caller + .state_mut_for_test() + .signals + .raise_with_value(32, 202); proc.add_thread(caller); proc.add_thread(ThreadInfo::new(12, 0, 0, 0)); proc.main_thread_signals.raise(SIGINT); @@ -25958,7 +25632,7 @@ mod tests { sys_setrlimit(&mut proc, RLIMIT_FSIZE, limit, limit).unwrap(); assert_eq!( - write_operation_budget(&mut proc, &mut host, fd, Some(0), 10), + write_operation_budget(&mut proc, &mut host, 1, fd, Some(0), 10), Ok(10) ); assert_eq!( @@ -26857,14 +26531,6 @@ mod tests { assert_eq!(result, Err(Errno::ERANGE)); } - #[test] - fn test_fork_returns_enosys_with_mock() { - let mut proc = Process::new(1); - let host = MockHostIO::new(); - let result = sys_fork(&mut proc, &host); - assert_eq!(result, Err(Errno::ENOSYS)); - } - #[test] fn test_fork_child_fields_default_to_false() { let proc = Process::new(1); @@ -27199,9 +26865,6 @@ mod tests { fn host_fchown(&mut self, _handle: i64, _uid: u32, _gid: u32) -> Result<(), Errno> { Ok(()) } - fn host_kill(&mut self, _pid: i32, _sig: u32) -> Result<(), Errno> { - Ok(()) - } fn host_exec(&mut self, _path: &[u8]) -> Result<(), Errno> { Ok(()) } @@ -27284,9 +26947,6 @@ mod tests { fn host_getaddrinfo(&mut self, _name: &[u8], _result: &mut [u8]) -> Result { Err(Errno::ENOENT) } - fn host_fork(&self) -> i32 { - -(Errno::ENOSYS as i32) - } fn host_futex_wait( &mut self, _addr: usize, @@ -27298,16 +26958,6 @@ mod tests { fn host_futex_wake(&mut self, _addr: usize, _count: u32) -> Result { Ok(0) } - fn host_clone( - &mut self, - _fn_ptr: usize, - _arg: usize, - _stack_ptr: usize, - _tls_ptr: usize, - _ctid_ptr: usize, - ) -> Result { - Err(Errno::ENOSYS) - } fn bind_framebuffer( &mut self, pid: i32, @@ -27656,25 +27306,30 @@ mod tests { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); use crate::process_table::ProcessTable; - const PARENT: u32 = 9031; - const CHILD: u32 = 9032; + const PARENT: u32 = 100; + const CHILD: u32 = 101; let path = b"/tmp/fork_accept_9031.sock"; let resolved = crate::path::resolve_path(path, b"/"); unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); let mut host = MockHostIO::new(); - let mut parent = Process::new(PARENT); - let server_fd = sys_socket(&mut parent, &mut host, 1, 1, 0).unwrap(); + 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]; addr[0] = 1; addr[2..2 + path.len()].copy_from_slice(path); let addrlen = 2 + path.len() + 1; - sys_bind(&mut parent, &mut host, server_fd, &addr[..addrlen]).unwrap(); - sys_listen(&mut parent, &mut host, server_fd, 5).unwrap(); + sys_bind( + table.get_mut(PARENT).unwrap(), + &mut host, + server_fd, + &addr[..addrlen], + ) + .unwrap(); + sys_listen(table.get_mut(PARENT).unwrap(), &mut host, server_fd, 5).unwrap(); - let mut table = ProcessTable::new(); - table.processes.insert(PARENT, parent); - table.fork_process(PARENT, CHILD).unwrap(); + assert_eq!(table.fork_process_for_caller(PARENT, PARENT).unwrap(), CHILD); let client_fd = { let parent = table.get_mut(PARENT).unwrap(); @@ -27836,7 +27491,7 @@ mod tests { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9020; + 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]; @@ -27895,7 +27550,7 @@ mod tests { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9021; + proc.set_pid_for_test(9021); let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); let mut addr = [0u8; 110]; addr[0] = 1; @@ -27935,7 +27590,7 @@ mod tests { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9022; + proc.set_pid_for_test(9022); let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); let mut addr = [0u8; 110]; addr[0] = 1; @@ -27959,7 +27614,7 @@ mod tests { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9023; + proc.set_pid_for_test(9023); let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); let mut addr = [0u8; 16]; addr[0] = 1; // AF_UNIX @@ -27986,7 +27641,7 @@ mod tests { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9024; + proc.set_pid_for_test(9024); let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); let mut addr = [0u8; 16]; addr[0] = 1; // AF_UNIX @@ -28121,9 +27776,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_kill(&mut self, _p: i32, _s: u32) -> Result<(), Errno> { - Ok(()) - } fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { Ok(()) } @@ -28191,25 +27843,12 @@ mod tests { fn host_getaddrinfo(&mut self, _n: &[u8], _r: &mut [u8]) -> Result { Err(Errno::ENOENT) } - fn host_fork(&self) -> i32 { - -(Errno::ENOSYS as i32) - } fn host_futex_wait(&mut self, _a: usize, _e: u32, _t: i64) -> Result { Err(Errno::EAGAIN) } fn host_futex_wake(&mut self, _a: usize, _c: u32) -> Result { Ok(0) } - fn host_clone( - &mut self, - _f: usize, - _a: usize, - _s: usize, - _t: usize, - _c: usize, - ) -> Result { - Err(Errno::ENOSYS) - } fn bind_framebuffer( &mut self, _pid: i32, @@ -29857,7 +29496,7 @@ mod tests { let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; - proc.pid = 9025; + proc.set_pid_for_test(9025); let server_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); let mut addr = [0u8; 64]; addr[0] = 1; // AF_UNIX @@ -30651,20 +30290,11 @@ mod tests { // ── Threading tests ────────────────────────────────────────────── - #[test] - fn test_process_thread_alloc_tid() { - let mut proc = Process::new(42); - let tid1 = proc.alloc_tid(); - let tid2 = proc.alloc_tid(); - assert_eq!(tid1, 43); // pid + 1 - assert_eq!(tid2, 44); - } - #[test] fn test_process_thread_add_remove() { use crate::process::ThreadInfo; let mut proc = Process::new(10); - let tid = proc.alloc_tid(); + let tid = 11; proc.add_thread(ThreadInfo::new(tid, 0x1000, 0x2000, 0x3000)); assert!(proc.get_thread(tid).is_some()); assert_eq!(proc.get_thread(tid).unwrap().stack_ptr, 0x2000); @@ -30676,34 +30306,37 @@ mod tests { #[test] fn test_clone_rejects_non_thread() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); + let mut table = crate::process_table::ProcessTable::new(); + let pid = table.create_process().unwrap(); + table.bind_current_tid(pid, pid).unwrap(); // Without CLONE_VM | CLONE_THREAD, clone should return ENOSYS - let result = sys_clone(&mut proc, &mut host, 0, 0, 0, 0, 0, 0, 0); + let result = sys_clone(&mut table, 0, 0, 0, 0, 0, 0, 0); assert_eq!(result, Err(Errno::ENOSYS)); } #[test] fn test_clone_rejects_clone_vm_only() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); + let mut table = crate::process_table::ProcessTable::new(); + let pid = table.create_process().unwrap(); + table.bind_current_tid(pid, pid).unwrap(); const CLONE_VM: u32 = 0x00000100; // CLONE_VM without CLONE_THREAD should fail - let result = sys_clone(&mut proc, &mut host, 0, 0x8000, CLONE_VM, 0, 0, 0, 0); + let result = sys_clone(&mut table, 0, 0x8000, CLONE_VM, 0, 0, 0, 0); assert_eq!(result, Err(Errno::ENOSYS)); } #[test] fn test_clone_thread_allocates_kernel_thread() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); + let mut table = crate::process_table::ProcessTable::new(); + let pid = table.create_process().unwrap(); + table.bind_current_tid(pid, pid).unwrap(); const CLONE_VM: u32 = 0x00000100; const CLONE_THREAD: u32 = 0x00010000; let flags = CLONE_VM | CLONE_THREAD; - let result = sys_clone(&mut proc, &mut host, 0, 0x8000, flags, 0, 0, 0, 0); + let result = sys_clone(&mut table, 0, 0x8000, flags, 0, 0, 0, 0); let tid = result.expect("thread-style clone should allocate a tid"); - assert!(tid > 0); - assert!(proc.get_thread(tid as u32).is_some()); + assert_eq!(tid, 101); + assert!(table.get(pid).unwrap().get_thread(tid as u32).is_some()); } #[test] @@ -30739,9 +30372,9 @@ mod tests { let mut proc = Process::new(1); // Allocate 3 threads - let t1 = proc.alloc_tid(); - let t2 = proc.alloc_tid(); - let t3 = proc.alloc_tid(); + let t1 = 2; + let t2 = 3; + let t3 = 4; proc.add_thread(ThreadInfo::new(t1, 0x100, 0x1000, 0x2000)); proc.add_thread(ThreadInfo::new(t2, 0x200, 0x3000, 0x4000)); @@ -30767,7 +30400,7 @@ mod tests { assert_eq!(info.tls_ptr, 0x123); assert_eq!(info.tidptr, 0); // default - info.tidptr = 0x456; + info.state_mut_for_test().tidptr = 0x456; assert_eq!(info.tidptr, 0x456); } @@ -30775,7 +30408,7 @@ mod tests { fn test_process_get_thread_mut() { use crate::process::ThreadInfo; let mut proc = Process::new(5); - let tid = proc.alloc_tid(); + let tid = 6; proc.add_thread(ThreadInfo::new(tid, 0, 0, 0)); // Modify thread via mutable ref @@ -31917,9 +31550,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_kill(&mut self, _p: i32, _s: u32) -> Result<(), Errno> { - Ok(()) - } fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { Ok(()) } @@ -31987,25 +31617,12 @@ mod tests { fn host_getaddrinfo(&mut self, _n: &[u8], _r: &mut [u8]) -> Result { Ok(0) } - fn host_fork(&self) -> i32 { - -(Errno::ENOSYS as i32) - } fn host_futex_wait(&mut self, _a: usize, _e: u32, _t: i64) -> Result { Err(Errno::EAGAIN) } fn host_futex_wake(&mut self, _a: usize, _c: u32) -> Result { Ok(0) } - fn host_clone( - &mut self, - _f: usize, - _a: usize, - _s: usize, - _t: usize, - _c: usize, - ) -> Result { - Err(Errno::ENOSYS) - } fn bind_framebuffer( &mut self, _pid: i32, @@ -32150,9 +31767,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_kill(&mut self, _p: i32, _s: u32) -> Result<(), Errno> { - Ok(()) - } fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { Ok(()) } @@ -32220,25 +31834,12 @@ mod tests { fn host_getaddrinfo(&mut self, _n: &[u8], _r: &mut [u8]) -> Result { Ok(0) } - fn host_fork(&self) -> i32 { - -(Errno::ENOSYS as i32) - } fn host_futex_wait(&mut self, _a: usize, _e: u32, _t: i64) -> Result { Ok(0) } fn host_futex_wake(&mut self, _a: usize, _c: u32) -> Result { Ok(0) } - fn host_clone( - &mut self, - _f: usize, - _a: usize, - _s: usize, - _t: usize, - _c: usize, - ) -> Result { - Err(Errno::ENOSYS) - } fn bind_framebuffer( &mut self, _pid: i32, @@ -32380,9 +31981,6 @@ mod tests { fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { Ok(()) } - fn host_kill(&mut self, _p: i32, _s: u32) -> Result<(), Errno> { - Ok(()) - } fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { Ok(()) } @@ -32450,25 +32048,12 @@ mod tests { fn host_getaddrinfo(&mut self, _n: &[u8], _r: &mut [u8]) -> Result { Ok(0) } - fn host_fork(&self) -> i32 { - -1 - } fn host_futex_wait(&mut self, _a: usize, _e: u32, _t: i64) -> Result { Ok(0) } fn host_futex_wake(&mut self, _a: usize, _c: u32) -> Result { Ok(0) } - fn host_clone( - &mut self, - _f: usize, - _a: usize, - _s: usize, - _t: usize, - _c: usize, - ) -> Result { - Err(Errno::ENOSYS) - } fn bind_framebuffer( &mut self, _pid: i32, @@ -33002,7 +32587,7 @@ mod tests { let mut table = ProcessTable::new(); // PID 1 is reserved for the virtual init process; use 100 for the test // parent so create_process doesn't collide with the auto-registered init. - table.create_process(100).unwrap(); + assert_eq!(table.create_process().unwrap(), 100); // Create a pipe in the parent { @@ -33016,7 +32601,7 @@ mod tests { } // Fork - table.fork_process(100, 101).unwrap(); + assert_eq!(table.fork_process_for_caller(100, 100).unwrap(), 101); // Child should NOT have fd 0 (CLOFORK), but SHOULD have fd 1 let child = table.get(101).unwrap(); @@ -34519,8 +34104,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) }; @@ -34542,7 +34136,14 @@ mod tests { }; let mut dbuf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(dbuf.as_mut_ptr() as *mut WpkDrmModeDestroyDumb, req) }; - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_DESTROY_DUMB, &mut dbuf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_DESTROY_DUMB, + &mut dbuf, + ) + .unwrap(); assert!( !proc .ofd_table @@ -34556,7 +34157,14 @@ mod tests { // Second DESTROY_DUMB on the same handle → ENOENT. assert_eq!( - sys_ioctl(&mut proc, &mut host, fd, DRM_IOCTL_MODE_DESTROY_DUMB, &mut dbuf).unwrap_err(), + sys_ioctl( + &mut proc, + &mut host, + fd, + DRM_IOCTL_MODE_DESTROY_DUMB, + &mut dbuf + ) + .unwrap_err(), Errno::ENOENT ); } @@ -34632,7 +34240,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 out: WpkDrmPrimeHandle = unsafe { core::ptr::read_unaligned(pbuf.as_ptr() as *const WpkDrmPrimeHandle) }; assert!(out.fd >= 0); @@ -34670,8 +34285,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_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 created: WpkDrmModeCreateDumb = unsafe { core::ptr::read_unaligned(buf.as_ptr() as *const WpkDrmModeCreateDumb) }; @@ -34683,7 +34307,14 @@ mod tests { }; let mut pbuf = [0u8; core::mem::size_of::()]; unsafe { core::ptr::write_unaligned(pbuf.as_mut_ptr() as *mut WpkDrmPrimeHandle, exp) }; - sys_ioctl(&mut proc, &mut host, fd_a, DRM_IOCTL_PRIME_HANDLE_TO_FD, &mut pbuf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd_a, + DRM_IOCTL_PRIME_HANDLE_TO_FD, + &mut pbuf, + ) + .unwrap(); let exported: WpkDrmPrimeHandle = unsafe { core::ptr::read_unaligned(pbuf.as_ptr() as *const WpkDrmPrimeHandle) }; @@ -34694,7 +34325,14 @@ mod tests { fd: exported.fd, }; unsafe { core::ptr::write_unaligned(pbuf.as_mut_ptr() as *mut WpkDrmPrimeHandle, imp) }; - sys_ioctl(&mut proc, &mut host, fd_b, DRM_IOCTL_PRIME_FD_TO_HANDLE, &mut pbuf).unwrap(); + sys_ioctl( + &mut proc, + &mut host, + fd_b, + DRM_IOCTL_PRIME_FD_TO_HANDLE, + &mut pbuf, + ) + .unwrap(); let imported: WpkDrmPrimeHandle = unsafe { core::ptr::read_unaligned(pbuf.as_ptr() as *const WpkDrmPrimeHandle) }; assert_eq!(imported.handle, 1, "first handle in fd_b's namespace"); @@ -34781,14 +34419,12 @@ mod tests { // Bo exists in the registry with refcount = 1. let bo_id = crate::dri::bo::next_id_for_test() - 1; - let bo_present_before = - crate::dri::with_registry(|r| r.get(bo_id).is_some()); + let bo_present_before = crate::dri::with_registry(|r| r.get(bo_id).is_some()); assert!(bo_present_before); // Close the fd — the bo must be gone. sys_close(&mut proc, &mut host, fd).unwrap(); - let bo_gone_after = - crate::dri::with_registry(|r| r.get(bo_id).is_none()); + let bo_gone_after = crate::dri::with_registry(|r| r.get(bo_id).is_none()); assert!(bo_gone_after, "close should have released the bo"); // Avoid "unused variable" warning. let _ = created; @@ -35486,9 +35122,7 @@ 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) - }; + 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) }; diff --git a/crates/kernel/src/terminal.rs b/crates/kernel/src/terminal.rs index 2f19a0bb70..67e6883815 100644 --- a/crates/kernel/src/terminal.rs +++ b/crates/kernel/src/terminal.rs @@ -139,7 +139,9 @@ impl TerminalState { ws_xpixel: 0, ws_ypixel: 0, }, - foreground_pgid: 1, // default to PID 1's group + // No foreground process group exists until a Process or PTY + // allocation binds this terminal to authoritative process state. + foreground_pgid: 0, session_id: 0, line_buffer: Vec::new(), cooked_buffer: Vec::new(), diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index b1b16e897d..256d7b415d 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -26,7 +26,8 @@ use crate::process::{ HostIO, Process, ProcessState, StdioConfig, StdioKind, normalize_posix_timer_signo, }; use crate::signal::{ - DefaultSignalOutcome, apply_default_signal_action_with_locks, deliver_pending_signals_with_locks, + 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, }; use crate::syscalls; @@ -74,7 +75,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_kill(pid: i32, sig: 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( @@ -140,16 +140,8 @@ unsafe extern "C" { result_ptr: *mut u8, result_len: u32, ) -> i32; - fn host_fork() -> i32; fn host_futex_wait(addr: usize, expected: u32, timeout_ns_lo: u32, timeout_ns_hi: u32) -> i32; fn host_futex_wake(addr: usize, count: u32) -> i32; - fn host_clone( - fn_ptr: usize, - arg: usize, - stack_ptr: usize, - tls_ptr: usize, - ctid_ptr: usize, - ) -> i32; fn host_is_thread_worker() -> i32; fn host_bind_framebuffer( pid: i32, @@ -569,18 +561,6 @@ impl HostIO for WasmHostIO { i32_to_result(result) } - fn host_kill(&mut self, pid: i32, sig: u32) -> Result<(), Errno> { - let ret = unsafe { host_kill(pid, sig) }; - if ret < 0 { - match Errno::from_u32((-ret) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(()) - } - } - fn host_exec(&mut self, path: &[u8]) -> Result<(), Errno> { let ret = unsafe { host_exec(path.as_ptr(), path.len() as u32) }; if ret < 0 { @@ -830,13 +810,6 @@ impl HostIO for WasmHostIO { } } - fn host_fork(&self) -> i32 { - gkl_release(); - let result = unsafe { host_fork() }; - gkl_acquire(); - result - } - fn host_futex_wait( &mut self, addr: usize, @@ -872,29 +845,6 @@ impl HostIO for WasmHostIO { } } - fn host_clone( - &mut self, - fn_ptr: usize, - arg: usize, - stack_ptr: usize, - tls_ptr: usize, - ctid_ptr: usize, - ) -> Result { - // Release GKL before blocking — host_clone blocks on Atomics.wait - // until the process-manager assigns a TID. - gkl_release(); - let result = unsafe { host_clone(fn_ptr, arg, stack_ptr, tls_ptr, ctid_ptr) }; - gkl_acquire(); - if result < 0 { - match Errno::from_u32((-result) as u32) { - Some(e) => Err(e), - None => Err(Errno::EIO), - } - } else { - Ok(result) - } - } - fn bind_framebuffer( &mut self, pid: i32, @@ -1290,13 +1240,13 @@ pub extern "C" fn kernel_get_memory_pages() -> u32 { } /// Create a new process in the process table with captured pipe stdio. -/// Returns 0 on success, -EEXIST if pid already exists. +/// Returns the kernel-allocated pid on success or a negative errno. #[unsafe(no_mangle)] -pub extern "C" fn kernel_create_process(pid: u32) -> i32 { +pub extern "C" fn kernel_create_process() -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.create_process(pid) { - Ok(()) => 0, - Err(()) => -(Errno::EEXIST as i32), + match table.create_process() { + Ok(pid) => pid as i32, + Err(e) => -(e as i32), } } @@ -1306,11 +1256,10 @@ pub extern "C" fn kernel_create_process(pid: u32) -> i32 { /// - 0: host-backed pipe semantics (`isatty` false, FIFO stat mode) /// - 1: host-backed terminal/char-device semantics /// -/// Returns 0 on success, -EINVAL for an unknown stdio kind, or -EEXIST if -/// pid already exists. +/// Returns the kernel-allocated pid on success, -EINVAL for an unknown stdio +/// kind, or another negative errno on allocation failure. #[unsafe(no_mangle)] pub extern "C" fn kernel_create_process_with_stdio( - pid: u32, stdin_kind: u32, stdout_kind: u32, stderr_kind: u32, @@ -1329,9 +1278,9 @@ pub extern "C" fn kernel_create_process_with_stdio( }; let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.create_process_with_stdio(pid, stdio) { - Ok(()) => 0, - Err(()) => -(Errno::EEXIST as i32), + match table.create_process_with_stdio(stdio) { + Ok(pid) => pid as i32, + Err(e) => -(e as i32), } } @@ -1527,7 +1476,6 @@ pub extern "C" fn kernel_push_process_metadata_entry( fn finish_removed_process(pid: u32, result: crate::process_table::RemoveProcessResult) { use core::sync::atomic::Ordering; - let removed = result.process; // A process removed without reaching sys_exit (worker crash or explicit // host termination) can still own host-side VFS handles. Close directory // iterators before their backing file handles, @@ -1542,7 +1490,7 @@ fn finish_removed_process(pid: u32, result: crate::process_table::RemoveProcessR // /dev/fb0 cleanup: if the exiting process held a live mmap, tell the host // to drop the canvas binding before the process Memory disappears. Then // release the global owner claim — best-effort CAS makes this idempotent. - if removed.fb_binding.is_some() { + if result.had_framebuffer_binding { unsafe { host_unbind_framebuffer(pid as i32) }; } let _ = crate::process_table::FB0_OWNER.compare_exchange( @@ -1605,14 +1553,15 @@ pub extern "C" fn kernel_reap_process(pid: u32) -> i32 { reap_process_and_cleanup(pid) } -/// Fork a process in the process table. -/// Clones parent's Process state and creates a child with `child_pid`. -/// Returns 0 on success, negative errno on error. +/// 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. +/// Returns the child pid on success, negative errno on error. #[unsafe(no_mangle)] -pub extern "C" fn kernel_fork_process(parent_pid: u32, child_pid: u32) -> i32 { +pub extern "C" fn kernel_fork_process(parent_pid: u32, caller_tid: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.fork_process(parent_pid, child_pid) { - Ok(()) => 0, + match table.fork_process_for_caller(parent_pid, caller_tid) { + Ok(child_pid) => child_pid as i32, Err(e) => -(e as i32), } } @@ -1632,7 +1581,12 @@ pub extern "C" fn kernel_fork_process(parent_pid: u32, child_pid: u32) -> i32 { /// `blob_ptr..blob_ptr + blob_len` lies inside the kernel's linear /// memory and stays valid for the duration of this call. #[unsafe(no_mangle)] -pub extern "C" fn kernel_spawn_process(parent_pid: u32, blob_ptr: usize, blob_len: usize) -> i32 { +pub extern "C" fn kernel_spawn_process( + parent_pid: u32, + caller_tid: u32, + blob_ptr: usize, + blob_len: usize, +) -> 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, @@ -1648,8 +1602,9 @@ pub extern "C" fn kernel_spawn_process(parent_pid: u32, blob_ptr: usize, blob_le // / etc) — host imports defined at the top of this module. let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let mut host = WasmHostIO; - match table.spawn_child( + match table.spawn_child_for_caller( parent_pid, + caller_tid, &argv_refs, &envp_refs, &parsed.file_actions, @@ -1791,6 +1746,7 @@ pub extern "C" fn kernel_mark_process_signaled(pid: u32, signum: u32) -> i32 { #[unsafe(no_mangle)] pub extern "C" fn kernel_wait_child_poll( parent_pid: u32, + caller_tid: u32, target_pid: i32, event_mask: u32, flags: u32, @@ -1802,6 +1758,9 @@ pub extern "C" fn kernel_wait_child_poll( let selected = { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + if table.validate_task(parent_pid, caller_tid).is_err() { + return -(Errno::ESRCH as i32); + } table.poll_wait_event(parent_pid, target_pid, event_mask, flags) }; @@ -1882,23 +1841,6 @@ pub extern "C" fn kernel_has_sa_nocldstop(pid: u32) -> i32 { } } -/// Reset signal mask for a process. -/// Fork children re-execute _start, so they don't get musl's __restore_sigs -/// after fork(). Clear the blocked mask so the child starts with no signals -/// blocked, matching a fresh process. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_reset_signal_mask(pid: u32) -> i32 { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.get_mut(pid) { - Some(proc) => { - proc.signals.blocked = 0; - proc.sigsuspend_saved_mask = None; - 0 - } - None => -(Errno::ESRCH as i32), - } -} - /// Check if a signal is blocked for a process. /// Returns 1 if blocked by *every* thread of `pid` (i.e. no thread can /// currently receive it), 0 if at least one thread has it unblocked, @@ -1956,6 +1898,9 @@ pub extern "C" fn kernel_thread_has_deliverable(pid: u32, tid: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; match table.get(pid) { Some(proc) => { + if !proc.is_live_explicit_tid(tid) { + return -(Errno::ESRCH as i32); + } if proc.deliverable_for(tid) != 0 { 1 } else { @@ -2208,20 +2153,19 @@ fn process_name_bytes(proc: &crate::process::Process) -> Vec { } } -/// Dequeue one pending Handler signal for a process. +/// 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) /// 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, out_ptr: *mut u8) -> i32 { +pub extern "C" fn kernel_dequeue_signal(pid: u32, tid: u32, out_ptr: *mut u8) -> i32 { use crate::signal::{SignalHandler, sig_bit}; - let tid = crate::process_table::current_tid(); let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - let (proc, advisory_locks) = match table.process_and_advisory_locks(pid) { + let (proc, advisory_locks) = match table.task_and_advisory_locks(pid, tid) { Some(pair) => pair, - None => return 0, + None => return -(Errno::ESRCH as i32), }; loop { // Peek at the lowest-numbered deliverable signal for this thread: @@ -2304,17 +2248,10 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, out_ptr: *mut u8) -> i32 { } } -/// Handle exec semantics on a process in the process table. -/// Closes CLOEXEC descriptors and resets image-specific state in place so -/// surviving kernel objects retain their exact identity and queues. -/// Returns 0 on success, negative errno on error. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_exec_setup(pid: u32) -> i32 { - kernel_exec_setup_inner(pid, pid) -} - /// Thread-aware exec setup. When a pthread invokes exec, its signal mask and -/// directed pending signals become the surviving process thread's state. +/// 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) @@ -2336,23 +2273,9 @@ pub extern "C" fn kernel_exec_prepare(pid: u32, caller_tid: u32) -> 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)?; - - if matches!( - proc.state, - crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo - ) { - return Err(Errno::ESRCH); - } + let (proc, advisory_locks) = table.process_and_advisory_locks(pid).ok_or(Errno::ESRCH)?; - if caller_tid != 0 - && caller_tid != pid - && !proc.threads.iter().any(|thread| thread.tid == caller_tid) - { - return Err(Errno::ESRCH); - } + proc.begin_exec_prepare(caller_tid)?; // Apply pending fork fd actions (from posix_spawn) before exec. // These are dup2/close/open operations that rearrange descriptors (for @@ -2400,31 +2323,19 @@ fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { } } } + proc.finish_exec_prepare(caller_tid); Ok(()) } fn kernel_exec_setup_inner(pid: u32, caller_tid: u32) -> i32 { - // Compatibility fallback for hosts that have not adopted the explicit - // prepare step yet. New hosts call kernel_exec_prepare first, leaving no - // actions here; validating twice is deliberate and harmless. - let has_pending_actions = { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.get(pid) { - Some(proc) => !proc.fork_fd_actions.is_empty(), - None => return -(Errno::ESRCH as i32), - } - }; - if has_pending_actions { - if let Err(e) = prepare_exec_state(pid, caller_tid) { - return -(e 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); + } let mut host = WasmHostIO; match syscalls::commit_exec_state_with_locks(proc, advisory_locks, &mut host, caller_tid) { Ok(()) => 0, @@ -2496,9 +2407,13 @@ fn mq_would_block_result(timeout_ptr: usize, table: &crate::mqueue::MqueueTable, pub extern "C" fn kernel_handle_channel(offset: usize, pid: u32) -> i32 { use wasm_posix_shared::channel::*; - // Set current process for dispatch - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.set_current_pid(pid); + // 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. + let has_task_binding = { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + table.has_current_tid_binding(pid) + }; // Read syscall number and args from kernel memory let base = offset; @@ -2537,7 +2452,12 @@ pub extern "C" fn kernel_handle_channel(offset: usize, pid: u32) -> i32 { // kernel memory terms. The JS layer sets pointer args as absolute // kernel-memory addresses, so we pass them through unchanged. - let result = dispatch_channel_syscall(syscall_nr, &args); + let result = if has_task_binding { + dispatch_channel_syscall(syscall_nr, &args) + } else { + -(Errno::ESRCH as i32) + }; + unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); // Write result back to channel let out = unsafe { @@ -3311,8 +3231,10 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let len = unsafe { cstr_len(p) }; kernel_execveat(a1 as i32, p, len, a5 as u32) } - 212 => kernel_fork(), // SYS_FORK - 213 => kernel_fork(), // SYS_VFORK (treat as fork) + // 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. + 212 | 213 => -(Errno::ENOSYS as i32), // SYS_FORK / SYS_VFORK 201 => kernel_clone( 0, a2 as usize, @@ -3588,7 +3510,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { } else { 0 }; - kernel_kill_with_value(a1, a2 as u32, si_value) + kernel_kill_with_metadata(a1, a2 as u32, si_value, -1) } // SYS_RT_SIGRETURN: signal handler return — clean up alt stack state @@ -4346,59 +4268,134 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SysV IPC kernel exports // --------------------------------------------------------------------------- -/// Set the current process for subsequent kernel_ipc_* calls. -/// Host must call this before kernel_ipc_shmat/shmdt/shm_read_chunk/shm_write_chunk. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_set_current_pid(pid: u32) { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.set_current_pid(pid); -} - -/// Set the current kernel/libc thread id for the next `kernel_handle_channel` -/// call and for subsequent signal syscalls that need to know which POSIX thread -/// is executing (`sigprocmask`, `sigsuspend`, `ppoll`/`pselect`, etc.). +/// Bind the current kernel/libc thread id for exactly the next +/// `kernel_handle_channel` call. Signal syscalls dispatched by that call use +/// the same validated task context. /// /// The host tracks `(pid, channelOffset) -> tid` in its own map (`channelTids`) /// and must call this *before* dispatching a thread-originated syscall. The -/// channel offset identifies the host mailbox; this value supplies the -/// guest-visible pthread identity used by gettid, set_tid_address, per-thread -/// signal state, and clear-TID cleanup. +/// ProcessTable rejects a TID that it did not allocate for `pid`, so the host +/// mapping remains transport metadata rather than an identity authority. /// /// This is ambient dispatch context for today's serialized host/kernel entry /// model. If a single kernel instance ever services channels concurrently or /// reentrantly, the TID should move into the syscall header or be passed as a -/// `kernel_handle_channel` argument. The main thread uses `tid = 0`, which is -/// also the default. +/// `kernel_handle_channel` argument. The main thread uses its explicit leader +/// TID, equal to `pid`. Zero is reserved for kernel-internal unit-test dispatch +/// state and is rejected here. #[unsafe(no_mangle)] -pub extern "C" fn kernel_set_current_tid(tid: u32) { +pub extern "C" fn kernel_set_current_tid(pid: u32, tid: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.set_current_tid(tid); + match table.bind_current_tid(pid, tid) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Validate a host channel's exact task identity without installing a +/// one-shot dispatch binding. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_validate_task(pid: u32, tid: u32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.validate_task(pid, tid) { + Ok(()) => 0, + Err(e) => -(e as i32), + } } /// Attach to shared memory segment. Returns segment size, or negative errno. /// Host uses this + kernel_ipc_shm_read_chunk to transfer data to process memory. #[unsafe(no_mangle)] -pub extern "C" fn kernel_ipc_shmat(shmid: i32, _shmaddr: i32, flags: i32) -> i32 { +pub extern "C" fn kernel_ipc_shmat(shmid: i32, shmaddr: i32, flags: i32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + let pid = table.current_pid(); + if !table.has_current_tid_binding(pid) { + return -(Errno::ESRCH as i32); + } + kernel_ipc_shmat_for_process(pid, shmid, shmaddr, flags) +} + +/// Host-side SysV attachment with an explicit kernel-owned process identity. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shmat_for_process( + pid: u32, + shmid: i32, + _shmaddr: i32, + flags: i32, +) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + let (uid, gid) = match table.get(pid) { + Some(proc) + if pid != crate::process_table::SYNTHETIC_INIT_PID + && matches!(proc.state, ProcessState::Running | ProcessState::Stopped) => + { + (proc.euid, proc.egid) + } + _ => return -(Errno::ESRCH as i32), + }; let ipc = unsafe { crate::ipc::global_ipc_table() }; - let (pid, uid, gid) = current_pid_eids(); match ipc.shmat(shmid, pid, flags as u32, uid, gid) { Ok(size) => size as i32, Err(e) => -(e as i32), } } +/// Guest-originated SysV attachment for an exact live calling task. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shmat_for_task( + pid: u32, + tid: u32, + shmid: i32, + shmaddr: i32, + flags: i32, +) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if table.validate_task(pid, tid).is_err() { + return -(Errno::ESRCH as i32); + } + kernel_ipc_shmat_for_process(pid, shmid, shmaddr, flags) +} + /// Detach from shared memory segment. /// Host should call kernel_ipc_shm_write_chunk first to sync data back. #[unsafe(no_mangle)] pub extern "C" fn kernel_ipc_shmdt(shmid: i32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + let pid = table.current_pid(); + if !table.has_current_tid_binding(pid) { + return -(Errno::ESRCH as i32); + } + kernel_ipc_shmdt_for_process(pid, shmid) +} + +/// Host-side SysV detach with an explicit retained process identity. Exited +/// zombies remain eligible so teardown can release attachments after death. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shmdt_for_process(pid: u32, shmid: i32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) + if pid != crate::process_table::SYNTHETIC_INIT_PID + && proc.state != ProcessState::Limbo => {} + _ => return -(Errno::ESRCH as i32), + } let ipc = unsafe { crate::ipc::global_ipc_table() }; - let pid = unsafe { &*PROCESS_TABLE.0.get() }.current_pid(); match ipc.shmdt(shmid, pid) { Ok(()) => 0, Err(e) => -(e as i32), } } +/// Guest-originated SysV detach for an exact live calling task. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_ipc_shmdt_for_task(pid: u32, tid: u32, shmid: i32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if table.validate_task(pid, tid).is_err() { + return -(Errno::ESRCH as i32); + } + kernel_ipc_shmdt_for_process(pid, shmid) +} + /// Read a chunk of shared memory segment data into scratch area. /// Returns bytes written to out_ptr. #[unsafe(no_mangle)] @@ -4601,14 +4598,6 @@ pub extern "C" fn kernel_mq_is_mqd(fd: i32) -> i32 { if table.is_mqd(fd as u32) { 1 } else { 0 } } -/// Initialize the kernel with a new process. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_init(pid: u32) { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.processes.insert(pid, Process::new(pid)); - table.set_current_pid(pid); -} - /// Serialize current process state for fork. Returns bytes written, or negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_fork_state(buf_ptr: *mut u8, buf_len: u32) -> i32 { @@ -4620,64 +4609,6 @@ pub extern "C" fn kernel_get_fork_state(buf_ptr: *mut u8, buf_len: u32) -> i32 { } } -/// Initialize kernel from serialized fork state (child side). -#[unsafe(no_mangle)] -pub extern "C" fn kernel_init_from_fork(buf_ptr: *const u8, buf_len: u32, child_pid: u32) -> i32 { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - if table.get(child_pid).is_some() { - return -(Errno::EEXIST as i32); - } - let buf = unsafe { core::slice::from_raw_parts(buf_ptr, buf_len as usize) }; - match crate::fork::deserialize_fork_state(buf, child_pid) { - Ok(proc) => { - if let Err(e) = table.insert_legacy_fork_process(proc) { - return -(e as i32); - } - table.set_current_pid(child_pid); - 0 - } - Err(e) => -(e as i32), - } -} - -/// Serialize exec-safe state. Returns bytes written on success, negative errno on error. -/// Exec state differs from fork: closes CLOEXEC fds, resets caught signal handlers, -/// preserves pending signals and signal mask. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_get_exec_state(buf_ptr: *mut u8, buf_len: 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) }; - match crate::fork::serialize_exec_state(proc, buf) { - Ok(written) => written as i32, - Err(e) => -(e as i32), - } -} - -/// Initialize kernel from exec state (replaces current process image). -/// Returns 0 on success, negative errno on error. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_init_from_exec(buf_ptr: *const u8, buf_len: u32, pid: u32) -> i32 { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - let buf = unsafe { core::slice::from_raw_parts(buf_ptr, buf_len as usize) }; - match crate::fork::deserialize_exec_state(buf, pid) { - Ok(proc) => { - let cleanup = match table.replace_legacy_exec_process(pid, proc) { - Ok(cleanup) => cleanup, - Err(e) => return -(e as i32), - }; - for dir_handle in cleanup.host_dir_closes { - unsafe { host_closedir(dir_handle) }; - } - for handle in cleanup.host_closes { - unsafe { host_close(handle) }; - } - table.set_current_pid(pid); - 0 - } - Err(e) => -(e as i32), - } -} - /// Convert a pipe's OFD from kernel-internal to host-delegated. /// After this, reads/writes for this OFD will go through host_read/host_write. #[unsafe(no_mangle)] @@ -4843,11 +4774,12 @@ pub extern "C" fn kernel_write(fd: i32, buf_ptr: *const u8, buf_len: u32) -> i32 /// /// `positioned != 0` selects the supplied offset (pwrite/pwritev); otherwise /// the open-file-description cursor and O_APPEND state are authoritative. -/// The host binds the calling TID before entering this export so SIGXFSZ is -/// queued for the thread that issued the operation. +/// 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, @@ -4855,8 +4787,7 @@ pub extern "C" fn kernel_prepare_write_operation( ) -> i64 { let _gkl = GklGuard::acquire(); let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.set_current_pid(pid); - let (proc, advisory_locks) = match table.process_and_advisory_locks(pid) { + let (proc, advisory_locks) = match table.task_and_advisory_locks(pid, tid) { Some(pair) => pair, None => return -(Errno::ESRCH as i64), }; @@ -4864,6 +4795,7 @@ pub extern "C" fn kernel_prepare_write_operation( let result = match syscalls::write_operation_budget( proc, &mut host, + tid, fd, (positioned != 0).then_some(offset), requested_len as usize, @@ -4871,7 +4803,12 @@ pub extern "C" fn kernel_prepare_write_operation( Ok(len) => len as i64, Err(e) => -(e as i64), }; - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + let _ = deliver_pending_signals_for_tid_with_locks( + proc, + advisory_locks, + &mut host, + tid, + ); result } @@ -5795,31 +5732,40 @@ pub extern "C" fn kernel_setsid() -> i32 { /// Send a signal to a process. Returns 0 on success, or negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_kill(pid: i32, sig: u32) -> i32 { - kernel_kill_with_value(pid, sig, 0) + kernel_kill_with_metadata(pid, sig, 0, 0) } -/// Send signal with si_value (for sigqueue/rt_sigqueueinfo). -fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> 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 { use wasm_posix_shared::signal::NSIG; let _gkl = GklGuard::acquire(); let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let mut host = WasmHostIO; let caller_pid = table.current_pid(); + let caller_tid = table.current_tid(); let (caller_pgid, sender_uid, sender_euid) = match table.get(caller_pid) { - Some(caller) => (caller.pgid, caller.uid, caller.euid), + Some(caller) if caller.is_live_explicit_tid(caller_tid) => { + (caller.pgid, caller.uid, caller.euid) + } None => return -(Errno::ESRCH as i32), + Some(_) => return -(Errno::ESRCH as i32), }; let deliver_caller = |table: &mut crate::process_table::ProcessTable, host: &mut WasmHostIO| { if let Some((caller, locks)) = table.process_and_advisory_locks(caller_pid) { - deliver_pending_signals_with_locks(caller, locks, host); + let _ = deliver_pending_signals_for_tid_with_locks( + caller, + locks, + host, + caller_tid, + ); } }; // Handle cross-process kill directly via ProcessTable. - let is_local = pid == caller_pid as i32 || pid == 0 || pid == -(caller_pgid as i32); - if !is_local && pid > 0 { + let is_self = pid == caller_pid as i32; + if !is_self && pid > 0 { if sig >= NSIG && sig != 0 { deliver_caller(table, &mut host); return -(Errno::EINVAL as i32); @@ -5832,11 +5778,22 @@ fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> 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(_) => { if sig > 0 { if let Some((target, locks)) = table.process_and_advisory_locks(target_pid) { - target.raise_signal_with_value(sig, si_value); - deliver_pending_signals_with_locks(target, locks, &mut host); + target.raise_signal_with_metadata(sig, si_value, si_code); + 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, + ); + } } } 0 @@ -5846,13 +5803,62 @@ fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> i32 { deliver_caller(table, &mut host); return result; } - // kill(-pgid, sig) sends signal to all processes in group |pid| - if !is_local && pid < -1 { + // kill(-1, sig) targets every permitted live process except the caller and + // synthetic init, matching Linux's observable choice within POSIX's + // implementation-defined system-process exclusions. + if pid == -1 { if sig >= NSIG && sig != 0 { deliver_caller(table, &mut host); return -(Errno::EINVAL as i32); } - let target_pgid = (-pid) as u32; + let pids: Vec = table + .live_processes_descending() + .map(|(target_pid, _)| target_pid) + .filter(|&target_pid| target_pid != caller_pid) + .collect(); + let mut delivered = false; + let mut any_perm_denied = false; + for target_pid in pids { + let Some(target) = table.get(target_pid) else { + continue; + }; + if !syscalls::can_signal(sender_uid, sender_euid, target.uid, target.euid) { + any_perm_denied = true; + continue; + } + 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_tid) = target.pick_thread_for_shared_signal(sig) { + let _ = deliver_pending_signals_for_tid_with_locks( + target, + locks, + &mut host, + target_tid, + ); + } + } + } + } + deliver_caller(table, &mut host); + if delivered { + return 0; + } + if any_perm_denied { + return -(Errno::EPERM as i32); + } + return -(Errno::ESRCH as i32); + } + + // kill(0, sig) targets the caller's group; kill(-pgid, sig) targets the + // named group. Every target task is selected from kernel state. + if pid == 0 || pid < -1 { + if sig >= NSIG && sig != 0 { + deliver_caller(table, &mut host); + return -(Errno::EINVAL as i32); + } + let target_pgid = if pid == 0 { caller_pgid } else { (-pid) as u32 }; let pids = table.pids_in_group(target_pgid); if pids.is_empty() { deliver_caller(table, &mut host); @@ -5868,13 +5874,27 @@ fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> i32 { any_perm_denied = true; continue; } + if target_pid == crate::process_table::SYNTHETIC_INIT_PID { + delivered = true; + continue; + } + if !target.is_live_explicit_tid(target.pid) { + continue; + } delivered = true; if sig > 0 { if let Some((target, locks)) = table.process_and_advisory_locks(target_pid) { - target.raise_signal_with_value(sig, si_value); - deliver_pending_signals_with_locks(target, locks, &mut host); + target.raise_signal_with_metadata(sig, si_value, si_code); + 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, + ); + } } } } @@ -5889,24 +5909,25 @@ fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> i32 { } } - // For local sigqueue, raise with value on the current process directly. - if si_value != 0 && sig > 0 { - let Some((caller, locks)) = table.process_and_advisory_locks(caller_pid) else { - return -(Errno::ESRCH as i32); - }; - caller.raise_signal_with_value(sig, si_value); - deliver_pending_signals_with_locks(caller, locks, &mut host); - return 0; + // The only remaining target is the exact calling process. Remote target + // selection never delegates to a host callback. + if sig >= NSIG && sig != 0 { + deliver_caller(table, &mut host); + return -(Errno::EINVAL as i32); } let Some((caller, locks)) = table.process_and_advisory_locks(caller_pid) else { return -(Errno::ESRCH as i32); }; - let result = match syscalls::sys_kill(caller, &mut host, pid, sig) { - Ok(()) => 0, - Err(e) => -(e as i32), - }; - deliver_pending_signals_with_locks(caller, locks, &mut host); - result + if sig > 0 { + caller.raise_signal_with_metadata(sig, si_value, si_code); + } + let _ = deliver_pending_signals_for_tid_with_locks( + caller, + locks, + &mut host, + caller_tid, + ); + 0 } /// sigaltstack — get/set alternate signal stack state. @@ -6050,27 +6071,12 @@ pub extern "C" fn kernel_sched_getparam(pid: i32, param_ptr: *mut u8) -> i32 { 0 } -/// Deliver a signal from an external source (host). Called by host when -/// another process sends a signal to this one via kill(). -/// Returns 0 on success, -EINVAL for invalid signal. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_deliver_signal(sig: u32) -> i32 { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - if sig == 0 || sig >= wasm_posix_shared::signal::NSIG { - return -(Errno::EINVAL as i32); - } - proc.raise_signal(sig); - let mut host = WasmHostIO; - deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); - 0 -} - /// Send a signal to the current process. Returns 0 on success, or negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_raise(sig: u32) -> i32 { let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; let mut host = WasmHostIO; - let result = match syscalls::sys_raise(proc, &mut host, sig) { + let result = match syscalls::sys_raise(proc, sig) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -6082,10 +6088,10 @@ pub extern "C" fn kernel_raise(sig: u32) -> i32 { /// process. POSIX requires that directed signals go to that thread's pending /// queue, not the process-wide shared queue. /// -/// - `tid == 0` or `tid == pid` targets the main thread's directed queue. +/// - `tid == pid` targets the main thread's directed queue. /// - Other `tid` values look up the thread in `Process::threads` and raise /// on its own per-thread pending queue. -/// - Unknown `tid` → `-ESRCH`. +/// - `tid == 0`, unknown, or stale `tid` → `-ESRCH`. /// /// Cross-process `tkill` is not supported (returns `-ESRCH`); use `kill` or /// `tgkill` with the current process's `tgid` for current-process delivery. @@ -6122,6 +6128,14 @@ fn kernel_tkill_with_value(tid: u32, sig: u32, si_value: i32, si_code: i32) -> i return -(Errno::EINVAL as i32); } + // Exact-thread signal APIs accept only task IDs that the ProcessTable + // allocated and that are still live in this process. In particular, the + // internal tid=0 sentinel used for process-wide dispatch is not a task. + if !proc.is_live_explicit_tid(tid) { + deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); + return -(Errno::ESRCH as i32); + } + // Main thread: use its directed queue rather than the process-shared set. if proc.is_main_thread(tid) { if sig > 0 { @@ -6136,27 +6150,12 @@ 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 the TID doesn't match any known worker, fall back to shared-pending - // delivery. This is a safety net for callers that pass a stale TID — - // notably `raise()` in a forked child whose pthread_self()->tid hasn't - // been refreshed via `set_tid_address` yet. Strict POSIX would return - // ESRCH, but returning an error here silently breaks `abort()` and any - // in-process signalling that uses `tkill(self_tid, sig)`; aligning with - // the previous "tkill is raise" behaviour keeps those paths alive while - // per-thread routing is still correct for genuinely-known TIDs. if sig > 0 { - let directed = if si_code != 0 || si_value != 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) }; - if !directed { - if si_code != 0 || si_value != 0 { - proc.raise_signal_with_value(sig, si_value); - } else { - proc.raise_signal(sig); - } - } } deliver_pending_signals_with_locks(proc, advisory_locks, &mut host); 0 @@ -7203,6 +7202,10 @@ pub extern "C" fn kernel_exit(status: i32) -> ! { syscalls::sys_exit_with_locks(proc, advisory_locks, &mut host, status); } } // _gkl dropped here — GKL released + // `kernel_handle_channel` normally consumes its one-shot task binding on + // return. `_exit` deliberately traps instead, so consume it here before + // control leaves the kernel and no exited task remains ambient authority. + unsafe { &mut *PROCESS_TABLE.0.get() }.clear_current_tid_binding(); // Halt execution — musl's _exit loops forever if we just return. #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] unsafe { @@ -7502,7 +7505,7 @@ fn cross_process_loopback_connect( // Prefer highest pid — when parent and child both inherit a listener // (e.g. FPM master forks worker), the child (worker) is the one // that actually calls accept(). - for (&pid, target_proc) in table.processes.iter().rev() { + for (pid, target_proc) in table.live_processes_descending() { if pid == my_pid { continue; } @@ -7648,7 +7651,7 @@ fn cross_process_loopback_connect6( sock_idx }; let mut listener = None; - for (&pid, target_proc) in table.processes.iter().rev() { + for (pid, target_proc) in table.live_processes_descending() { if pid == my_pid { continue; } @@ -7857,36 +7860,34 @@ mod socket_wrapper_tests { use crate::errno::Errno; use crate::fd::OpenFileDescRef; use crate::ofd::FileType; - use crate::process::Process; use crate::process_table::ProcessTable; use crate::socket::{SocketDomain, SocketInfo, SocketType}; use wasm_posix_shared::flags::O_RDWR; #[test] fn unix_datagram_cross_process_retry_preserves_connrefused() { - let mut proc = Process::new(9040); - let sock_idx = - proc.sockets - .alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0)); - let ofd_idx = proc.ofd_table.create( - FileType::Socket, - O_RDWR, - -((sock_idx as i64) + 1), - b"/dev/socket".to_vec(), - ); - let fd = proc - .fd_table - .alloc(OpenFileDescRef(ofd_idx), 0) - .unwrap(); + let mut table = ProcessTable::new(); + let pid = table.create_process().unwrap(); + let fd = { + let proc = table.get_mut(pid).unwrap(); + let sock_idx = + proc.sockets + .alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0)); + let ofd_idx = proc.ofd_table.create( + FileType::Socket, + O_RDWR, + -((sock_idx as i64) + 1), + b"/dev/socket".to_vec(), + ); + proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap() + }; // Abstract names do not touch HostIO, which keeps this wrapper test // focused on the retry's socket-type guard. let addr = [1, 0, 0, b'm', b'i', b's', b's']; let mut host = WasmHostIO; - let mut table = ProcessTable::new(); - table.processes.insert(proc.pid, proc); assert_eq!( - cross_process_unix_connect(&mut table, 9040, &mut host, fd, &addr), + cross_process_unix_connect(&mut table, pid, &mut host, fd, &addr), Err(Errno::ECONNREFUSED), ); } @@ -9406,8 +9407,9 @@ pub extern "C" fn kernel_execve(path_ptr: *const u8, path_len: u32) -> i32 { 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 kernel calls - // (e.g. kernel_get_exec_state) don't deadlock. + // 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(); @@ -9445,6 +9447,7 @@ pub extern "C" fn kernel_execveat( 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(); @@ -9458,21 +9461,6 @@ pub extern "C" fn kernel_execveat( } } -// --------------------------------------------------------------------------- -// fork (guest-initiated) -// --------------------------------------------------------------------------- - -/// Fork the current process. Returns child PID in parent, 0 in child, negative errno on error. -#[unsafe(no_mangle)] -pub extern "C" fn kernel_fork() -> i32 { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let host = WasmHostIO; - match syscalls::sys_fork(proc, &host) { - Ok(pid) => pid as i32, - 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( @@ -9484,10 +9472,10 @@ pub extern "C" fn kernel_clone( tls_ptr: usize, ctid_ptr: usize, ) -> i32 { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - let mut host = WasmHostIO; + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; match syscalls::sys_clone( - proc, &mut host, fn_ptr, stack_ptr, flags, arg, ptid_ptr, tls_ptr, ctid_ptr, + table, fn_ptr, stack_ptr, flags, arg, ptid_ptr, tls_ptr, ctid_ptr, ) { Ok(tid) => tid, Err(e) => -(e as i32), @@ -9659,16 +9647,6 @@ pub extern "C" fn kernel_clear_fork_exec() -> i32 { 0 } -/// Set PID/PPID on an existing process (used by wpk_fork_* instrumentation -/// to update identity after full memory snapshot restoration without full -/// re-init). -#[unsafe(no_mangle)] -pub extern "C" fn kernel_set_child_pid(new_pid: u32) { - let (_gkl, proc, advisory_locks) = unsafe { get_process_and_advisory_locks() }; - proc.ppid = proc.pid; - proc.pid = new_pid; -} - // --------------------------------------------------------------------------- // alarm // --------------------------------------------------------------------------- @@ -10355,13 +10333,55 @@ 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 owner = ((pid as u64) << 32) | tid as u64; let pt = unsafe { &mut *PROCESS_TABLE.0.get() }; - if let Some(proc) = pt.get_mut(pid) { - syscalls::cancel_fifo_open_for_owner(proc, owner); - proc.remove_thread(tid); + match kernel_thread_exit_in_table(pt, pid, tid) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +fn kernel_thread_exit_in_table( + pt: &mut crate::process_table::ProcessTable, + 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(()) +} + +#[cfg(test)] +mod thread_exit_tests { + use super::*; + + #[test] + fn thread_exit_rejects_unknown_or_wrong_owner_and_removes_exact_task() { + 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(); + + 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!(pt.get(first).unwrap().get_thread(tid).is_none()); + assert_eq!( + kernel_thread_exit_in_table(&mut pt, first, tid), + Err(Errno::ESRCH) + ); + assert_eq!( + kernel_thread_exit_in_table(&mut pt, 9_999, tid), + Err(Errno::ESRCH) + ); } - 0 } /// futex — real implementation via host Atomics.wait/notify. diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 3a65328b5f..190b63fc46 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -78,7 +78,13 @@ pub mod host_abi; /// require the host_fcntl_lock import; fork/exec OFD state is versioned. /// 41: main-thread, pthread, and side-module fork continuations reserve 60 KiB /// so valid wide call stacks do not overwrite adjacent host control state. -pub const ABI_VERSION: u32 = 41; +/// 42: process and thread identities come from one kernel-owned allocator, +/// while fork continuations use transactional, dynamically allocated +/// linked chunks instead of a fixed-capacity save buffer. Process creation +/// 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; /// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. /// @@ -1273,6 +1279,144 @@ pub mod abi { /// the host can thread channel / TLS state through fork and exec. pub const PROCESS_EXPECTED_GLOBALS: &[&str] = &["__channel_base", "__tls_base"]; + /// Pointer-sensitive value types used by program-artifact function + /// requirements. `Pointer` resolves to i32 for wasm32 artifacts and i64 + /// for wasm64 artifacts. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum ProgramArtifactValueType { + Pointer, + I32, + } + + /// One required function import in an instrumented program artifact. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct ProgramArtifactImport { + pub module: &'static str, + pub name: &'static str, + pub params: &'static [ProgramArtifactValueType], + pub results: &'static [ProgramArtifactValueType], + } + + /// One required function export in an instrumented program artifact. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct ProgramArtifactExport { + pub name: &'static str, + pub params: &'static [ProgramArtifactValueType], + pub results: &'static [ProgramArtifactValueType], + } + + /// 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 + /// publisher, and Homebrew validator. Keeping the publication contract in + /// Rust-owned ABI metadata makes `dump-abi` record drift instead of + /// allowing a newly instrumented program to publish successfully and fail + /// only when its first `fork()` reaches the host. + pub const WPK_FORK_LINKED_FRAME_FORMAT_SECTION: &str = "kandelo.wpk_fork.linked_frames"; + pub const WPK_FORK_LINKED_FRAME_FORMAT_MAGIC: [u8; 4] = *b"KLCF"; + pub const WPK_FORK_LINKED_FRAME_FORMAT_VERSION: u16 = 1; + pub const WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE: u16 = 24; + pub const WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT: u8 = 8; + pub const WPK_FORK_LINKED_FRAME_FLAG_TRANSACTIONAL_NODES: u16 = 1 << 0; + pub const WPK_FORK_LINKED_FRAME_FLAG_ABORT_UNWINDING: u16 = 1 << 1; + pub const WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS: u16 = + 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]; + + 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_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_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}; + + pub const WPK_FORK_REQUIRED_IMPORTS: &[ProgramArtifactImport] = &[ + ProgramArtifactImport { + module: WPK_FORK_FRAME_IMPORT_MODULE, + name: WPK_FORK_FRAME_IMPORT_COMMIT, + params: &[Pointer], + results: &[], + }, + ProgramArtifactImport { + module: WPK_FORK_FRAME_IMPORT_MODULE, + name: WPK_FORK_FRAME_IMPORT_NEXT, + params: &[Pointer], + results: &[Pointer], + }, + ProgramArtifactImport { + module: WPK_FORK_FRAME_IMPORT_MODULE, + name: WPK_FORK_FRAME_IMPORT_RESERVE, + params: &[Pointer], + results: &[Pointer], + }, + ]; + + pub const WPK_FORK_REQUIRED_EXPORTS: &[ProgramArtifactExport] = &[ + ProgramArtifactExport { + name: WPK_FORK_EXPORT_ABORT_BEGIN, + params: &[Pointer], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_ABORT_END, + params: &[], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_REWIND_BEGIN, + params: &[Pointer], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_REWIND_END, + params: &[], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_STATE, + params: &[], + results: &[I32], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_UNWIND_BEGIN, + params: &[Pointer], + results: &[], + }, + ProgramArtifactExport { + name: WPK_FORK_EXPORT_UNWIND_END, + params: &[], + results: &[], + }, + ]; + + /// Return the version-1 linked-chunk header size for one pointer width. + pub const fn wpk_fork_linked_chunk_header_size(pointer_width: u8) -> Option { + match pointer_width { + 4 => Some(32), + 8 => Some(56), + _ => None, + } + } + + /// Return the version-1 linked-frame-node header size for one pointer + /// width, including the required eight-byte alignment. + pub const fn wpk_fork_linked_node_header_size(pointer_width: u8) -> Option { + match pointer_width { + 4 => Some(24), + 8 => Some(32), + _ => 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 @@ -1378,6 +1522,10 @@ pub mod abi { "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", @@ -1385,12 +1533,20 @@ pub mod abi { "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", ]; @@ -1846,7 +2002,11 @@ pub mod abi { HOST_ADAPTER_MANIFEST, HOST_ADAPTER_MANIFEST_MAGIC, HOST_ADAPTER_MANIFEST_SIZE, 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, extended_syscalls::SYSCALLS, + HOST_ADAPTER_VERSION, HOST_ADAPTER_WORKER_FEATURES, + 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, }; use crate::Syscall; @@ -1936,6 +2096,41 @@ pub mod abi { ); } + #[test] + fn linked_fork_program_artifact_contract_is_complete_and_sorted() { + 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); + assert_eq!(WPK_FORK_LINKED_FRAME_POINTER_WIDTHS, &[4, 8]); + assert_eq!(wpk_fork_linked_chunk_header_size(4), Some(32)); + assert_eq!(wpk_fork_linked_node_header_size(4), Some(24)); + assert_eq!(wpk_fork_linked_chunk_header_size(8), Some(56)); + assert_eq!(wpk_fork_linked_node_header_size(8), Some(32)); + 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); + 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" + ); + previous_import = current; + } + + assert_eq!(WPK_FORK_REQUIRED_EXPORTS.len(), 7); + let mut previous_export = ""; + for requirement in WPK_FORK_REQUIRED_EXPORTS { + assert!( + previous_export < requirement.name, + "fork exports must be sorted and unique" + ); + previous_export = requirement.name; + } + } + fn assert_sorted_unique(items: &[&str]) { let mut prev = None; for item in items { diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 879447f527..e7278d9e10 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -34,7 +34,7 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: (`WasmStat`, `WasmDirent`, `WasmFlock`, `WasmTimespec`, `WasmPollFd`, `WasmStatfs`), or changing a field's type in a way that shifts offsets or span. -- Changing the five `wpk_fork_*` export names or the save-buffer / +- Changing the required `wpk_fork_*` export names or the save-buffer / frame format emitted by [`wasm-fork-instrument`](fork-instrumentation.md) into every fork-using user program. The kernel does not read these exports @@ -125,6 +125,135 @@ rebuilding because the public process-memory layout belongs to ABI 41. ABI 41 candidate programs created before publication remain mechanically valid when only this host-supplied reserve grows and the frame format stays unchanged. +### ABI 42 kernel-owned task identities and scalable fork continuations + +ABI 42 makes the Rust `ProcessTable` the sole authority for process and thread +identities. One monotonically increasing positive signed task-ID sequence starts +at 100 and serves top-level process creation, fork, non-forking `posix_spawn`, +and thread-style clone. IDs are not reused after process reaping or thread exit; +allocating `i32::MAX` succeeds, and only the following allocation returns +`EAGAIN`. PID 1 is created separately as the synthetic init reservation and +never names a user Wasm worker. + +The kernel implementation enforces that ownership with a linear +`AllocatedTaskId`: only `ProcessTable` can mint one, and production `Process` or +`ThreadInfo` construction consumes it. PID, TID, and thread-membership views are +not mutable outside that path. Caller-selected constructors remain test-only, +and fork deserialization restores non-identity state into an already-authorized +child instead of constructing a PID from serialized or host input. These are +internal Rust invariants rather than additional Wasm exports. + +The kernel creation exports now return their assigned identities: +`kernel_create_process()` takes no PID, and +`kernel_create_process_with_stdio(stdin_kind, stdout_kind, stderr_kind)` takes +only stdio kinds. `kernel_fork_process(parent_pid, caller_tid)` takes no child +PID and returns the allocated child. The new +`kernel_spawn_process(parent_pid, caller_tid, blob_ptr, blob_len)` signature +likewise names the already-existing calling task, not a proposed child +identity. The kernel validates that `caller_tid` is the parent's live main task +or one of its live kernel-allocated threads before either operation; an unknown, +stale, or cross-process caller returns `ESRCH`. The caller-selected +`kernel_init(pid)` and `kernel_init_from_fork(..., child_pid)` constructors are +removed. Host `createProcess` asks the kernel for an identity, while +`registerProcess` only attaches memory, channels, and worker metadata to +existing kernel state; no host allocator or task-ID watermark remains. +Thread-style clone likewise validates its bound caller against the owning +process before consuming a task ID. The host adapter manifest and kernel +artifact gates require the create, fork, spawn, exact exec, and thread-exit +exports, so a stale kernel cannot defer a missing authority or lifecycle path +until the first child, exec, or thread exit. + +Exec is an exact-caller two-step operation. The required +`kernel_exec_prepare(pid, caller_tid)` export validates the live task and +applies deferred file actions before the irreversible transition. The required +`kernel_exec_setup_for_thread(pid, caller_tid)` export performs the in-place +exec reset while preserving the calling task's mask and directed signal state. +The required `kernel_thread_exit(pid, tid)` export removes only that process's +exact live thread; unknown, already-exited, and cross-process TIDs return +`ESRCH` rather than falling back to a host-side lifecycle decision. + +Fork and spawn use the validated caller identity to select the calling task's +blocked signal mask. A fork child inherits that mask, and a spawn child inherits +it unless `POSIX_SPAWN_SETSIGMASK` supplies a replacement. The obsolete +`kernel_reset_signal_mask` export is removed; clearing the fork child's mask in +the host would violate pthread-fork semantics. On the child rewind path, libc +refreshes the copied pthread TID from the kernel through `set_tid_address` +before returning from `fork()`. + +Channel identity binding is kernel-validated in the same epoch. +`kernel_set_current_tid(pid, tid) -> 0 | -errno` replaces the former unchecked +one-argument setter. It accepts only the process's main task or a thread that +the same `ProcessTable` has already allocated for that process; a host cannot +invent a TID or bind one process's channel to another process's task. The +read-only `kernel_validate_task(pid, tid)` export lets the host validate channel +registration without installing dispatch authority. Clone callbacks attach a +mailbox by consuming a one-shot host transport proof whose immutable PID/TID +pair comes from that exact kernel clone result. The public attachment path does +not accept a numeric TID, and rejects proof replay, duplicate offsets, duplicate +TID ownership, and attempts to substitute a different valid sibling task. A +successful `kernel_set_current_tid` binding authorizes exactly one +`kernel_handle_channel` call and is cleared after every return. Because +`_exit` intentionally traps instead of returning through the dispatcher, it +clears the binding before trapping. Missing, rejected, stale, or exited task +bindings fail closed with `ESRCH`; no PID-only ambient selector remains. + +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 +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 +`kernel_ipc_shmat_for_process` and `kernel_ipc_shmdt_for_process` exports. +The former `kernel_set_current_pid` export is removed. + +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 +the request through the centralized host, which calls +`kernel_fork_process(parent_pid, caller_tid)` and uses the PID returned by +`ProcessTable`. + +Exact-thread signal delivery is strict in ABI 42. `tkill` and `tgkill` deliver +only to a retained live task record in the calling process. TID 0 and unknown +or exited TIDs return `ESRCH`; they are not reinterpreted as process-wide +signal requests. Cross-process exact-thread delivery remains unsupported. +Machine-wide `kill` target selection, including process groups and `kill(-1)`, +now runs entirely against `ProcessTable`; the former `host_kill` import and +host-side `DeliverSignalMessage` routing path are removed. + +These removals and signature/return-semantics changes, including task +creation, `kernel_set_current_tid`, signal dequeue, child wait, write prepare, +SysV attachment, exact exec, and exact thread exit, are incompatible kernel +Wasm changes. Kernels, hosts, packages, guest binaries, and VFS images from +ABI 41 must be rebuilt rather than mixed with ABI 42 artifacts. +#### Scalable fork continuations + +ABI 42 replaces the fixed-capacity contiguous save buffer with dynamically +mapped linked chunks. Instrumented modules carry the strict version-1 +`kandelo.wpk_fork.linked_frames` descriptor and import +`env.__wpk_fork_frame_reserve`, `env.__wpk_fork_frame_commit`, and +`env.__wpk_fork_frame_next`. The host validates the descriptor, owns chunk +allocation and cleanup, and rejects incomplete or stale instrumentation. + +The transition is incompatible: generated postambles depend on +reserve-before-write and commit-after-write semantics, replay uses a validated +linked-node order, and instrumented modules require the seven-export control +set including `wpk_fork_abort_begin` and `wpk_fork_abort_end`. The old +channel-adjacent area is only an active-root handoff anchor. ABI 41 and older +programs must be rebuilt with the ABI 42 instrumenter and package/VFS artifacts +must be republished for the new ABI epoch. + +Version 1 keeps inherited chunks at the parent's virtual addresses in the +child. Relocating and rebasing a serialized continuation is not part of this +ABI. The linked descriptor requires transactional-node and abort-unwinding +flags. A typed allocation failure before unwind returns its errno directly; a +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. + ## The snapshot `abi/snapshot.json` is generated by `cargo xtask dump-abi` from the @@ -160,9 +289,18 @@ captures: pthread slot page offsets, and the process-wasm thread-slot declaration contract. - `custom_sections` — names of wasm custom sections that participate in - the ABI (currently `wasm-posix-abi` for the per-binary version). + the ABI: `wasm-posix-abi` for the per-binary version and + `kandelo.wpk_fork.linked_frames` for the linked-continuation layout. - `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. + 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 `.wasm`: function signatures (`(params) -> (results)`), global types/mutability, memory + table entries. Toolchain-internal diff --git a/docs/agent-guidance/build-docs-and-prs.md b/docs/agent-guidance/build-docs-and-prs.md index 62b67b21db..0e59b61adf 100644 --- a/docs/agent-guidance/build-docs-and-prs.md +++ b/docs/agent-guidance/build-docs-and-prs.md @@ -152,6 +152,21 @@ Avoid unexplained repository shorthand, internal task names, and descriptions that start with file edits or implementation mechanics. Preserve technical precision while explaining specialized concepts in ordinary language. +### Comment Decisions, Not Syntax + +Add a concise inline `WHY` comment when a reasonable reader can see what the +code does but cannot recover why that design is necessary. This especially +applies to ownership boundaries, ordering constraints, security checks, +cross-host parity, POSIX or ABI invariants, performance-sensitive shapes, and +workarounds at documented compatibility boundaries. + +Place the comment beside the decision it protects and name the failure that a +plausible simplification would reintroduce. Link the authoritative reference +when the full rationale is too large for the code. Do not narrate obvious +syntax, restate a function name, preserve incident chronology, or use comments +instead of tests and enforceable types. If the reason belongs to the public +platform contract, update the authoritative documentation as well. + ### Preserve Contributor Attribution Authorship records contribution; restacking must not transfer it. Before diff --git a/docs/agent-guidance/debugging-and-posix.md b/docs/agent-guidance/debugging-and-posix.md index fe099c7ace..42111490ae 100644 --- a/docs/agent-guidance/debugging-and-posix.md +++ b/docs/agent-guidance/debugging-and-posix.md @@ -61,6 +61,36 @@ Process state is authoritative. `fork`, `exec`, `posix_spawn`, `clone`, `exit`, dispositions, fd tables, OFDs, locks, sockets, PTYs, memory layout, and zombie/reaping state must remain coherent across transitions. +The Rust `ProcessTable` is the sole PID/TID authority. Its one monotonic task-ID +sequence allocates top-level process, fork, spawn, and clone identities; host +code and callbacks may only consume those assigned IDs and attach worker state. +Do not add a host allocator, caller-selected identity, collision-retry loop, or +watermark API. PID 1 remains the kernel-created synthetic init +reservation, outside the user task sequence that starts at 100. +Keep this boundary compile-enforced inside Rust: production process and thread +construction must consume the opaque allocation token minted by `ProcessTable`, +identity fields and thread membership must remain read-only elsewhere, and raw +caller-selected constructors may exist only as `cfg(test)` fixtures. Fork-state +deserialization must populate a process whose identity was already allocated; +it must not accept or construct a child PID independently. +Host-transported caller TIDs must be validated against the parent process's live +kernel task records before fork, spawn, clone, or exact-thread signaling; they +identify existing state and never delegate allocation authority. An unknown, +exited, or +cross-process exact-thread target must fail with `ESRCH`, not fall back to a +process-wide operation. A host channel that cannot bind to a task while its +kernel Process is live is a fatal host/kernel protocol failure; do not turn it +into an ordinary guest `EIO` and continue. Channels retained only while an +already-Exited Process's Workers are being terminated may complete musl's final +exit handshake, but must never dispatch another syscall into that zombie. +Thread-channel attachment must consume a one-shot transport proof bound to the +exact TID returned by that clone allocation. Do not expose a numeric attachment +API that lets host code substitute another valid sibling TID, reuse a proof, or +map one task to multiple mailboxes. +Likewise, the host may publish a Worker crash only after Rust accepts the +signal-death transition, and a trapped kernel exit path must be checked for an +authoritative `Exited` state before the host wakes a parent or reports success. + `fork()` means continuation preservation. If a change touches fork, fork instrumentation, pthread fork, fd/resource inheritance, signal state, or memory copying, verify that the child resumes at the correct call site with correct diff --git a/docs/agent-guidance/validation.md b/docs/agent-guidance/validation.md index 36c7be3c16..2b34f0ced4 100644 --- a/docs/agent-guidance/validation.md +++ b/docs/agent-guidance/validation.md @@ -24,7 +24,7 @@ Core validation surface: | Fork instrument tests | `cargo test -p fork-instrument --target ` | Fork instrumentation/tooling changes | | 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 lazy VFS contract | `cd apps/browser-demos && npx playwright test test/browser-kernel-lazy-registration.spec.ts --project=chromium --project=firefox --project=webkit` | Browser-host lazy VFS registration ordering, including Safari/WebKit | +| 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 | | Browser asset check | `bash scripts/ci-check-browser-assets.sh` | Browser asset/import changes | | musl libc-test | `scripts/run-libc-tests.sh` | libc, syscall, and kernel semantic changes | | Open POSIX Test Suite | `scripts/run-posix-tests.sh` | POSIX API behavior | diff --git a/docs/architecture.md b/docs/architecture.md index d7db2dce0a..674865791a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,8 +15,10 @@ Kandelo is a shared, multi-process POSIX kernel that runs as WebAssembly. A sing │ │ │ ProcessTable │ │ ├─ pid 1 │ - │ ├─ pid 2 │ + │ │ synthetic init│ + │ ├─ pid 100 │ │ └─ pid N │ + │ Task-ID allocator│ │ │ │ Fd tables │ │ Advisory locks │ @@ -29,8 +31,8 @@ Kandelo is a shared, multi-process POSIX kernel that runs as WebAssembly. A sing ┌────────────┼────────────┐ │ │ │ ┌────────┴──┐ ┌─────┴─────┐ ┌──┴────────┐ - │ Worker 1 │ │ Worker 2 │ │ Worker N │ - │ pid=1 │ │ pid=2 │ │ pid=N │ + │ Worker 100 │ │ Worker 101 │ │ Worker N │ + │ pid=100 │ │ pid=101 │ │ pid=N │ │ User Wasm │ │ User Wasm │ │ User Wasm │ │ + musl │ │ + musl │ │ + musl │ │ + glue │ │ + glue │ │ + glue │ @@ -55,7 +57,7 @@ Key source files: | `pipe.rs` | Kernel-space pipe ring buffers with cross-process wakeup | | `pty.rs` | Pseudoterminal pairs with line discipline (canonical/raw mode) | | `process.rs` | Process struct, HostIO trait, per-process state | -| `process_table.rs` | ProcessTable — maps PIDs to Process structs | +| `process_table.rs` | ProcessTable — maps PIDs to Process structs and owns the machine-wide PID/TID allocator | | `signal.rs` | Signal subsystem: masks, handlers, RT queuing, delivery | | `socket.rs` | AF_INET and AF_UNIX socket implementation | | `fork.rs` | Fork/exec state serialization and deserialization | @@ -67,11 +69,24 @@ Key source files: Key kernel exports (called by the host): ``` -kernel_create_process(pid) → 0 -kernel_fork_process(parent_pid, child_pid) → 0 +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_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_exec_setup(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_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_get_cwd(pid, buf, len) → bytes_written kernel_set_max_addr(pid, addr) → 0 kernel_set_brk_base(pid, addr) → 0 @@ -220,8 +235,8 @@ dedicated kernel worker. The kernel worker owns the JavaScript timer because a process worker executing a CPU-bound Wasm loop cannot service its own event loop. At the monotonic deadline the host sets each flag byte with an atomic store in the process's shared memory. Timer entries retain the exact -process-generation object as well as the PID, so exec, exit, or PID reuse -cannot redirect a stale callback into a replacement process. Deadlines beyond +process-generation object as well as the PID, so exec or exit cannot redirect +a stale callback into a replacement process image. Deadlines beyond JavaScript's signed 32-bit timer range are scheduled in bounded chunks. The imported function and its pointer-width-specific signature are part of @@ -332,26 +347,103 @@ generic wake event without implementing advisory-lock storage. ## Multi-Process Model +### Task identity allocation + +The Rust `ProcessTable` is the sole authority for every process ID (PID) and +pthread thread ID (TID) in a kernel instance. One monotonically increasing, +positive signed task-ID sequence serves top-level process creation, `fork()`, +non-forking `posix_spawn()`, and thread-style `clone()`. It starts at 100 and +never reuses an identity, even after a process is reaped or a thread exits. If +the sequence assigns `i32::MAX`, that allocation succeeds; the next allocation +fails with `EAGAIN` instead of wrapping. + +This ownership is enforced inside the Rust type boundary, not only by call-site +convention. Each allocation produces an opaque, non-cloneable `AllocatedTaskId` +that production process or thread construction must consume. Process IDs, +thread IDs, and thread membership are read-only outside that path. Raw numeric +constructors exist only for isolated `cfg(test)` fixtures, and fork +deserialization fills a child record whose identity `ProcessTable` has already +allocated rather than accepting a second PID source. + +PID 1 is outside that sequence. The kernel creates it as a synthetic root-owned +init reservation with no user Wasm worker, so PID-addressed existence and +permission checks have a real kernel target. The first user process is therefore +PID 100, not PID 1. This synthetic record is not a user-space init program or +an active wait-loop reaper. + +Callers never choose an ID or advance a watermark. The host helper +`CentralizedKernelWorker.createProcess(...)` asks the Rust kernel to create a +top-level process and returns the assigned PID. `registerProcess(pid, ...)` +only attaches host memory, syscall channels, and worker metadata to an existing +running or stopped kernel process; it rejects unknown, exited, and synthetic +PID 1 records and cannot create or reserve one. Adding a syscall channel calls +the read-only `kernel_validate_task(pid, tid)`, which accepts only that +process's main task or one of its kernel-allocated threads before the host +records the channel. For a cloned pthread, the callback receives a one-shot +transport proof bound to the exact clone result; `attachThreadChannel` derives +the PID and TID from that proof, rejects replay and duplicate mailbox ownership, +and never accepts a caller-selected numeric identity. Validation does not +install dispatch authority. Immediately before each mailbox call, the host +calls `kernel_set_current_tid(pid, tid)`; +`kernel_handle_channel` consumes and clears that exact pair on every returning +path, while the non-returning `_exit` path clears it before trapping. Transport +misrouting or earlier validation therefore cannot authorize a later dispatch. +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. +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 +against the same live task records before allocating its new thread ID. Fork, +spawn, and clone callbacks likewise receive identities that the Rust kernel has +already allocated. +`exec()` preserves the calling process identity. + +A task-binding rejection while the Process is live is a fatal host/kernel +protocol failure: the host marks the process crashed and terminates its Workers +without returning a synthetic guest error. Rust must accept that signal-death +transition before the host records the process as reaped or wakes its parent; +a missing or rejected transition remains a loud protocol failure. A trap from +the non-returning kernel exit path is also insufficient by itself: the host +verifies the Process is actually `Exited` before publishing a clean exit. +During process exit, Node and browser may briefly retain exact channel objects +until Worker termination completes. +Once Rust has made that Process Exited, those channels can finish only musl's +`exit_group`/`exit` transport handshake; every other late syscall stays parked +and never re-enters kernel state. Final teardown removes all PID-prefixed thread +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)): 1. User calls `fork()` → musl → `__syscall(SYS_clone, ...)` → glue -2. Host's `kernel_fork` override calls `wpk_fork_unwind_begin(buf)`. The tool-injected export sets state to UNWINDING, initializes the absolute frame cursor `current_pos = buf + frames_start_offset` at `*(buf+0)`, and snapshots every mutable scalar global (including `__tls_base` and `__stack_pointer`) into the buffer's `saved_globals[]` area. -3. The return-to-caller chain unwinds; each instrumented function's postamble writes its frame to the buffer and bumps `current_pos`. +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. 4. Once `_start` returns (top-of-stack), the host sends SYS_FORK through the channel. -5. Kernel's `kernel_fork_process` copies process metadata and the fd/OFD tables, - while inherited stateful descriptors retain references to their existing - kernel-global backings. -6. Host copies the parent's linear memory to a new `WebAssembly.Memory` and spawns a child worker. -7. Child worker 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 sees state=REWINDING, reloads its frame, and re-enters the call site where the parent was interrupted. Eventually reaches the `kernel_fork` call site in the leaf function, which returns 0. -9. `wpk_fork_rewind_end` resets state; fork returns 0 in child, child PID in parent. +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. +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. + +If the root continuation mapping cannot be allocated, `kernel_fork` returns the +negative mmap errno before unwind starts. If a later node allocation fails, +the owning module enters `ABORT_UNWINDING`: the live failing activation +restarts at its call site, committed inner nodes replay to the original fork +import, and the host releases the partial chain before returning the negative +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. A fork reached directly inside an instrumented dlopened side module uses two -ordered state machines and two save buffers: side then main during unwind, main +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 five-export fallback, while ABI 18 and later require role claims and reject @@ -397,15 +489,19 @@ 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. -4. The host validates the exec caller and deferred file actions, then publishes - and flushes writable tracked mappings while the old image is still live. +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. - `kernel_exec_setup` then 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 + 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 @@ -439,16 +535,20 @@ 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, copies it to kernel scratch, and calls - `kernel_spawn_process(parent_pid, blob_ptr, blob_len)`. + `kernel_spawn_process(parent_pid, caller_tid, blob_ptr, blob_len)`. 3. Kernel parses the blob (`crates/kernel/src/spawn.rs::parse_blob` — - the trust boundary; bails with EINVAL on any malformed offset) and - calls `ProcessTable::spawn_child`. -4. `spawn_child` allocates the child pid, builds the child Process - from `Process::new(child_pid)` plus selective inheritance from the - parent (uid/gid/pgid/sid/cwd/umask/rlimits, fd_table + ofd_table + + the trust boundary; bails with EINVAL on any malformed offset), validates + `caller_tid` as a live task belonging to the parent, and calls + `ProcessTable::spawn_child_for_caller`. +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 + 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), then applies file actions in forward + 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: @@ -456,9 +556,10 @@ caller now take. `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 Worker is registered with - `skipKernelCreate: true` because the kernel already inserted the - Process; its initialization metadata carries the same parent pid. + `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. PATH search lives in libc (`posix_spawnp.c`); the kernel never sees PATH-relative names. @@ -703,6 +804,18 @@ 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. +For each declared transport, materialization permits three total GET attempts: +only HTTP 408, 429, and 5xx responses or recognized fetch/body network +interruptions repeat the same URL. The two retry waits default to 250 and 500 +milliseconds; a valid `Retry-After` value replaces that wait up to a five-second +cap. A lazy fetcher may register an optional `AbortSignal`; the VFS passes it +to each fetch, aborts a pending retry wait, and checks its exact `reason` +before mirror fallback and namespace commit. That explicit signal preserves +arbitrary `Error` and `TypeError` reasons. Standard `AbortError` and +`ABORT_ERR` shapes remain a compatibility fallback for existing one-argument +fetchers. Permanent HTTP responses and size, digest, decode, inventory, and +commit failures remain truthful failures rather than retry signals. + The generic-tree schema is revalidated through one closed, bounded path at live registration, cross-worker import, image restore, and filesystem rebase. Content, activation, mount prefix, inventory, and pending inode metadata reject @@ -724,6 +837,45 @@ decoding; deferred-tree imports additionally allow at most 512 groups and 100,000 entries per group. A pending metadata-only tree remains valid and must still verify its immutable payload through its activation policy. +Build tooling can derive a package-owned deferred ZIP tree from one exact +declared package output. The reviewable spec names the output, its distribution +role (`source-tree` or `runtime-tree`), mount prefix, owner, and first-use +activation roots. The builder reads the exact ZIP once and derives a canonical +typed-tree descriptor containing its digest, byte counts, decoder, and complete +inventory. A lazy image registers that descriptor and keeps the relative +package-output URL; an eager derivative directly materializes the same +descriptor from the same bytes. The eager path is therefore a consumption +choice, not a second package recipe or artifact identity. A `source-tree` +output, such as a pinned upstream tool implementation, is explicitly not a +Homebrew bottle; formula bottles remain their original published TAR+gzip +artifacts. + +Package ZIP trees declare the closed `portable-posix-v1` mode policy. It +normalizes directories to `0755`, symbolic links to `0777`, and regular files +to `0755` when the ZIP member carries any execute bit or `0644` otherwise. +This prevents host-specific archive modes from changing the installed tree, +and the lazy and eager paths validate and install the same normalized modes. +`host/test/package-deferred-tree.test.ts`, in “derives one canonical descriptor +from the exact package output,” covers the policy with deliberately +non-portable input modes. + +Relative lazy asset URLs are resolved inside the dedicated kernel worker on +both hosts. Browser boots use `BrowserKernel`'s `lazyUrlBase`; Node boots use +the peer `NodeKernelHost.rootfsLazyUrlBase` option. Closed/offline acceptance +can bind the resolved URL to exact caller-owned bytes through the existing +closed-lazy-asset transport. Before kernel boot, that acceptance-only loader +eagerly fetches bounded source URLs, verifies each complete decoded response +against its declared byte count and SHA-256, and only then associates the +bytes with the separate immutable HTTPS URL stored in the deferred tree. A +source URL is transport input, never VFS authority: absolute cleartext HTTP is +limited to loopback acceptance servers, requests omit credentials and +referrers and reject redirects, and source URLs must not contain bearer +secrets. This eager pre-publication proof is not the product tree's first-use +transport. Metadata inspection and directory enumeration do not fetch a +deferred tree. The first prepared open or executable resolution fetches the +whole declared archive once, verifies it, and atomically materializes the +complete group; later accesses do not fetch it again. + ### VFS Images A `MemoryFileSystem` can be serialized to a portable binary image and restored later to boot a new kernel with a pre-populated filesystem. This enables snapshotting an initialized VFS (with all files, directories, symlinks, and permissions) and restoring it without repeating the setup work. @@ -780,7 +932,7 @@ Kandelo browser UI presets use this approach. Each image builder pre-populates a There are two consumption patterns for VFS images, depending on whether the demo wants the kernel worker to fully own the filesystem: -**Kernel-owned VFS (`kernelOwnedFs: true` + `kernel.boot()`).** The main thread never instantiates the `MemoryFileSystem`. Instead, the demo fetches the `.vfs.zst` bytes and hands them to `BrowserKernel.boot({ kernelWasm, vfsImage, argv, env })`. The kernel worker restores the filesystem internally (auto-detecting zstd magic), exec()s `argv[0]` as the first ("init") process, and the main thread becomes a thin client — only routing stdin/stdout, network backend messages, framebuffer events, and HTTP-bridge messages. Service-supervised demos run dinit (`/sbin/dinit --container`) as that init process; dinit reads `/etc/dinit.d/*` from the image and brings up the service tree. Single-program demos (python, perl, php, ruby) exec the language interpreter directly. This is the path new demos should use. +**Kernel-owned VFS (`kernelOwnedFs: true` + `kernel.boot()`).** The main thread never instantiates the `MemoryFileSystem`. Instead, the demo fetches the `.vfs.zst` bytes and hands them to `BrowserKernel.boot({ kernelWasm, vfsImage, argv, env })`. The kernel worker restores the filesystem internally (auto-detecting zstd magic), exec()s `argv[0]` as the first user process, and the main thread becomes a thin client — only routing stdin/stdout, network backend messages, framebuffer events, and HTTP-bridge messages. Service-supervised demos run dinit (`/sbin/dinit --container`) as that service supervisor; dinit reads `/etc/dinit.d/*` from the image and brings up the service tree. Single-program demos (python, perl, php, ruby) exec the language interpreter directly. This is the path new demos should use. **Legacy main-thread-owned VFS (`memfs:` constructor option + `kernel.spawn()`).** The main thread restores the image into its own `MemoryFileSystem`, hands the SAB to a fresh `BrowserKernel`, and then calls `kernel.spawn(programBytes, argv)` to launch transient binaries. Useful for demos that fetch additional binaries at runtime (test runners, REPLs that load arbitrary code), but the main thread is in the syscall hot path for FS operations. Still used by `benchmark`, `erlang`, and `shell`. @@ -977,6 +1129,11 @@ 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. +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 +exact-thread delivery is not yet supported. + POSIX timer scheduling is split at an explicit ownership boundary. The shared Node/browser host owns wall-clock `setTimeout`/`setInterval` scheduling, while the kernel owns the timer object, notification-pending state, exact @@ -1032,14 +1189,14 @@ Main Thread Kernel Worker **`BrowserKernel`** (`host/src/browser-kernel-host.ts`): Main-thread proxy that communicates with the browser kernel worker via `postMessage`. This is host/runtime code, maintained beside the Node.js host (`host/src/node-kernel-host.ts`). Browser apps and demos consume it; they do not own it. The current API has two boot paths: -- `kernel.boot({ kernelWasm, vfsImage, argv, env, ... })` — preferred. Combined with `kernelOwnedFs: true`, the main thread never holds a `MemoryFileSystem` reference. The kernel worker restores the image and exec()s `argv[0]` as the first process. All FS operations stay inside the worker, off the syscall hot path. -- `kernel.spawn(programBytes, argv, opts)` — legacy. Posts the wasm bytes to the kernel worker, which allocates and registers the process, starts its process worker, and then returns the assigned pid in the spawn response. Kept for transient binary launches (REPLs, test runners, benchmarks) that the kernel can't currently load via fork+exec from a baked binary. +- `kernel.boot({ kernelWasm, vfsImage, argv, env, ... })` — preferred. Combined with `kernelOwnedFs: true`, the main thread never holds a `MemoryFileSystem` reference. The kernel worker restores the image and exec()s `argv[0]` as the first user process. All FS operations stay inside the worker, off the syscall hot path. +- `kernel.spawn(programBytes, argv, opts)` — legacy. Posts the wasm bytes to the kernel worker; the Rust `ProcessTable` allocates the PID, then the worker attaches host state, starts the process worker, and returns the assigned PID. Kept for transient binary launches (REPLs, test runners, benchmarks) that the kernel can't currently load via fork+exec from a baked binary. The remaining methods (`pipeRead`/`pipeWrite`, `injectConnection`, stdin/PTY routing, framebuffer registry mirroring, HTTP bridge handoff) are pid-addressed and work the same in both boot paths. **Browser kernel worker** (`host/src/browser-kernel-worker-entry.ts`): Dedicated web worker that hosts `CentralizedKernelWorker`, following the standard architecture requirement. Process workers are sub-workers created by the kernel worker. Syscall notification remains event-driven through `Atomics.waitAsync`, not channel polling. The browser config uses batch size 1 so every relisten and already-`PENDING` dispatch is deferred through the MessageChannel-backed `setImmediate` queue; this keeps syscall handling and worker messages progressing together under multi-process bridge load. Node.js retains its native/default batching unchanged. -**dinit (PID 1)** (`packages/registry/dinit/`): Service-supervised demos boot dinit v0.19.4 (cross-compiled to wasm32) as the first process via `kernel.boot({ argv: ["/sbin/dinit", "--container", ...] })`. The service tree is baked into `/etc/dinit.d/*` at image-build time via `addDinitInit()` in `dinit-image-helpers.ts`. Service types in use: `process` (long-running daemons), `scripted` (one-shot bootstraps that exit cleanly), and `internal` (dependency-only nodes used to express "boot the whole tree" or "pick this engine"). dinit handles SIGCHLD reaping, restarts disabled by default, and inter-service `depends-on` ordering. +**dinit service supervisor** (`packages/registry/dinit/`): Service-supervised demos boot dinit v0.19.4 (cross-compiled to wasm32) as the first user process via `kernel.boot({ argv: ["/sbin/dinit", "--container", ...] })`. It receives the first kernel-allocated user PID (100); PID 1 remains the kernel's synthetic init reservation. The service tree is baked into `/etc/dinit.d/*` at image-build time via `addDinitInit()` in `dinit-image-helpers.ts`. Service types in use: `process` (long-running daemons), `scripted` (one-shot bootstraps that exit cleanly), and `internal` (dependency-only nodes used to express "boot the whole tree" or "pick this engine"). dinit reaps its directly supervised children, leaves reparented-orphan reaping unsupported because synthetic PID 1 has no wait loop, disables restarts by default, and enforces inter-service `depends-on` ordering. **Service Worker** (`apps/browser-demos/public/service-worker.js`): Dual-mode file that acts as both a page bootstrap script (registers itself, enables cross-origin isolation) and a service worker (adds COOP/COEP headers, handles HTTP bridge routing). diff --git a/docs/browser-support.md b/docs/browser-support.md index 8053426827..ef44c63e0c 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -51,15 +51,15 @@ Service Worker ──MessagePort──> Kernel Worker │ ### Key Design Decisions - **Kernel in dedicated worker**: Browser syscall notification remains event-driven through `Atomics.waitAsync`; it does not poll channels. The browser config uses batch size 1 so every relisten and already-`PENDING` dispatch is deferred through the MessageChannel-backed `setImmediate` queue, allowing syscall handling and worker messages to keep progressing together under multi-process bridge load. Node.js keeps its native/default batching unchanged. -- **Kernel-owned VFS** (preferred path, `kernelOwnedFs: true` + `kernel.boot()`): the kernel worker restores a pre-built VFS image and exec()s `argv[0]` as the first process. The main thread never instantiates a `MemoryFileSystem` and is not in the FS hot path. Service-supervised demos run dinit (PID 1) inside this image; single-program demos exec the language interpreter directly. +- **Kernel-owned VFS** (preferred path, `kernelOwnedFs: true` + `kernel.boot()`): the kernel worker restores a pre-built VFS image and exec()s `argv[0]` as the first user process. The main thread never instantiates a `MemoryFileSystem` and is not in the FS hot path. Service-supervised demos run dinit under the first kernel-allocated user PID (100); PID 1 remains the kernel's synthetic init reservation. Single-program demos exec the language interpreter directly. Browser harnesses that must stage a transient file between process spawns use `BrowserKernel`'s worker RPC methods (`readFileSnapshotFromVfs`, `writeFileToVfs`, and `unlinkFileFromVfs`). The owning worker performs those mutations through the mounted VFS; the main thread never receives the live VFS `SharedArrayBuffer`. -- **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 kernel worker allocates and returns its pid so top-level spawns and guest forks share one authoritative sequence. +- **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. - **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 (PID 1) 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 handles SIGCHLD reaping, `depends-on` ordering, and bootstrap-then-daemon chains. 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. - **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 @@ -219,10 +219,10 @@ Located in `apps/browser-demos/pages/`: | doom | fbDOOM | legacy spawn | `/dev/fb0` framebuffer + canvas renderer + keyboard via stdin + mouse via `/dev/input/mice` (pointer-locked) + SFX **and** OPL2-synthesized music via `/dev/dsp` → AudioContext. The shareware `doom1.wad` is **fetched at page load** from a commit-pinned CDN URL (SHA-256 verified, Cache API cached); no IWAD ships in the package archive. | The "Boot pattern" column reflects how the demo enters the kernel: -- **`kernel.boot`** — `kernelOwnedFs: true`, exec the language interpreter as the first process. -- **dinit** — `kernelOwnedFs: true`, exec dinit (PID 1), which brings up the per-demo service tree. +- **`kernel.boot`** — `kernelOwnedFs: true`, exec the language interpreter as the first user process. +- **dinit** — `kernelOwnedFs: true`, exec dinit as the first user process (PID 100), which brings up the per-demo service tree; PID 1 remains synthetic. - **dinit + spawn** — dinit boots the supervised services; the page spawns transient binaries (e.g. mysqltest) via `kernel.spawn()`. -- **legacy spawn** — main thread restores a `MemoryFileSystem`, page calls `kernel.spawn(programBytes, argv)` for each binary, and the kernel worker allocates the pid. +- **legacy spawn** — main thread restores a `MemoryFileSystem`, page calls `kernel.spawn(programBytes, argv)` for each binary, and the Rust kernel allocates the PID before the worker launches it. Run the browser app: `cd apps/browser-demos && npm run dev`, then open `http://127.0.0.1:5401/`. @@ -307,7 +307,16 @@ file can be created. `saveShellDerivedVfsImage()` rejects a product build unless at least 64 MiB of data blocks and 8,192 inode slots remain after its immutable contents are written. This makes runtime allocation space a checked artifact contract instead of allowing an image to build successfully and then -fail with `ENOSPC` during normal browser initialization. +fail with `ENOSPC` during normal browser initialization. The shared save helper +also requires the serialized artifact's encoded growth ceiling to equal the +768 MiB product profile. A future product that intentionally needs a larger +reviewed profile must pass that exact ceiling explicitly rather than silently +drifting from its browser consumer; an override cannot select a smaller +profile. The Homebrew main-shell composer applies the same serialized-ceiling +check against its selected `--max-bytes` contract before it creates the output +artifact. Host-tree copies fail the build on any read or VFS write error. +Intentional omissions are declared through the copy helper's `exclude` option, +and every unexcluded symlink must be preserved explicitly or the build fails. ```typescript // Typical demo pattern @@ -459,7 +468,7 @@ 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 exact reviewed 38-Formula public Homebrew bottle closure, compatibility links, profile, and image-owned Homebrew Bash | +| Shell | `shell.vfs.zst` | `./run.sh build shell-vfs` | platform base plus the exact reviewed 42-Formula public Homebrew bottle closure, compatibility links, profile, and image-owned Homebrew Bash | | Node | `node-vfs.vfs.zst` | `bash images/vfs/scripts/build-node-vfs-image.sh` | npm 10.9.2 dist + writable `/work` | | 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 | @@ -524,8 +533,18 @@ Registration, `stat`, and `readdir` do not fetch it. The first ordinary open/read, mapping, or executable resolution downloads and verifies the whole owning bottle; transports are tried in descriptor order until one passes the same digest and size identity, and all members are bounded, decoded, and -verified before one identity-guarded batch commit. There is no per-file or -byte-range retrieval inside the gzip/TAR. A failed fetch, digest, +verified before one identity-guarded batch commit. Each transport gets at most +three total GET attempts. The same URL is retried only for HTTP 408, 429, or +5xx responses and recognized fetch/body network interruptions, with 250/500 ms +backoff unless `Retry-After` requests a delay capped at five seconds. A custom +fetcher may register an `AbortSignal` alongside its existing one-argument +callback. The host passes that exact signal into every attempt, makes retry +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. +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 retryable. Hard-link inventory members are restored as names of the same inode, including across VFS image save/restore. A metadata-only tree remains deferred @@ -553,13 +572,13 @@ original error. Failed and superseded boots then run the same bounded WebKit reclamation pass used after kernel teardown, so repeated failures do not leave untracked staged images on the persistent main thread. -The Homebrew collection producer emits one candidate tree per selected Formula -and keeps that Formula's finalized bottle `.tar.gz` byte-for-byte as the tree -payload. Its closed schema can represent the production shell's 32 requested -roots under the shared 128-request bound, but Phase 3 calls +The Homebrew collection producer emits one tree per selected Formula and keeps +that Formula's finalized bottle `.tar.gz` byte-for-byte as the tree payload. +Its closed schema represents the production shell's 36 requested roots under +the shared 128-request bound, but the canonical shell calls `buildHomebrewOriginalBottleCollection` directly; it does not publish or boot -that collection as one multi-root runtime layer. The later shell composer -chooses the embedded/deferred partition. A +that collection as one multi-root runtime layer. The shell composer chooses +the embedded/deferred partition. A complete source inventory describes every TAR member. A separate guest projection binds those members to the keg, reviewed link-manifest copies, the builder-owned `opt` link, ownership, modes, and hard-link inode groups. @@ -605,9 +624,10 @@ ephemeral flags, credentials in the URL, or non-root target paths. No Perl, Python, or Erlang layer URL is built into the browser. Concrete entries require immutable published descriptor/content identities derived from their finalized bottle sidecars; missing or mismatched identities fail boot -instead of falling back to a standalone language VFS. This substrate does not -change the main-shell composition: the Bash-plus-required-closure embedding and -any default-shell cutover remain explicit later producer decisions. +instead of falling back to a standalone language VFS. The canonical main shell +uses the same substrate directly: Bash and its required closure are embedded, +the remaining reviewed Formulae are registered as bottle-backed deferred trees, +and the image-owned default-shell contract selects the embedded Bash. That direct release proves only its configured acceptance image; it does not set generic package browser flags. The separate gallery path first boots a diff --git a/docs/compromising-xfails.md b/docs/compromising-xfails.md index 7497fe528f..0dd710b397 100644 --- a/docs/compromising-xfails.md +++ b/docs/compromising-xfails.md @@ -30,7 +30,7 @@ This file is the counterpart to [wasm-limitations.md](wasm-limitations.md), whic - `pthread_create-oom`: **not a kernel gap** — see "Not compromising" table below. The kernel correctly caps address space and `pthread_create` returns `EAGAIN` when `mmap` fails; the test's `t_memfill` setup sequence (specifically the `while (malloc(1));` drain) doesn't terminate within the 30 s timeout in our 1 GiB wasm arena. **Closed:** -- `signal/pthread_kill`, `raise-race`: **per-thread signal routing landed.** `ThreadInfo` now carries its own pending/blocked/rt_queue; `tkill`/`tgkill` deliver to the target thread's directed queue; `pthread_sigmask` / `sigsuspend` / `ppoll` / `pselect` / `sigtimedwait` all operate on the calling thread's state via `kernel_set_current_tid`. +- `signal/pthread_kill`, `raise-race`: **per-thread signal routing landed.** `ThreadInfo` now carries its own pending/blocked/rt_queue; `tkill`/`tgkill` deliver only to a kernel-validated live target's directed queue and return `ESRCH` for TID 0 or an unknown/stale target; `pthread_sigmask` / `sigsuspend` / `ppoll` / `pselect` / `sigtimedwait` all operate on the calling thread's state via `kernel_set_current_tid`. **Root cause (fixed):** `__NR_exit_group` was aliased to `__NR_exit` (both = 34) in the wasm syscall headers. When a non-main thread called `exit()` / `_Exit()` → `SYS_exit_group`, it emitted syscall 34. The host's channel dispatcher saw syscall 34 from a non-main channel and ran the *thread-exit* path (remove channel only), leaving the main process worker to spin forever. Tests that called `exit(0)` from a spawned thread therefore hung. @@ -127,7 +127,7 @@ And a matching `/etc/group`. Low-risk change — pure userspace + VFS. ### 6. Per-thread signal masks (blocks AIO and `pthread_kill`) — *CLOSED* -**Status (landed in this PR):** Per-thread `blocked` / `pending` / `rt_queue` now live on `ThreadInfo`. `kernel_set_current_tid` lets `sigprocmask`, `sigsuspend`, `ppoll`, `pselect6`, `sigtimedwait` operate on the calling thread's state. `tkill`/`tgkill` write into the target thread's directed pending queue rather than the shared process queue. `ABI_VERSION` bumped to 4 (new kernel exports — see `abi/snapshot.json`). +**Status (landed in this PR):** Per-thread `blocked` / `pending` / `rt_queue` now live on `ThreadInfo`. The feature first added `kernel_set_current_tid` in ABI 4 so `sigprocmask`, `sigsuspend`, `ppoll`, `pselect6`, and `sigtimedwait` operate on the calling thread's state. As of ABI 42 the export takes `(pid, tid)` and validates that the TID belongs to that kernel process before binding a channel. `tkill`/`tgkill` write only into a live target thread's directed pending queue; TID 0 and unknown or exited targets return `ESRCH` instead of falling back to the process-wide queue. **Closed tests:** libc-test `regression/raise-race` (previously flakey XFAIL; now passes — timing-slow so it can appear as `TIME` on heavily-loaded runs, still acceptable per `CLAUDE.md`), sortix `signal/pthread_kill`, sortix `basic/aio/aio_fsync`, sortix `basic/aio/aio_read`, sortix `basic/aio/aio_error` (after `sys_pread` / `sys_pwrite` reject negative offsets with `EINVAL`, 2026-04-22 — the test's `aio_write(offset=-9000)` used to surface the host's seek error as `EIO`). @@ -163,22 +163,21 @@ Verified by adding `debug_log` calls in `kernel_kill_with_value`, `sys_sigsuspen If the caller inserts even a short `nanosleep` between `aio_read` and `sigsuspend`, SIGUSR1 gets delivered correctly to main because main is running at the moment delivery picks a thread. Sortix's test has no such delay, so it's the realistic failure mode. -**Fix approach:** - -1. Move `blocked` and `sigsuspend_saved_mask` off `SignalState` / `Process` and onto `ThreadInfo` (or a new per-thread slot keyed by `(pid, tid)`). Keep `pending` and `rt_queue` process-wide — signals are generated at the process, not the thread. -2. `get_process()` plus the current channel must surface the *current thread*, so that `sys_sigprocmask` / `sys_sigsuspend` / `sys_rt_sigpending` read-and-write the caller's own `blocked` mask. The channel already threads `channel.pid` through `kernel_set_current_pid`; add a similar `set_current_tid` and read it in those syscalls. -3. Signal delivery logic (`kernel_dequeue_signal`, `deliver_pending_signals`) must pick a thread whose `blocked` mask does *not* block the signal, with a preference for a thread currently parked in `sigsuspend`/`sigtimedwait`/`pselect6`/`ppoll` on that signal. Today we just dequeue into whichever channel next completes a syscall — that's what sends the AIO signal to the worker. -4. `sys_pthread_kill(tid, sig)` should target a specific thread's pending queue rather than the process-wide queue. -5. Fork: a single thread survives fork; only that thread's mask is inherited. Existing code resets the process mask on fork (`kernel_reset_signal_mask`) — needs to become per-thread. - -Non-trivial refactor. Estimated ~600–900 LoC across `signal.rs`, `process.rs`, `wasm_api.rs`, `syscalls.rs`, plus matching host-side `currentHandleTid` plumbing in `kernel-worker.ts`. All existing tests that exercise the single-threaded path must continue to pass — the main thread's mask semantics are load-bearing for nginx, PHP-FPM, MariaDB signal handling, etc. - -**Starting files:** -- `crates/kernel/src/signal.rs` — `SignalState` split: per-thread (`blocked`, saved masks) vs per-process (`pending`, `rt_queue`, `actions`). -- `crates/kernel/src/process.rs` — `ThreadInfo` struct: add `blocked`, `sigsuspend_saved_mask`, per-thread alt-stack depth (already multi-thread capable?). -- `crates/kernel/src/wasm_api.rs` — sigprocmask/sigsuspend/rt_sigpending must look up the current thread; `kernel_dequeue_signal` must choose a thread whose mask permits the signal. -- `host/src/kernel-worker.ts` — add `currentHandleTid` alongside `currentHandlePid`; plumb it into `set_current_tid` before every `kernel_handle_channel` call. -- Reference: `host/src/kernel-worker.ts` line ~4869 (`callbacks.onClone(..., channel.memory)`) — thread channels are already per-tid; the channel→tid mapping is the hook to add. +**Landed implementation:** + +1. `blocked`, saved masks, directed pending state, and realtime queues live on + each kernel-owned task record, while process-directed pending state remains + shared. +2. ABI 42 validates and binds the exact `(pid, tid)` for each channel dispatch. + `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. +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. **Why AIO does not need its own target.** Once per-thread masks work, stock musl AIO passes the three sortix tests unmodified. No AIO-specific code is required in our kernel. Any attempt to "implement AIO natively" (kernel io_uring shim, etc.) would be wasted effort — the bug is strictly in the signal subsystem. diff --git a/docs/fork-instrumentation.md b/docs/fork-instrumentation.md index 172b9680f4..0c87674ddb 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: `39` (see +ABI version: `42` (see [`crates/shared/src/lib.rs`](../crates/shared/src/lib.rs) — see [abi-versioning.md](abi-versioning.md) for the policy). @@ -48,7 +48,7 @@ ABI version: `39` (see Every instrumented module carries a single mutable i32 global, `_wpk_fork_state`, and one mutable pointer global, `_wpk_fork_buf` (i32 for wasm32 programs, i64 for wasm64). The pointer is zero while the state is `NORMAL` and holds the -address of the active save buffer otherwise. +address of the active root chunk's module prefix otherwise. ``` wpk_fork_unwind_begin(buf) @@ -66,9 +66,10 @@ address of the active save buffer otherwise. - `NORMAL` — ordinary execution. Gated ops and gated calls run normally. - `UNWINDING` — the stack is being torn down. Each instrumented function runs - its postamble, writes a frame into the save buffer, and returns a default - value; the runtime-exported `wpk_fork_unwind_end` is called once the top of - the stack is reached. + its unwind-only call-site bridge, reserves a complete linked node before the + first frame write, then runs its postamble to finish the payload, commit the + node, and return a default value; the runtime-exported + `wpk_fork_unwind_end` is called once the top of the stack is reached. - `REWINDING` — the stack is being rebuilt from saved frames. Each instrumented function loads its frame and jumps straight to the matching call site via switch-dispatch. Body chunks before the chosen post-call @@ -80,7 +81,7 @@ The host drives the state machine externally. User code never writes to ## Exported ABI -The tool injects five exports into every instrumented module. Names are +The tool injects seven exports into every instrumented module. Names are exact — they are part of the kernel ABI and tracked by the snapshot check (see [abi-versioning.md](abi-versioning.md)). @@ -106,11 +107,39 @@ wpk_fork_rewind_end() -> () Precondition: state == REWINDING and all frames have been reloaded. Postcondition: state := NORMAL +wpk_fork_abort_begin(buf: ptr) -> () + Precondition: state == UNWINDING after a typed frame-allocation failure. + Postcondition: state := ABORT_UNWINDING + _wpk_fork_buf := buf + All saved mutable scalar globals restored from buf. + +wpk_fork_abort_end() -> () + Precondition: state == ABORT_UNWINDING and all committed inner frames + have been reloaded. + Postcondition: state := NORMAL + wpk_fork_state() -> i32 Returns current state. Exported for host-side assertions. ``` -The five exports identify the state-machine ABI, but they do not prove which +ABI 42 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. + +__wpk_fork_frame_commit(payload: ptr) -> () + Publishes the pending node after all payload and reference writes complete. + +__wpk_fork_frame_next(expected_frame_size: ptr) -> ptr + Returns the next committed payload during rewind and rejects size/order + mismatches before generated code reads it. +``` + +The control exports identify the state-machine ABI, but they do not prove which import seeded call-graph discovery. The tool therefore also emits the custom section `kandelo.wpk_fork.capabilities`. Its two-byte payload is `[version, flags]`; version 1 defines: @@ -145,34 +174,37 @@ advance `ABI_VERSION` and regenerate `abi/snapshot.json` atomically. tool picks the pointer width from the module's primary memory — a memory64 memory yields `i64`, anything else yields `i32`. -Important Phase 7 behavior: `wpk_fork_unwind_begin` self-initializes -`*(buf + 0)` with the absolute address `buf + frames_start_offset` before -touching any user state. The host does **not** need to pre-seed the buffer -header — it only needs to allocate a buffer at least as large as the -instrumented module's `frames_start_offset` plus its worst-case frame-data -footprint. +`wpk_fork_unwind_begin` self-initializes `*(buf + 0)` with the address of the +first byte after the fixed prefix before touching user state. During linked +unwind and rewind, generated code overwrites that word with the payload address +returned by the corresponding host hook. The host allocates the root chunk and +passes `root + chunk_header_size` as `buf`; no caller computes or preallocates a +worst-case frame-data footprint. ## Host Threading Contract -The save buffer belongs to the channel that issued `SYS_FORK`. For a main-thread -fork this is the process worker's channel, and the child enters `_start` before +The continuation belongs to the channel that issued `SYS_FORK`. For a +main-thread fork this is the process worker's channel, and the child enters `_start` before `wpk_fork_rewind_begin` replays to the saved call site. -`current_pos` is an absolute linear-memory address inside that channel's save -buffer, not an offset from address zero. This is load-bearing for pthreads: -thread instances share linear memory, so relative frame addresses would make -simultaneous fork unwinds overwrite one process-wide low-memory payload even -though their buffer headers are distinct. +Each process worker or pthread worker owns a separate host-side continuation +object. This is load-bearing for pthreads: thread instances share linear +memory, but separately allocated mappings and per-worker replay cursors prevent +their unwinds from sharing frame storage. For `fork()` from a pthread worker, the host must preserve the pthread entry context as well as the buffer: -- `CentralizedKernelWorker.addChannel(pid, offset, tid, fnPtr, argPtr)` records - the pthread entry table index and userdata for each thread channel. +- `CentralizedKernelWorker` creates a host-side, one-shot + `ThreadChannelAttachment` bound to the kernel's exact clone result. + `attachThreadChannel(attachment, offset)` records that kernel-assigned + identity, pthread entry table index, and userdata for the thread channel; + host code cannot provide or substitute a PID/TID. - `centralizedThreadWorkerMain` overrides `kernel_fork` for instrumented modules and drives `wpk_fork_unwind_begin` / `wpk_fork_state` / `wpk_fork_rewind_begin` around the pthread function, using - `channelOffset - FORK_BUF_SIZE` as that thread's save buffer. + a dynamically mapped root chunk. `channelOffset - FORK_BUF_SIZE` now stores + only the active root address used by the kernel-worker fork handoff. - `handleFork` passes a `ForkFromThreadContext` through the host `onFork` callback. Node and browser hosts copy `forkBufAddr`, `fnPtr`, and `argPtr` into the child init message. @@ -212,7 +244,7 @@ into one side-module instance whose call stack reaches `env.fork`: 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 fork save buffer and versioned side-entry capability. + 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. @@ -249,65 +281,69 @@ already contains live TLS, and reinitialization would overwrite C++ landing-pad and application `thread_local` state. TLS-relative exports relocate from that base, while `__tls_size` and `__tls_align` remain scalar constants. -Every participating module uses the fixed 60 KiB save-buffer limit -described below. A dynamically allocated side buffer avoids overlap with the -main control slab but does not make deep/unbounded frame use safe. Before the -worker sends `SYS_FORK`, it checks both the main module's buffer and the active -side module's independent buffer; either overrun terminates the process instead -of creating a child from corrupted continuation state. +Every participating module owns an independent linked continuation. Side and +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`. ## Save buffer format -All offsets are byte-exact, all values little-endian. `P` is pointer width -(4 on wasm32, 8 on wasm64). `N` is the total byte size of the module's saved -scalar globals — fixed per module at instrument time. `B` is the total byte -size of the B1 plain-catch scratch region, fixed per module at instrument -time and 0 in modules that do not contain plain-catch capture sites. - -| Offset | Size | Field | Purpose | -|-------------------|------|-----------------------|----------------------------------------| -| `+0` | `P` | `current_pos` | Absolute address of next frame byte | -| `+P` | `P` | `end_pos` | Reserved; not read or written today | -| `+2P` | `N` | `saved_globals[]` | Mutable scalar globals, decl. order | -| `+2P + N` | `B` | `b1_scratch[]` | Plain-catch operand stash (see B1) | -| `+2P + N + B` | var | frame data | Frames grow upward from here | - -`frames_start_offset = 2P + N + B`. It is exposed as metadata on the tool's -internal `Runtime` struct, and `wpk_fork_unwind_begin` writes -`buf + frames_start_offset` into `*(buf + 0)` on every invocation. - -After an unwind, the host compares this absolute `current_pos` with the explicit -`buf + FORK_SAVE_BUFFER_SIZE` bound before sending `SYS_FORK`. Main-process and -pthread buffers sit next to their syscall channels; a fork-capable side module -has a separately allocated buffer recorded in its active-fork state. A cursor -beyond either end means frame writes crossed that module's reserved boundary. -The process fails with the required and reserved byte counts instead of -creating a child from corrupted continuation state. This is detection, not -prevention: the instrumented unwind has already written past the fixed reserve, -so the host discards the process and its memory. Increasing the reserve again -or making it elastic would be another ABI layout change. - -ABI 41 places the 60 KiB main and pthread buffers at the top of a dedicated -64 KiB scratch page. The lower 4 KiB remains host-owned control space; current -dlopen archive, active-side-fork, and arbitration slots consume at most 40 -bytes of it. The exact Homebrew candidate Bash child measured 49,232 bytes -(192 bytes of fixed header/global/plain-catch state plus 49,040 bytes of live -frames), leaving 12,208 bytes below the ABI 41 bound. - -For wasm32 (`P = 4`) with a module that declares three additional scalar -mutable globals totaling 16 bytes (e.g. `__stack_pointer`, `__tls_base`, one -user i64) and one fork-path function with a single `(catch $tag (param i32))` -arm (16-byte scratch tuple after 8-byte alignment): - -``` -+0 4 current_pos -+4 4 end_pos (reserved) -+8 4 saved __stack_pointer (i32) -+12 4 saved __tls_base (i32) -+16 8 saved user i64 global -+24 16 b1 scratch (1 slot) -+40 frames start here -``` +All values are little-endian and all records are eight-byte aligned. `P` is +pointer width (4 on wasm32, 8 on wasm64). Instrumented modules carry exactly +one 24-byte `kandelo.wpk_fork.linked_frames` custom section. Version 1 contains +the `KLCF` magic, descriptor size, pointer width, alignment, transactional-node +flag, chunk-header size, node-header size, and module-specific fixed-prefix +size. The host validates every field before instantiation. + +Continuation storage consists of page-rounded anonymous process mappings. The +root starts with a chunk header, followed by the module's fixed prefix. Later +chunks contain only a chunk header and nodes. Version-1 chunk headers are: + +| Offset | Size | Field | Purpose | +|---|---:|---|---| +| `+0` | 4 | magic | `KFCH` | +| `+4` | 2 | version | Linked format version | +| `+6` | 2 | flags | Zero in version 1 | +| `+8` | `P` | root | Root chunk address | +| `+8+P` | `P` | previous | Previous chunk, or zero | +| `+8+2P` | `P` | next | Next chunk, or zero | +| `+8+3P` | `P` | capacity | Mapped byte length | +| `+8+4P` | `P` | used | First unused byte | +| `+8+5P` | `P` | committed tail | Newest committed node; meaningful on root | + +The module prefix retains the runtime's pointer word, reserved pointer word, +saved scalar globals, B1 plain-catch scratch, and a 16-byte abort selector. +`frames_start_offset = 2P + N + B` identifies the selector, while the +host-visible fixed-prefix size is `frames_start_offset + 16`. Frame nodes are +not stored in that prefix. + +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 +instrumentation or whole-stack prepass is required. When the active chunk does +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 +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 +committed inner nodes replay back to the original fork import. The import ends +abort replay, unmaps every owned chunk, restores `NORMAL`, and returns the +negative errno. Invalid metadata, impossible transitions, and cleanup failures +remain fatal integrity errors. + +A root allocation failure occurs before `wpk_fork_unwind_begin` and therefore +returns its negative errno without replay. A negative `SYS_FORK` result after a +complete unwind uses the ordinary parent rewind and is likewise returned to +the guest; neither case terminates the parent or creates a child. + +The child receives the mappings through the normal process-memory copy and the +kernel's inherited mmap metadata, at the same virtual addresses in version 1. +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. The `b1_scratch` region is empty (`B == 0`) when no fork-path function in the module has a plain (non-`_ref`) catch arm — this is the case for every @@ -319,8 +355,12 @@ a future extension. The tool currently ignores them when snapshotting globals. ## Frame format -Each instrumented function reserves a fixed-size frame. The size depends on -how many scalar user locals the function has, but the header is uniform. +Each instrumented function has a statically known payload size. The size +depends on how many scalar user locals the function has, but the payload header +is uniform. Each payload is preceded by a linked-node header: `KFCN` magic, +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 | |--------|------|-------------------|------------------------------------------| @@ -348,10 +388,10 @@ resume](#catch-handler-resume). Every fork-path function uses **one of two dispatch shapes**, chosen by the tool per-function based on call-site topology: -| Scheme | When picked | How REWIND reaches the resumed call | +| Scheme | When picked | How replay reaches the resumed call | |------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| switch-dispatch (top-level) | Every fork-path call lives at the function's top level. Top-level operand-stack carryovers (values pushed before the call's args and consumed after — common in LLVM `*(sp+K) = call(...)` patterns) are absorbed via per-call spill locals (sub-commit 2.4c). Pure scalar call-argument tails can be replayed instead of spilled. | A top-level `br_table`, gated by `state == REWINDING`, jumps directly to the matching `$POST_K` label. The chunks between calls run only on the NORMAL fall-through path; carryover spill locals are reloaded in the post-call, followed by spilled or replayed call args. | -| switch-dispatch (nested) | Some fork-path calls live inside `Block` / `IfElse` / `Loop` / `TryTable` bodies. Sub-commits 2.5/2.6 made this scheme cover: direct-call carryovers at any nesting depth (2.5c), nested-Loop-with-carryover (2.5c side benefit), multi-value-params SubRegion bodies via body-input-param prespill (2.6c). Pure scalar direct-call args and condition-only `IfElse` carryovers can be replayed instead of spilled. | Cascading `POST_K` blocks plus a per-region `br_table` route REWIND through each enclosing instruction's own dispatch — see [Nested per-block switch-dispatch](#nested-per-block-switch-dispatch). For multi-value-params bodies, the body's input params are pre-spilled at body entry and reloaded inside POST_0 to bridge the `Simple(None)` POST_K typing. | +| switch-dispatch (top-level) | Every fork-path call lives at the function's top level. Top-level operand-stack carryovers (values pushed before the call's args and consumed after — common in LLVM `*(sp+K) = call(...)` patterns) are absorbed via per-call spill locals (sub-commit 2.4c). Pure scalar call-argument tails can be replayed instead of spilled. | A top-level `br_table`, gated by `state >= REWINDING`, jumps directly to the matching `$POST_K` label for ordinary or abort replay. The chunks between calls run only on the NORMAL fall-through path; carryover spill locals are reloaded in the post-call, followed by spilled or replayed call args. | +| switch-dispatch (nested) | Some fork-path calls live inside `Block` / `IfElse` / `Loop` / `TryTable` bodies. Sub-commits 2.5/2.6 made this scheme cover: direct-call carryovers at any nesting depth (2.5c), nested-Loop-with-carryover (2.5c side benefit), multi-value-params SubRegion bodies via body-input-param prespill (2.6c). Pure scalar direct-call args and condition-only `IfElse` carryovers can be replayed instead of spilled. | Cascading `POST_K` blocks plus a per-region `br_table` route ordinary or abort replay through each enclosing instruction's own dispatch — see [Nested per-block switch-dispatch](#nested-per-block-switch-dispatch). For multi-value-params bodies, the body's input params are pre-spilled at body entry and reloaded inside POST_0 to bridge the `Simple(None)` POST_K typing. | A third path — **guard-dispatch** — existed before commits 3-4 of the fork-instrument mega-PR (2026-05-14). It wrapped each fork-path call site @@ -414,8 +454,8 @@ The tool applies a per-function transform that depends on the dispatch scheme described above. The following pairs show representative fixtures from `crates/fork-instrument/tests/instrument.rs` and `crates/fork-instrument/tests/switch_dispatch.rs`. The transformed WAT is -simplified for readability; the actual output includes `current_pos` -bumping, default values for result types, and preserved source locations. +simplified for readability; the actual output includes linked-node hook calls, +default values for result types, and preserved source locations. > **Note (post-commit-4):** Examples (a) and (c) below describe the > pre-2.5/2.6 guard-dispatch shape, which was deleted in commit 4 of @@ -447,8 +487,8 @@ After instrumentation (abridged): ;; [1] Preamble: if REWINDING, load our frame and jump to matching call. (if (i32.eq (global.get $_wpk_fork_state) (i32.const 2 (; REWINDING ;))) (then - ;; Move current_pos back to this frame, then restore frame fields - ;; and locals. call_index remains in the frame header. + ;; Request the next committed payload, then restore frame fields and + ;; locals. call_index remains in the frame payload header. ...)) ;; [2] Body wrapper: runs on NORMAL; on REWINDING, dispatch jumps to @@ -474,9 +514,8 @@ After instrumentation (abridged): (br $unwind_save))) (return)) - ;; [5] Postamble: write remaining frame header fields, serialize locals, - ;; bump current_pos, then return a default value for the function's - ;; result type. + ;; [5] Postamble: finish writing the already-reserved node's frame header + ;; and serialized locals, commit it, then return a default result. ... (return (i32.const 0))) ``` @@ -484,25 +523,26 @@ After instrumentation (abridged): Numbered callouts: 1. **Preamble (Phase 4d).** Every instrumented function opens with a state - test. Under `REWINDING`, the preamble reads `current_pos`, locates the - frame at `current_pos - frame_size`, stores that frame base back into + test. Under `REWINDING`, the preamble calls + `__wpk_fork_frame_next(frame_size)`, stores the returned payload in `*(buf + 0)`, and deserializes each scalar user local. Dispatch reads - `call_index` directly from that active frame header. + `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 the selected post-call landing. On `NORMAL`, dispatch falls through and executes the original chunks. 3. **Wrapped call site (Phase 4c).** The original call is kept intact. After - the call returns in `UNWINDING`, the tool writes the call site's - `call_index` to `frame[+4]`. + the call returns in `UNWINDING`, the tool reserves the function's complete + frame node before its first write, stores the returned payload pointer in + `*(buf + 0)`, and writes the call site's `call_index` to `frame[+4]`. 4. **Unwind bridge (Phase 4c/4d).** The unwind-only branch writes `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 each scalar user local, bumps - `*(buf + 0)` by the frame size, 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, exnref_slot), writes each scalar user local, + 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 @@ -678,7 +718,7 @@ end ;; close POST_K (Simple(None) is satisfied). ;; post-landing sequence — re-create cond for the IfElse: push force_flag ;; 1 if active call_idx in THEN's range, else 0. local.get $cond_swap ;; re-push orig_cond. -push (state == REWINDING) +push (state >= REWINDING) ;; ordinary or abort replay select ;; (is_rewind ? force_flag : orig_cond) if (then ...) (else ...) ;; original IfElse, untouched. ``` @@ -1040,10 +1080,47 @@ investigation — is preserved in ## Performance envelope -The Phase 7 acceptance gate is ±3% of the previous fork-continuation baseline on fork-heavy -benchmark suites, measured with `npx tsx benchmarks/run.ts --rounds=3` on -both the Node.js host and the browser host. The suites that exercise fork -meaningfully are `wordpress`, `erlang-ring`, and `process-lifecycle`. +Linked continuations add three imported host calls per saved activation over a +complete fork cycle: reserve and commit while unwinding, then next while +replaying. Each saved activation also carries a 24-byte node header on wasm32 +or a 32-byte node header on wasm64, rounded together with the payload to an +8-byte boundary. Each active module continuation uses at least one 64 KiB +page-rounded anonymous mapping; another mapping is added only when the current +chunk cannot hold the next complete node. An individual frame larger than a +Wasm page is allocated in a correspondingly larger page-rounded chunk. + +ABORT_UNWINDING adds one i32 local and a result-typed restart-loop guard to each +transformed function, a zero-reservation branch at each fork-path call site, +two control exports, and a 16-byte root-prefix selector. This code executes +only on replay checks or allocation failure; ordinary execution adds the local +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. + +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 +and 52,330 bytes with the ABI 42 linked-frame instrumenter: 1,457 additional +bytes (2.86%). This is one small fixture, not a general application-size +claim; the fixed import/metadata cost and the number of transformed call sites +change the percentage substantially between programs. + +Using the same dev-shell compiler invocation on 2026-07-21, the current P-10 +source produced a 27,608-byte raw module. Commit `a4789e2c6` (linked frames +before abort recovery) instrumented it to 52,052 bytes; ABORT_UNWINDING +instrumented the identical raw input to 58,370 bytes. The recovery state +machine therefore added 6,318 bytes (12.14%) to this instrumented fixture. +P-10 deliberately creates a very large conservative fork-path closure, so this +is a stress-fixture result rather than a general package-size estimate. + +Performance comparisons must use the fork-heavy benchmark suites with +`npx tsx benchmarks/run.ts --rounds=3` on both the Node.js and browser hosts. +The suites that exercise fork meaningfully are `wordpress`, `erlang-ring`, +and `process-lifecycle`. Do not infer a regression percentage from the +structural costs above. For the concrete numbers landed by the Phase 7 rollout PR, see Task 15 of `docs/plans/2026-04-21-fork-instrument-phase-7-rollout-plan.md`. Binary size @@ -1062,7 +1139,7 @@ to identify which switch-dispatch shape the offending function uses: wasm-tools print "$BIN" | awk '/^\s+\(func [^;]*main/{found=1} found{print}' | head -200 ``` -A leading `block ... block ... if (state == REWINDING) ... br_table ...` +A leading `loop ... block ... block ... if (state >= REWINDING) ... br_table ...` shape at the function's entry means switch-dispatch is active. A historical `block $unwind_save` followed by per-call `(state == NORMAL || (REWINDING && call_idx == K))` if-elses means an old guard-dispatch binary is being @@ -1070,7 +1147,7 @@ inspected, not current PR output. To distinguish top-level switch-dispatch from nested switch-dispatch, look inside the enclosing instructions: nested switch-dispatch emits the -same `if (state == REWINDING) ... br_table ...` shape inside any +same `if (state >= REWINDING) ... br_table ...` shape inside any fork-bearing `block` / `loop` / `if` / `try_table`, plus a `select` rewriting any fork-bearing IfElse's condition afterwards. Impure IfElse conditions also show a `local.set $cond_swap_local` at the end of the diff --git a/docs/future-improvements.md b/docs/future-improvements.md index 336e0bc0e4..5db33d209a 100644 --- a/docs/future-improvements.md +++ b/docs/future-improvements.md @@ -210,28 +210,6 @@ Future cleanup: `host/src/dylink.ts` (`DynamicLinker`, `LoadSharedLibraryOptions`), `examples/dlopen/test.test.ts`, `host/test/dylink.test.ts`. -### Pre-instantiation worker errors bypass the kernel exit path -When a process worker fails before any syscall (e.g. ABI mismatch, link -error, malformed wasm), it posts `{type:"error"}` via `port.postMessage`. -The kernel-worker-entry catches that and synthesizes `{type:"stderr"}` + -`{type:"exit"}` messages directly to the host, which works for the -common case but bypasses the kernel's normal exit path -(`callbacks.onExit` → `kernelWorker.unregisterProcess(pid)` → -hostReaped tracking → child-pid bookkeeping). For these pre-instantiation -failures the kernel only holds `kernel_create_process(pid)` state, so the -leak is minimal — but it's inconsistent with how successful exits flow. - -The SAB syscall channel can't carry this signal because the channel -glue isn't linked yet at the failure point (the wasm instance doesn't -exist), so the postMessage path is the right transport. The fix is to -route the message through the kernel's normal exit machinery — call -`kernelWorker.unregisterProcess(pid)` and trigger the `onExit` callback -with a non-zero status — instead of fabricating an exit message at -the protocol layer. - -**Files:** `host/src/node-kernel-worker-entry.ts` (handleSpawn's -`worker.on("message")` handler). - ## User-space programs ### Add a real shadow-stack overflow guard beyond the SDK's 8 MiB floor diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index 1b2aaa5192..8bcb36066d 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -29,6 +29,17 @@ metadata is an additional contract for VFS builders, Node validation, browser automation, and publication audits; it is not a replacement for Formula Ruby or Homebrew's `bottle do` block. +The patched Homebrew Ruby tree used by a guest is a dedicated Kandelo program +package named `homebrew-bootstrap`; it is not a Formula bottle. The package +emits `homebrew-bootstrap.zip` from a sealed exact Homebrew checkout and the +reviewed guest-platform patch. Its source lock at +`homebrew/homebrew-bootstrap-source-lock.json` binds all source, patch, +prepared-tree, portable-Ruby, archive-producing Git, and final-archive +identities. The first package revision is sealed by the exact final ZIP +SHA-256 and byte count recorded in that lock. Consumer cutover is a separate +rollout step, so introducing the package does not change shell or +bootstrap-image consumers. + ## Repositories And Ownership | Repository | Owns | @@ -418,6 +429,49 @@ also carries the sorted immutable target-tap set plus three native lists: - `runtime_and_test` is used by the bottle verifier and excludes dependencies that are only tagged `:build`. +### Publisher-Only Native Requirements + +Publisher-only tools are represented by three closed, tap-local Homebrew +`Requirement` classes rather than ordinary guest Formula dependencies: +`BinaryenRequirement`, `PkgconfRequirement`, and `WabtRequirement`. Each class +has one canonical definition under `KandeloFormulaSupport`: it is fatal, binds +one fixed `KANDELO_NATIVE_FORMULA` and `KANDELO_NATIVE_SENTINEL`, and checks +that same sentinel with `satisfy(build_env: false)`. Formulae may refer to +those classes only through the canonical support require and a literal +`depends_on KandeloFormulaSupport:: => :build` or +`[:build, :test]` declaration. Unknown classes, dynamic constant lookup, +changed metadata or predicates, and `:test`-only native Requirements fail +closed. + +The static Formula parser recognizes that exact source shape without +evaluating Formula Ruby. Schema 4 of the protected host-dependency plan binds +the Requirement class, native Formula identity, sentinel executable, and +sorted tags in addition to the existing native dependency lists and immutable +target-tap map. The bottle builder and pour verifier run the same closed-schema +validator before staging the plan. The publisher overlay then compares the +evaluated Requirement objects with those sealed records before reconstructing +only the matching build-only dependencies for Homebrew's normal Superenv path. +For a Requirement also tagged `:test`, the Formula test process receives only +the planned proxy keg's standard tool and metadata paths after the exact +sentinel has been found executable. A missing proxy, omitted evaluated object, +forged class, changed constant, changed tag, or legacy ambiguous schema fails +instead of widening the host-tool graph. + +The publisher lifecycle and guest lifecycle deliberately use different +artifacts. Trusted Linux publication runs the reviewed publisher-side Homebrew +commit pinned by the reusable workflow and proves a real install and test +offline after its disposable Ruby dependencies have been provisioned and +sealed. Kandelo guests instead receive upstream Homebrew commit +`4ead8619231cb15cbe15e8e8188081e347d6f7cd` through the dedicated +`homebrew-bootstrap` program package. Guest acceptance must materialize that +package through the canonical ABI release index and verify its package-output +receipt, archive identity, cache key, and locked inner ZIP before booting it. +The PR staging release is evidence for the package build, not a durable +consumer URL. Until the package is activated in the canonical ABI index and +the tap has adopted these Requirement declarations under a compatible pinned +publisher, the full guest install lifecycle remains a rollout gate rather than +a supported user-facing contract. + The native launcher installs each selected direct dependency as an explicit `homebrew/core/` reference under an ephemeral native prefix. Each install uses Homebrew's normal dependency resolution and completes its full transitive @@ -754,15 +808,22 @@ workflow name. The three dispatch events are `publish-kandelo-bottles`, `dry-run-kandelo-bottles`, and `maintain-kandelo-bottles`. Publish and dry-run payloads must select at least one Formula and architecture; an absent or empty selection is an error, not a successful no-op. -Write publication is additionally fixed to `Automattic/kandelo@main` and the -caller tap's `main` branch. A dry run keeps those repository identities fixed, -but may select a reviewed, valid Git branch name or an exact lowercase -40-character commit SHA from each repository. The trust step normalizes branch -names under `refs/heads/`, and the planning job resolves both selections to -immutable commits before any matrix job starts. These source selections are -data passed to the already-reviewed caller and reusable workflow definitions; -they do not select either workflow definition. The bottle root is never -caller-selected: +Write publication is fixed to the caller tap's `main` branch and normally to +`Automattic/kandelo@main`. During an ABI transition, the protected tap caller +may instead hardcode one reviewed, exact lowercase 40-character Kandelo commit +so the new-ABI bottles exist before the bottle-backed shell can validate. The +Kandelo PR must then merge without rewriting that commit, making the published +source commit an ancestor of `main`; immediately afterward, rotate the tap +caller back to Kandelo `main`. Write publication never accepts a non-main +Kandelo branch. + +A dry run keeps those repository identities fixed, but may select a reviewed, +valid Git branch name or an exact lowercase 40-character commit SHA from each +repository. The trust step normalizes branch names under `refs/heads/`, and the +planning job resolves both selections to immutable commits before any matrix +job starts. These source selections are data passed to the already-reviewed +caller and reusable workflow definitions; they do not select either workflow +definition. The bottle root is never caller-selected: the workflow rejects a non-empty `bottle-root-url` and derives `https://ghcr.io/v2//` from the validated tap repository. The separate reusable maintenance workflow remains first-party @@ -1282,11 +1343,13 @@ reviewed platform patch, and ABI-current Kandelo package artifacts with: The script writes `target/homebrew-bootstrap/homebrew-bootstrap.vfs`. It derives the ABI from `crates/shared`, resolves the Node kernel, canonical rootfs package set, and Homebrew bootstrap programs through `xtask build-deps`, and calls -`scripts/prepare-homebrew-bootstrap-source.sh` to prepare Homebrew. Source -preparation verifies the reviewed patch SHA-256, refuses an upstream revision -where the patch does not apply, limits the patch to its four declared Homebrew -files, and archives the patched Git tree with a fixed timestamp and UTC -timezone. +`scripts/prepare-homebrew-bootstrap-source.sh` to prepare Homebrew. The +dedicated `homebrew-bootstrap` package uses that same preparer and records +byte identity with this still-current image path; switching this image to +consume the package is a separate consumer change. Source preparation verifies +the reviewed patch SHA-256, refuses an upstream revision where the patch does +not apply, limits the patch to its declared Homebrew files, and archives the +patched Git tree with a fixed timestamp and UTC timezone. `/etc/kandelo/homebrew-image.json` records the exact upstream Homebrew commit, patch SHA-256, patched-tree Git object and normalized-tree SHA-256, patched ZIP @@ -1508,18 +1571,18 @@ are poured into a fresh filesystem with a but any file, symlink, or type collision fails composition. The direct collection producer resolves the complete selected plan once so -collision ownership and link suppression are global, then emits one candidate -tree for every selected Formula. Each payload is that Formula's exact finalized Homebrew -bottle `.tar.gz`; the producer does not recompress or combine dependency +collision ownership and link suppression are global, then emits one tree for +every selected Formula. Each payload is that Formula's exact finalized +Homebrew bottle `.tar.gz`; the producer does not recompress or combine dependency bottles. A complete source inventory records every validated TAR member. Its guest projection records ownership, POSIX modes, logical sizes, link targets, source member paths, regular-inode groups, the package keg and `opt` link, and whether a path is an archive member, a link-manifest copy, an explicitly mode-overridden copy, or descriptor-created structure. The image builder still does not duplicate the lower shell image, `/etc` metadata, a gallery profile, -or a language-specific VFS image into any bottle tree. Phase 3's product -composer, not this collection primitive, chooses which candidate trees are -embedded and which remain independently lazy. +or a language-specific VFS image into any bottle tree. The product composer, +not this collection primitive, chooses which trees are embedded and which +remain independently lazy. The exact bottle is transport truth, but installed Homebrew text is not always byte-identical to the archive member. Homebrew records the files it changed @@ -2063,8 +2126,16 @@ resolution starts one deduplicated preparation through the owning VFS mount. The host tries byte-identical transports in descriptor order, checks the same declared compressed identity for each attempt, decodes and validates the entire source inventory, and commits every still-matching regular inode in one -batch. Failure leaves all stubs unchanged and retryable; hard-link names retain -one inode and link count. A `boot-prefetch` tree uses the same path but must +batch. One transport is attempted at most three times. Only HTTP 408, 429, and +5xx responses or recognized fetch/body network interruptions repeat its URL; +the default waits are 250 and 500 milliseconds, and `Retry-After` is honored +up to five seconds. A fetcher may register an `AbortSignal` that is passed into +every attempt; retry waits, mirror fallback, and VFS commit all rethrow its +exact reason. Existing one-argument fetchers retain standard +`AbortError`/`ABORT_ERR` compatibility. Permanent HTTP, integrity, and decoder +failures do not repeat the same URL. Failure leaves all stubs unchanged and +retryable; hard-link names retain one inode and link count. A `boot-prefetch` +tree uses the same path but must finish successfully before boot returns. Metadata-only directory/symlink trees retain a group-level activation identity, so serialization cannot silently turn boot-prefetch into an unverified no-op merely because no regular stub exists. @@ -2127,11 +2198,12 @@ identities, ABI/base mismatches, and conflicting layers fail the boot instead of being skipped. Each runtime-layer reference currently names exactly one requested root equal to its layer ID; the shared 128-request parser/planner bound is not a promise that this boot mount composes a multi-root descriptor. -Phase 3 builds the multi-root shell through the bottle-collection primitive. -The bounded collection producer derives independently lazy, -byte-identical bottle trees for a complete reviewed package closure. The -production shell policy embeds `libcxx`, `ncurses`, and Bash, and retains the -other 35 original bottles as independently deferred trees. +The canonical shell builds its multi-root closure through the +bottle-collection primitive. The bounded collection producer derives +independently lazy, byte-identical bottle trees for a complete reviewed package +closure. The production shell policy embeds `libcxx`, `ncurses`, and Bash, and +retains the other 39 bottles in the language-expanded closure as independently +deferred trees. The lazy build keeps materialization code behind its own entrypoint. `build-homebrew-vfs-image.ts` owns the shared eager planning, metadata, and diff --git a/docs/package-management.md b/docs/package-management.md index fc7d6b3a66..3546fda489 100644 --- a/docs/package-management.md +++ b/docs/package-management.md @@ -533,6 +533,26 @@ 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 guest `brew` implementation is distributed separately from Formula +bottles as the `homebrew-bootstrap` program package. Its single declared +artifact, `homebrew-bootstrap.zip`, is a deterministic archive of one exact +upstream Homebrew commit plus Kandelo's reviewed guest-platform patch. Although +the artifact is not Wasm, it uses the ordinary program-package resolver, +program projection, cache key, and release archive contracts; its output +therefore declares `fork_instrumentation = "disabled"`. + +`homebrew/homebrew-bootstrap-source-lock.json` is the reviewed source/output +identity. It binds the upstream archive URL and SHA-256, sealed +`[[git_inputs]]` commit, patch path/SHA-256/license, patched Git and normalized +tree identities, portable Ruby version, archive-producing Git version, and +final ZIP SHA-256/byte count. The package build imports the resolver-owned +exact Git checkout into private scratch storage and performs no source fetch +of its own. The lock also records the dedicated package output's exact SHA-256 +and byte count, so a rebuild cannot silently change guest Homebrew bytes. +Shell and bootstrap-image consumer cutover remains a separate change. Run the +build through `scripts/dev-shell.sh`; a different Git ZIP implementation fails +the exact output lock instead of publishing different bytes. + See [docs/homebrew-publishing.md](homebrew-publishing.md) for the Homebrew formula, sidecar, GHCR, VFS, and runtime validation contract. diff --git a/docs/plans/2026-07-21-homebrew-migration-execution-plan.md b/docs/plans/2026-07-21-homebrew-migration-execution-plan.md index 47fbb65e83..d14aa64ef0 100644 --- a/docs/plans/2026-07-21-homebrew-migration-execution-plan.md +++ b/docs/plans/2026-07-21-homebrew-migration-execution-plan.md @@ -206,6 +206,75 @@ Work in different repositories may proceed concurrently when immutable inputs make the results independent. Do not serialize unrelated Formula rollouts, but do preserve the single-writer finalization and exact-commit trust contracts. +### Accelerated landing tranches + +The remaining critical path is grouped into coherent tranches to avoid paying +the serialized `prepare-merge` and immutable-publication cost once per small +prerequisite. This changes landing mechanics, not product scope or acceptance +criteria: + +1. Land the atomic package-generation foundation by itself because its exact + staging and public-bottle proof were already running when batching was + selected. Do not discard that evidence by expanding its head late. +2. After explicit kernel-change approval, land one integrated tranche that + contains the dedicated guest Homebrew program package, fail-closed VFS + publication integrity, native Homebrew `Requirement` support, the generic + lazy/eager verified-archive contract, and ABI 42's kernel-owned task identity + and dynamically allocated fork continuations. Preserve the individual + commits and PR references so failures and review history remain attributable. + Keeping these changes in one pull request saves one complete serialized + staging and synthesized-merge cycle without weakening the kernel approval + boundary: the tranche cannot merge until the ABI/kernel portion is approved. +3. ABI 42 is not a functional prerequisite for composing the revision-18 VFS. + It is deliberately ordered before final bottle publication and shell + activation because an ABI bump afterward would invalidate and require + republishing the complete ABI-bound bottle and image set. Validate the + combined exact tree once, publish one ABI-42 artifact generation, and avoid + building a throwaway final ABI-41 generation. +4. Rotate the tap's reusable-workflow trust pins to the exact landed + `Requirement`-support commit, then finalize the native-Requirement Formula + rollout. +5. Land one product cutover tranche that keeps Bash and its startup closure + eager, keeps optional bottles and Homebrew itself lazy, exposes the normal + `/usr/bin/brew` entrypoint, and proves the exact image in Node.js and + Chromium. + +A tranche may be split when a real correctness or review boundary requires it, +but queue convenience alone is not a reason to restore one PR per small step. +Batching must never weaken exact-head validation, immutable +artifact identity, browser/Node parity, POSIX correctness, or an explicit +merge-approval boundary. + +### Tracked non-blocking pipeline follow-ups + +These defects affect iteration speed or diagnostic quality but do not weaken +the exact artifact accepted by the current gates: + +- Make every completed PR staging release a self-contained snapshot. A run + that cannot reuse a partial target currently rebuilds against the canonical + ABI release, while each matrix writer adds only its own result to the target + index. The test gate still composes and verifies the complete canonical plus + local-matrix view, so this is not a correctness bypass, but a later run + cannot reuse a target that remains sparse. Add one post-matrix finalizer that + writes a complete target-relative index and verifies every referenced target + asset before making it reusable. Do not copy canonical relative URLs into a + different release namespace without rewriting and re-verifying them. +- Diagnose the intermittent MariaDB out-of-bounds failure from Homebrew shell + run `30041372714`. Exact replacement run `30044000480` passed the same + direct-input LAMP source-build and complete Node.js/Chromium shell proof, so + the failure is not a current landing blocker, but it remains evidence to + reproduce and root-cause. Do not turn the source build into unconditional + retry-until-green behavior. +- Remove the GNU mirror selector as a single availability dependency for + source builds. ABI 42 staging run `30045822037` exhausted all eleven ncurses + retries while `ftpmirror.gnu.org` returned HTTP 502, even though the + hash-identical archive remained available from GNU's canonical origin. + Ncurses uses the canonical origin to unblock this generation; follow up by + auditing every remaining GNU recipe and either pinning the canonical origin + or adding a package-schema mirror list whose alternatives remain bound to + the one declared source hash. Retrying the same failed selector is not source + redundancy. + ### Phase 1: Close the active publication and federation work 1. Completed by PR #1048 and run `29886510272`: fix the @@ -465,11 +534,98 @@ canonical release): - The initial proof used the exact locally built directory fix from draft PR #1058. PR #1060 superseded that draft, landed the general `getdents64` fix, activated canonical revision 17, and completed the Phase 3 package cutover. - There is no remaining kernel prerequisite for revision 18. Canonical PR - #1056 and the browser ledger in PR #1062 have now landed. The stacked exact - shell/language test is green in Node.js and Chromium. Canonical activation - now waits only for the shared product-VFS headroom fix, its derived-product - regression, and the final exact restack against landed prerequisites. + There is no remaining VFS-semantic kernel prerequisite for revision 18. + Canonical PR #1056 and the browser ledger in PR #1062 have now landed. The + stacked exact shell/language test is green in Node.js and Chromium. ABI 42 is + nevertheless scheduled before the final publication/cutover so the complete + bottle set is published once for the ABI Kandelo will actually ship, rather + than publishing and immediately invalidating another ABI-41 generation. + Canonical activation also waits for the shared product-VFS headroom fix, its + derived-product regression, and the final exact restack against landed + prerequisites. +- ABI 42 creates a real publication cycle: the bottle-backed shell cannot + validate until ABI-42 tap metadata exists, while normal write publication + builds only from Kandelo `main`. Break that cycle without accepting a mutable + source ref: + + 1. finish every non-cyclic #1079 validation and freeze its reviewed head; + 2. have protected tap `main` hardcode that same exact 40-character SHA as + both the reusable-workflow ref and `kandelo-ref`; + 3. publish only the complete intended ABI-42 closure, then update the shell + lock to the exact resulting tap commit and run exact Node/Chromium and + staging acceptance; + 4. merge #1079 with a merge commit so the published Kandelo SHA becomes an + ancestor of `main`, verify that ancestry, and immediately rotate the tap + caller back to landed Kandelo `main`; + 5. restore the repository's normal merge-method setting after that one + cutover. + + Rebase or squash is not acceptable for this transition because GitHub + rewrites the source SHA recorded by the bottle handoffs and sidecars. + + Make the tap-side transition one atomic `main` update rather than merging + the native-Requirement change, bottle-identity reservations, and publisher + pins separately. Before that merge, disable every core-tap and independent + canary `repository_dispatch` caller and wait until no older caller run is + queued or active; an already-loaded old caller could otherwise check out the + new tap tree. Recheck all 63 deterministic next-rebuild top-level GHCR tags + immediately before merging the combined tap PR. Afterward, enable only the + core production publisher while the frozen Kandelo SHA is not yet on `main`. + Dry-run selection can still choose pre-transition Kandelo `main`, and + maintenance resolves Kandelo `main` internally, so both remain disabled + until #1079 is merge-committed and the tap pins are normalized. The + historical repository-namespace canary remains disabled, and the independent + canary stays disabled until its vendored support, core lock, and publisher + pin are updated together. The base-owned tap trust check intentionally + rejects any workflow-contract edit; for this reviewed rotation, require the + candidate-owned trust check plus an exact manual workflow/trust-root diff. + + Before dispatching ABI-42 writes, reserve a fresh immutable bottle identity + for every live Formula by incrementing its reviewed `bottle do` rebuild once + and retaining the last-green hashes as evidence until the trusted finalizer + replaces them. GHCR's Homebrew identity annotations contain package version, + architecture, and bottle rebuild—not Kandelo ABI—so reusing the ABI-41 + rebuild would either collide with different bytes or stale Formula identity. + The exact 63-Formula reservation has 70 declared architecture identities, but + those child identity strings are OCI annotations within each Formula's top + index rather than independently addressable registry tags. All 63 + deterministic next-rebuild top-level tags must be absent before the + reservation merges. The trusted publisher and finalizer validate each + resulting index and its architecture-specific content. Tap PR #91 carries + this bulk reservation, stacked after the native Requirement rollout in tap + PR #86. + + Publish the ABI-42 catalog one Formula per write dispatch, with no more than + eight write runs queued or active. Refill those slots as soon as a Formula's + same-tap build, test, and runtime dependencies are finalized; do not wait for + an unrelated Formula in the same level to finish. The dependency-ready + wasm32 levels for the exact 63-Formula tap are: + + 1. `asa`, `bc`, `binutils`, `bzip2`, `coreutils`, `ctags`, `dash`, `ed`, + `fbdoom`, `gawk`, `gencat`, `getconf`, `grep`, `gzip`, `libcxx`, + `libiconv`, `lsof`, `modeset`, `musl-fts`, `ncompress`, `netcat`, + `openssl`, `pcre2`, `perl`, `posix-utils-lite`, `procps`, `sed`, `sqlite`, + `unzip`, `what`, `xz`, `zlib`, and `zstd`; + 2. `diffutils`, `dinit`, `erlang`, `findutils`, `icu`, `libcurl`, `libmagic`, + `libpng`, `libxml2`, `libzip`, `m4`, `make`, `ncurses`, `patch`, `pax`, + `python`, `ruby`, `tar`, `tcl`, `wget`, and `zip`; + 3. `bash`, `curl`, `file-formula`, `less`, `nano`, `nethack`, `texlive`, and + `vim`; + 4. `git`. + + This graph deliberately includes same-tap test dependencies: `erlang` and + `findutils` both need the ABI-42 `dash` bottle for `brew test`. It also + includes `icu`, the live Formula absent from the prior successful metadata + ledger, after its `libcxx` dependency. Native Requirements and unqualified + `homebrew/core` build tools run on the publisher and are not target-bottle + edges. + + Publish the seven declared wasm64 targets through the same Formula-scoped + runs: first `libcxx`, `musl-fts`, `openssl`, `sqlite`, and `zlib`; then + `libcurl`; then `curl`. A dual-architecture dispatch is valid when both + architecture dependencies are ready. The ABI-42 `python` wasm32 dispatch + must require the configured dependency-bearing VFS acceptance after `dash` + and `zlib` are finalized. ### Phase 5: Ship usable upstream Homebrew inside Kandelo diff --git a/docs/plans/2026-07-23-homebrew-vfs-formula-layer-contract.md b/docs/plans/2026-07-23-homebrew-vfs-formula-layer-contract.md new file mode 100644 index 0000000000..a620d43e38 --- /dev/null +++ b/docs/plans/2026-07-23-homebrew-vfs-formula-layer-contract.md @@ -0,0 +1,92 @@ +# Homebrew VFS Formula layer contract + +Status: validation substrate only. This note does not claim that Kandelo can +publish or consume a VFS Formula yet. + +## Decision shared by both transport options + +A VFS Formula is an ordinary Homebrew Formula. Its normal `depends_on` +declarations remain the only dependency source of truth, including fully named +cross-tap dependencies. Kandelo resolves that dependency closure from immutable +tap metadata before inspecting the root Formula bottle. + +The root Formula bottle owns its layer-specific system files at two fixed +locations inside its keg: + +- `share/kandelo/vfs-layer.json` is the bounded, URL-free manifest. +- `libexec/kandelo-vfs-layer/rootfs/` is projected onto `/`. + +Configuration, service definitions, writable-state setup, and presentation +metadata such as `/etc/kandelo/demo.json` are ordinary files below that rootfs +directory. They therefore travel with the Formula that owns them instead of +living in a package-name-specific browser rule. + +The schema-1 manifest binds the full Formula name, the fixed payload mapping, +and boot-prefetch or first-use activation policy. It deliberately does not +repeat dependencies, bottle hashes, public URLs, release tags, or acceptance +evidence. Dependencies already belong to the Formula and tap metadata. +Publication URLs and release tags cannot be embedded in immutable bottle bytes +without making publication provenance circular. + +`host/src/homebrew-vfs-formula-layer.ts` implements the common source contract: + +- exact manifest parsing and bounds; +- one explicitly requested, dependency-first Formula closure; +- fixed manifest and payload presence in the root bottle; +- canonical path, directory, mode, symlink, hard-link, and activation checks; +- deterministic cross-layer package and target ownership preflight; and +- image-wide package, entry, and payload budgets after shared ownership is + deduplicated. + +Two selected layers may share an ordinary Formula dependency. Composition +installs it once when both plans bind the exact same bottle, link projection, +and immutable provenance; two different identities for the same full Formula +name fail before filesystem staging begins. + +Per-layer bounds are not sufficient on their own: several valid layers can +still exceed one VFS image's retained-resource budget. Composition therefore +charges the final deduplicated package and path inventory before a descriptor +builder or staged filesystem is allowed to mutate state. + +The existing runtime-layer consumer remains the composition endpoint. It +already verifies descriptor and content hashes, the exact base image and ABI, +aggregate resource budgets, package ownership, base and cross-layer path +collisions, and all selected layers before publishing its staged filesystem. +The new source contract must feed that path rather than create a second browser +mount implementation. + +## Remaining transport decision + +Two implementations can derive the same checked target inventory: + +1. Reuse the original bottle as the deferred payload. The descriptor maps + regular payload members from their private keg paths to final rootfs paths, + creates structural directories and symlinks from checked metadata, and + fetches the original bottle once. This preserves the strongest + bottle-to-runtime byte identity, but the current schema-5 direct-bottle + binder needs an explicit, reviewed VFS-payload mapping rule. +2. Derive a rootfs-only immutable archive from the verified bottle payload. + The existing generic deferred-tree decoder can consume it with fewer schema + changes, but the release mirrors bytes already present in the bottle and + must bind that derived archive back to the exact source bottle and manifest. + +The current change does not choose between them. Both require exactly the +manifest, dependency closure, payload projection, and collision preflight +implemented here. A follow-up should select the transport after measuring the +release/storage cost and the complexity of extending the direct-bottle +descriptor without weakening its source-inventory checks. + +## Acceptance still required + +Before this becomes supported behavior: + +1. Bind the projected inventory into a draft runtime-layer descriptor using one + of the transport options above. +2. Prove two independently bottled VFS Formulae, including one cross-tap + dependency, through the exact Node.js and Chromium composition path. +3. Prove deterministic composition in either selected order, or a truthful + collision before any staged filesystem is published. +4. Publish immutable bottle, descriptor, transport, Node.js, and browser + evidence through the normal tap workflow. +5. Document Formula authoring and user-facing layer selection only after those + artifacts and tests are live. diff --git a/docs/porting-guide.md b/docs/porting-guide.md index 412635c17c..7f08239583 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -169,7 +169,7 @@ The simplest way to run a Wasm program is with `examples/run-example.ts`. For cu ```typescript import { readFileSync } from "fs"; -import { CentralizedKernelWorker } from "../host/src/kernel-worker"; +import { CAPTURED_STDIO, CentralizedKernelWorker } from "../host/src/kernel-worker"; import { NodePlatformIO } from "../host/src/platform/node"; import { NodeWorkerAdapter } from "../host/src/worker-adapter"; @@ -187,16 +187,21 @@ const kernelWorker = new CentralizedKernelWorker( io, { onFork: async (parentPid, childPid, parentMemory) => { - // Copy parent memory, register child, spawn child worker + // childPid is already allocated by Rust. Copy parent memory, + // attach the child host state, and spawn its worker. // See examples/run-example.ts for full implementation }, onExec: async (pid, path, argv, envp) => { // Resolve path to wasm binary, replace process // Return 0 on success, -2 (ENOENT) if not found }, - onClone: async (pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory) => { - // Allocate thread channel, spawn thread worker - // Return tid on success + onClone: async (attachment) => { + const { pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory } = + attachment; + // PID/TID and launch values are bound to Rust's exact clone result by a + // one-shot host transport proof. Allocate a mailbox, consume it with + // kernelWorker.attachThreadChannel(attachment, channelOffset), then + // spawn the thread worker. The host never supplies a numeric TID. }, onExit: (pid, status) => { // Handle process exit @@ -217,8 +222,8 @@ memory.grow(MAX_PAGES - 17); const channelOffset = (MAX_PAGES - 2) * 65536; new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); -// Register and spawn process -const pid = 100; +// Ask Rust to create the process, then attach its host memory and channel. +const pid = kernelWorker.createProcess(CAPTURED_STDIO); kernelWorker.registerProcess(pid, memory, [channelOffset]); ``` @@ -230,15 +235,15 @@ For a complete example with fork/exec/clone support, see `examples/run-example.t // Initialize with kernel wasm bytes await kernelWorker.init(kernelWasmBytes: ArrayBuffer) -// Register a process +// Create a kernel Process and receive its assigned PID +const pid = kernelWorker.createProcess(CAPTURED_STDIO) + +// Attach host memory and channels to that existing Process kernelWorker.registerProcess(pid, memory, channelOffsets, options?) // Set process working directory kernelWorker.setCwd(pid, path) -// Set next PID for child processes -kernelWorker.setNextChildPid(pid) - // Provide stdin data kernelWorker.setStdinData(pid, data: Uint8Array) kernelWorker.appendStdinData(pid, data: Uint8Array) @@ -250,6 +255,11 @@ kernelWorker.unregisterProcess(pid) kernelWorker.deactivateProcess(pid) ``` +`ProcessTable` in the Rust kernel owns the only PID/TID sequence. It starts at +100 and allocates identities for top-level creation, fork, `posix_spawn`, and +thread clone. Integration code must treat callback PIDs/TIDs as read-only: +there is no host watermark or API for choosing the next identity. + ## Browser UI Integration The browser UI uses `BrowserKernel` from `host/src/browser-kernel-host.ts`, which handles the browser kernel worker, process lifecycle, and filesystem in a browser-friendly API. The product UI lives under `apps/browser-demos/pages/kandelo/`, retained browser labs live under `apps/browser-demos/pages/`, and the host runtime itself is maintained under `host/src/` beside the Node.js host. @@ -574,7 +584,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 = 41 # current ABI_VERSION; required for packages with a [build] block +kernel_abi = 42 # 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 8bb0f936d9..e146d9bbe4 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -19,6 +19,9 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve **Key properties:** - **Single kernel instance** with a `ProcessTable` mapping PIDs to `Process` structs +- **One kernel-owned task-ID sequence** in that Rust `ProcessTable` allocates all + top-level, fork, spawn, and clone PIDs/TIDs monotonically from 100. Callers do + not choose IDs, and PID 1 is a kernel-created synthetic init reservation. - **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 @@ -30,8 +33,10 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve - **Signal delivery** across processes is direct — the kernel can write to any process's pending signal mask **Key kernel-side APIs:** -- `kernel_create_process(pid)` — register a new process -- `kernel_fork_process(parent, child)` — fork state from parent to child (fd table, OFDs, signals, etc.) +- `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_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 @@ -116,7 +121,7 @@ same final-OFD lifetime rules. | Function | Status | Notes | |----------|--------|-------| -| `fork()` | Partial | The kernel copies process state and the host starts a child Worker with copied Memory. 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. 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()`. 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. | | `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. `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()` | Full | 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 the parent and zombie state remains until `waitpid()` reaps it. | @@ -139,11 +144,11 @@ 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. 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. 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. | | `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations; it does not provide distinct vfork address-space semantics. | -| `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). Host parses the blob, calls `kernel_spawn_process` to allocate a child pid + build the child Process descriptor, then invokes `onSpawn` to launch a fresh Worker. 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). 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. | -| `clone()` | Partial | Thread-style clone (CLONE_VM\|CLONE_THREAD) supported. The kernel allocates the TID, 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. | +| `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. | | `ptrace()` | Stub | Returns ENOSYS. | @@ -170,7 +175,8 @@ same final-OFD lifetime rules. | Function | Status | Notes | |----------|--------|-------| -| `kill()` | Partial | Marks signal as pending. sig=0 validity check. Cross-process delivery via host_kill import and ProcessManager.deliverSignal(). Pending signals delivered at syscall boundaries. POSIX EPERM enforced: unprivileged processes cannot signal a target whose real/effective uid does not match their own. A virtual init (pid 1, uid 0) is auto-registered so `kill(1, ...)` resolves; target 4 in compromising-xfails.md. | +| `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. | @@ -307,7 +313,7 @@ shortcuts. | `sched_get_priority_min()` | Stub | Returns 0. | | `sched_rr_get_interval()` | Stub | Writes 10ms timespec. | | `sched_setaffinity()` | Stub | Returns 0 (no-op). | -| `sched_getaffinity()` | Stub | Linux-specific one-CPU compatibility surface. Running or stopped workers and process leaders that have not been reaped (including zombie leaders) report a fixed four-byte CPU-0 mask; reaped leaders, dead workers, and absent tasks return `ESRCH`. The raw syscall requires a size of at least four bytes aligned to four, writes and returns exactly four bytes, and leaves a larger raw buffer's tail untouched. Musl's public wrapper zero-fills that tail and returns 0. Exact leader PIDs take precedence over Kandelo's per-process worker TID records, whose numeric IDs can still collide across processes. | +| `sched_getaffinity()` | Stub | Linux-specific one-CPU compatibility surface. Running or stopped workers and process leaders that have not been reaped (including zombie leaders) report a fixed four-byte CPU-0 mask; reaped leaders, dead workers, and absent tasks return `ESRCH`. The raw syscall requires a size of at least four bytes aligned to four, writes and returns exactly four bytes, and leaves a larger raw buffer's tail untouched. Musl's public wrapper zero-fills that tail and returns 0. Process leaders and worker TIDs share the kernel's global task-ID sequence, so each numeric target identifies at most one retained task. | | `sched_yield()` | Stub | Returns 0 (no-op, single-threaded). | ## Event/Notification @@ -469,7 +475,7 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego ### Future Work — Remaining items **Threading:** -- `clone()` — CLONE_VM|CLONE_THREAD: kernel allocates TID, host spawns thread Worker sharing parent's Memory. TLS initialization via `__wasm_thread_init` export. +- `clone()` — CLONE_VM|CLONE_THREAD: the Rust `ProcessTable` allocates the TID from its global PID/TID sequence, and the host spawns a thread Worker sharing the parent's Memory. TLS initialization via `__wasm_thread_init` export. - `gettid()` — returns actual TID for threads, pid for main thread - `set_tid_address()` — stores tidptr; kernel writes 0 + futex-wakes on thread exit (CLONE_CHILD_CLEARTID) - `futex()` — WAIT/WAKE/REQUEUE/CMP_REQUEUE/WAKE_OP are implemented within one process; cross-process waits/wakes remain unsupported even over a coordinated shared mapping @@ -543,7 +549,7 @@ 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_get_fork_state / kernel_init_from_fork Wasm exports +- `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 - ProcessManager.fork() with state transfer to child worker - ProcessManager.waitpid() with WNOHANG support 13c. **Phase 13c (Complete):** Cross-Process Pipes @@ -552,13 +558,12 @@ These features require SharedArrayBuffer (and cross-origin isolation headers in - kernel_convert_pipe_to_host Wasm export - Pipe detection and conversion on fork via ProcessManager 13d. **Phase 13d (Complete):** Cross-Process Signals -- kernel_deliver_signal Wasm export for host-initiated signal injection -- host_kill Wasm import with cross-process routing in sys_kill -- DeliverSignalMessage protocol and ProcessManager.deliverSignal() -- KillRequestMessage: worker → host → target worker signal routing +- Centralized kernel-owned target resolution, pending queues, and permission checks +- Exact `(pid, tid)` dequeue at the host boundary; the host wakes channels but does not own signal state +- 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 -- Legacy kernel_get_exec_state / kernel_init_from_exec Wasm exports (scheduled for ABI cleanup) +- 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 - 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/docs/posix-test-report.md b/docs/posix-test-report.md index 23c360dfe0..245473debb 100644 --- a/docs/posix-test-report.md +++ b/docs/posix-test-report.md @@ -4,6 +4,10 @@ Generated: 2026-03-30 Source: LTP open_posix_testsuite (conformance/interfaces) +> This is dated historical evidence, not the current support contract. The run +> predates ABI 42, which reserves PID 1 for a kernel-created synthetic init +> process and allocates the first user PID from 100. + | Status | Count | |--------|-------| | PASS | 161 | @@ -20,8 +24,8 @@ Source: LTP open_posix_testsuite (conformance/interfaces) | Test | Interface | Reason | |------|-----------|--------| -| `2-2` | kill | EPERM test: PID 1 is our process, not init | -| `3-1` | kill | EPERM test: PID 1 is our process, not init | +| `2-2` | kill | Historical runtime made PID 1 the test process; superseded by ABI 42 | +| `3-1` | kill | Historical runtime made PID 1 the test process; superseded by ABI 42 | | `12-1` | mlock | Needs pwd.h (getpwnam) | | `1-1` | munmap | Requires real page unmapping | | `1-2` | munmap | Requires real page unmapping | diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index fe92cedbb8..a6f6bc6e4a 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -499,7 +499,7 @@ wasm-opt -O2 program.wasm -o program.wasm mv program.wasm.instr program.wasm ``` -The tool emits five `wpk_fork_*` exports that the host runtime drives during +The tool emits seven `wpk_fork_*` exports that the host runtime drives during fork. Programs that don't use fork can skip this step entirely, but a program that reaches `kernel_fork` without complete `wpk_fork_*` instrumentation is invalid. diff --git a/docs/software-unit-tests.md b/docs/software-unit-tests.md index ca5d882759..3aee56857d 100644 --- a/docs/software-unit-tests.md +++ b/docs/software-unit-tests.md @@ -292,9 +292,11 @@ Kernel/host fixes present in that historical source-PR handoff: host stdio handle for I/O, but `fstat(2)` reports FIFO metadata and `isatty(3)` observes non-terminal behavior. This is needed for PHPT `--CAPTURE_STDIO--` cases and is a general POSIX metadata correction. -- Centralized `fork(2)` retries host PID allocation when the kernel still owns a - zombie/limbo PID. The kernel remains the source of truth for PID occupancy; - `fork(2)` callers should not observe an internal `EEXIST` collision. +- The historical centralized `fork(2)` path retried a host-selected identity + when the kernel still owned a zombie/limbo PID. ABI 42 supersedes that split + authority: the Rust `ProcessTable` now allocates fork PIDs, top-level PIDs, + spawn PIDs, and clone TIDs from one monotonic sequence, so callers neither + choose identities nor handle internal `EEXIST` collisions. - Thread exit now clears `CLONE_CHILD_CLEARTID` storage and wakes the futex wait word, matching Linux pthread join expectations. - Centralized host/kernel calls that pass guest pointers now route through the diff --git a/homebrew/homebrew-bootstrap-source-lock.json b/homebrew/homebrew-bootstrap-source-lock.json new file mode 100644 index 0000000000..7352a54abc --- /dev/null +++ b/homebrew/homebrew-bootstrap-source-lock.json @@ -0,0 +1,44 @@ +{ + "schema": 1, + "kind": "kandelo-homebrew-bootstrap-source-lock", + "package": { + "name": "homebrew-bootstrap", + "version": "6.0.3-4-g4ead861", + "arch": "wasm32" + }, + "source": { + "repository": "https://github.com/Homebrew/brew.git", + "revision": "4ead8619231cb15cbe15e8e8188081e347d6f7cd", + "archive_url": "https://github.com/Homebrew/brew/archive/4ead8619231cb15cbe15e8e8188081e347d6f7cd.tar.gz", + "archive_sha256": "4b9fdfb4872bd2fbff001c69f91ec7b2c2b7a956459132b6c3adba878f551155" + }, + "patch": { + "path": "homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch", + "sha256": "9c52238d811616c210cd1ecdd23b0192a3e0333219a70b34d8ea6d77dbcfbf74" + }, + "license": { + "expression": "BSD-2-Clause AND GPL-2.0-or-later", + "upstream": { + "spdx": "BSD-2-Clause", + "path": "LICENSE.txt", + "sha256": "f80329e58613ad669c0e73cb132d8060b9b2c55e339c73848068e4d1567f4627", + "bytes": 1334 + }, + "kandelo_patch": { + "spdx": "GPL-2.0-or-later", + "evidence_path": "homebrew/patches/README.md", + "evidence_sha256": "22eef64e04c8d9dc0dbff51ce5e79622914789391b85aa908316cb62dd19094c" + } + }, + "prepared": { + "patched_tree_git_oid": "55de49b411de0ca122ec9eb8f0fc8b0a7a32dca6", + "patched_tree_sha256": "5b4dc5392afc6ccab74e77abde590bc8353a89f8be4665cb3b296700a0236c22", + "portable_ruby_version": "4.0.5_1", + "git_version": "2.51.2" + }, + "output": { + "path": "homebrew-bootstrap.zip", + "sha256": "6b94235c4463a7ae03104decb20910fb660af4d2313fc0c87a84ef02acde440c", + "bytes": 5081250 + } +} diff --git a/homebrew/patches/0002-support-isolated-publisher.patch b/homebrew/patches/0002-support-isolated-publisher.patch index 3435df6aae..504ee911ce 100644 --- a/homebrew/patches/0002-support-isolated-publisher.patch +++ b/homebrew/patches/0002-support-isolated-publisher.patch @@ -26,8 +26,15 @@ Exclude only the Homebrew repository from the preinstall writability diagnostic and skip automatic per-item persistence when the tap already grants the same authority. When the isolated publisher supplies its protected static plan, populate the build child's environment with only the matching direct native -build dependencies. Do not recursively expand them. Every selected dependency -must already be present as a proxy keg, so a missing proxy still fails closed. +build dependencies. Its schema distinguishes literal Formula dependencies from +statically allowlisted Requirements. Validate each evaluated Requirement's +class, Formula identity, sentinel executable, and tags against the sealed plan, +then reconstruct only those matching Formulae as build-only Superenv inputs. +Do not recursively expand them. Every selected dependency must already be +present as a proxy keg, so a missing proxy still fails closed. +For a Requirement also tagged `:test`, validate that its sealed proxy contains +the planned sentinel and expose only that proxy's standard tool and metadata +paths to Homebrew's Formula test process. Use that same protected plan to suppress Linux global dependencies for the identified Kandelo target and every Formula in its exact immutable target taps while Homebrew derives the recursive target receipt. Formulae from other taps @@ -49,17 +56,18 @@ against an immutable publisher store. This patch is applied only to the publisher's temporary Homebrew overlay. Guest Homebrew keeps its normal repository, trust, and dependency behavior. --- - Library/Homebrew/build.rb | 25 ++++++++++++++++++++++++-- + Library/Homebrew/build.rb | 31 ++++++++++++++++++++++++++++++- Library/Homebrew/dev-cmd/bottle.rb | 12 ++++++++++++ Library/Homebrew/diagnostic.rb | 1 + Library/Homebrew/extend/os/linux/formula.rb | 4 ++++ Library/Homebrew/extend/os/linux/sandbox.rb | 4 +++- - Library/Homebrew/kandelo_publisher.rb | 127 +++++++++++++++++++++++++++++ + Library/Homebrew/kandelo_publisher.rb | 225 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + Library/Homebrew/test.rb | 2 ++ Library/Homebrew/trust.rb | 1 + - 7 files changed, 172 insertions(+), 2 deletions(-) + 8 files changed, 278 insertions(+), 2 deletions(-) diff --git a/Library/Homebrew/build.rb b/Library/Homebrew/build.rb -index be833176c0..465cf63438 100644 +index be833176c0..c2c07f5bcf 100644 --- a/Library/Homebrew/build.rb +++ b/Library/Homebrew/build.rb @@ -10,6 +10,7 @@ old_trap = trap("INT") { exit! 130 } @@ -70,7 +78,7 @@ index be833176c0..465cf63438 100644 require "keg" require "extend/ENV" require "fcntl" -@@ -43,12 +44,34 @@ class Build +@@ -43,12 +44,40 @@ class Build @deps = T.let([], T::Array[Dependency]) @reqs = T.let(Requirements.new, Requirements) @@ -90,10 +98,16 @@ index be833176c0..465cf63438 100644 + return [] if plan.nil? + + target_tap_prefixes = plan.fetch("target_taps").map { |tap| "#{tap.fetch("tap_name")}/" } -+ direct_native_build_dependencies = formula.deps.select do |dependency| ++ direct_native_formula_dependencies = formula.deps.select do |dependency| + dependency.build? && !dependency.implicit? && + target_tap_prefixes.none? { |prefix| dependency.name.start_with?(prefix) } + end ++ native_requirement_dependencies = ++ KandeloPublisher.evaluated_native_requirements(formula, plan).map do |requirement| ++ Dependency.new(requirement.fetch("formula"), [:build]) ++ end ++ direct_native_build_dependencies = ++ direct_native_formula_dependencies + native_requirement_dependencies + expected_names = plan.fetch("build") + actual_names = direct_native_build_dependencies.map(&:name).sort + if actual_names != expected_names @@ -107,7 +121,7 @@ index be833176c0..465cf63438 100644 def effective_build_options_for(dependent) args = dependent.build.used_options diff --git a/Library/Homebrew/dev-cmd/bottle.rb b/Library/Homebrew/dev-cmd/bottle.rb -index dbd420e2c5..82d3263e97 100644 +index dbd420e2c5..82d326397e 100644 --- a/Library/Homebrew/dev-cmd/bottle.rb +++ b/Library/Homebrew/dev-cmd/bottle.rb @@ -4,4 +4,5 @@ @@ -158,7 +172,7 @@ index f493938c3a..66926e07ea 100644 return if not_writable_dirs.empty? diff --git a/Library/Homebrew/extend/os/linux/formula.rb b/Library/Homebrew/extend/os/linux/formula.rb -index dbe848ba51..ea87cff22e 100644 +index dbe848ba51..e0f7374914 100644 --- a/Library/Homebrew/extend/os/linux/formula.rb +++ b/Library/Homebrew/extend/os/linux/formula.rb @@ -1,6 +1,8 @@ @@ -195,10 +209,10 @@ index ba803bc756..06d1671285 100644 begin diff --git a/Library/Homebrew/kandelo_publisher.rb b/Library/Homebrew/kandelo_publisher.rb new file mode 100644 -index 0000000000..ff90519c8 +index 0000000000..7ee01d23d5 --- /dev/null +++ b/Library/Homebrew/kandelo_publisher.rb -@@ -0,0 +1,127 @@ +@@ -0,0 +1,225 @@ +# typed: false +# frozen_string_literal: true + @@ -209,8 +223,16 @@ index 0000000000..ff90519c8 +module KandeloPublisher + PLAN_FILENAME = ".kandelo-publisher-build-dependencies.json" + MAX_PLAN_BYTES = 65_536 -+ PLAN_KEYS = %w[build build_and_test formula full_name runtime_and_test schema tap target_taps].freeze ++ MAX_DEPENDENCIES = 128 ++ PLAN_KEYS = %w[ ++ build build_and_test formula full_name native_requirements runtime_and_test schema tap target_taps ++ ].freeze ++ NATIVE_REQUIREMENT_KEYS = %w[class formula sentinel tags].freeze + DEPENDENCY_NAME = /\A[a-z0-9][a-z0-9@+_.-]*\z/ ++ EXECUTABLE_NAME = /\A[A-Za-z0-9][A-Za-z0-9._+-]*\z/ ++ NATIVE_REQUIREMENT_CLASS = /\AKandeloFormulaSupport::[A-Z][A-Za-z0-9]*Requirement\z/ ++ NATIVE_FORMULA_CONSTANT = :KANDELO_NATIVE_FORMULA ++ NATIVE_SENTINEL_CONSTANT = :KANDELO_NATIVE_SENTINEL + TAP_NAME = /\A[a-z0-9._-]+\/[a-z0-9._-]+\z/ + TAP_REPOSITORY = /\A[a-z0-9._-]+\/homebrew-[a-z0-9._-]+\z/ + GNU_TAR_ENV = "HOMEBREW_KANDELO_GNU_TAR" @@ -264,6 +286,69 @@ index 0000000000..ff90519c8 + tab.source = archived_source + end + ++ def self.evaluated_native_requirements(formula, plan = dependency_plan(formula)) ++ expected = plan.fetch("native_requirements") ++ actual = formula.requirements.filter_map do |requirement| ++ requirement_class = requirement.class ++ class_name = requirement_class.name ++ next unless class_name.is_a?(String) && class_name.start_with?("KandeloFormulaSupport::") ++ ++ metadata_constants = [NATIVE_FORMULA_CONSTANT, NATIVE_SENTINEL_CONSTANT] ++ unless metadata_constants.all? { |constant| requirement_class.const_defined?(constant, false) } ++ raise "Kandelo publisher native Requirement lacks sealed metadata" ++ end ++ formula_name = requirement_class.const_get(NATIVE_FORMULA_CONSTANT, false) ++ sentinel = requirement_class.const_get(NATIVE_SENTINEL_CONSTANT, false) ++ tags = requirement.tags ++ unless formula_name.is_a?(String) && sentinel.is_a?(String) && tags.is_a?(Array) && ++ tags.all? { |tag| tag.is_a?(Symbol) } ++ raise "Kandelo publisher native Requirement has invalid evaluated metadata" ++ end ++ ++ { ++ "class" => class_name, ++ "formula" => formula_name, ++ "sentinel" => sentinel, ++ "tags" => tags.map(&:to_s).sort, ++ } ++ end.sort_by { |requirement| requirement.fetch("class") } ++ unless actual == expected ++ raise "Kandelo publisher native Requirements differ from the sealed dependency plan" ++ end ++ ++ actual ++ end ++ ++ def self.activate_native_test_requirements!(formula, env) ++ plan = dependency_plan(formula) ++ return if plan.nil? ++ ++ evaluated_native_requirements(formula, plan).each do |requirement| ++ next unless requirement.fetch("tags").include?("test") ++ ++ opt_prefix = HOMEBREW_PREFIX/"opt"/requirement.fetch("formula") ++ sentinel = requirement.fetch("sentinel") ++ sentinel_paths = [opt_prefix/"bin"/sentinel, opt_prefix/"sbin"/sentinel] ++ unless sentinel_paths.any?(&:executable?) ++ raise "Kandelo publisher native test Requirement sentinel is unavailable" ++ end ++ ++ [opt_prefix/"bin", opt_prefix/"sbin"].each do |path| ++ env.prepend_path("PATH", path.to_s) if path.directory? ++ end ++ [opt_prefix/"lib/pkgconfig", opt_prefix/"share/pkgconfig"].each do |path| ++ env.prepend_path("PKG_CONFIG_PATH", path.to_s) if path.directory? ++ end ++ aclocal = opt_prefix/"share/aclocal" ++ env.prepend_path("ACLOCAL_PATH", aclocal.to_s) if aclocal.directory? ++ env.prepend_path("CMAKE_PREFIX_PATH", opt_prefix.to_s) ++ lib = opt_prefix/"lib" ++ include_dir = opt_prefix/"include" ++ env.prepend("LDFLAGS", "-L#{lib}") if lib.directory? ++ env.prepend("CPPFLAGS", "-I#{include_dir}") if include_dir.directory? ++ end ++ end ++ + def self.dependency_plan(formula = nil, require_match: true) + plan_path = HOMEBREW_PREFIX/PLAN_FILENAME + return unless plan_path.exist? @@ -280,7 +365,7 @@ index 0000000000..ff90519c8 + end + + plan = JSON.parse(contents) -+ identified_plan = plan.is_a?(Hash) && plan.keys.sort == PLAN_KEYS && plan["schema"] == 3 && ++ identified_plan = plan.is_a?(Hash) && plan.keys.sort == PLAN_KEYS && plan["schema"] == 4 && + 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 @@ -305,7 +390,8 @@ index 0000000000..ff90519c8 + + arrays = %w[build build_and_test runtime_and_test].to_h do |key| + value = plan[key] -+ valid_names = value.is_a?(Array) && value == value.sort.uniq && ++ valid_names = value.is_a?(Array) && value.length <= MAX_DEPENDENCIES && ++ value == value.sort.uniq && + value.all? { |name| name.is_a?(String) && DEPENDENCY_NAME.match?(name) } + raise "Kandelo publisher dependency plan contains invalid #{key} names" unless valid_names + @@ -315,6 +401,32 @@ index 0000000000..ff90519c8 + (arrays.fetch("runtime_and_test") - arrays.fetch("build_and_test")).empty? + raise "Kandelo publisher dependency plan subsets are inconsistent" unless consistent_subsets + ++ native_requirements = plan["native_requirements"] ++ valid_native_requirements = native_requirements.is_a?(Array) && ++ native_requirements.length <= 128 && ++ native_requirements.all? do |requirement| ++ next false unless requirement.is_a?(Hash) && requirement.keys.sort == NATIVE_REQUIREMENT_KEYS ++ ++ class_name = requirement["class"] ++ formula_name = requirement["formula"] ++ sentinel = requirement["sentinel"] ++ tags = requirement["tags"] ++ class_name.is_a?(String) && NATIVE_REQUIREMENT_CLASS.match?(class_name) && ++ formula_name.is_a?(String) && DEPENDENCY_NAME.match?(formula_name) && ++ sentinel.is_a?(String) && EXECUTABLE_NAME.match?(sentinel) && ++ [["build"], ["build", "test"]].include?(tags) && ++ arrays.fetch("build").include?(formula_name) && ++ arrays.fetch("build_and_test").include?(formula_name) && ++ arrays.fetch("runtime_and_test").include?(formula_name) == tags.include?("test") ++ end ++ native_classes = valid_native_requirements ? native_requirements.map { |item| item.fetch("class") } : [] ++ native_formulae = valid_native_requirements ? native_requirements.map { |item| item.fetch("formula") } : [] ++ valid_native_requirements &&= native_classes == native_classes.sort.uniq && ++ native_formulae.length == native_formulae.uniq.length ++ unless valid_native_requirements ++ raise "Kandelo publisher dependency plan contains invalid native Requirements" ++ end ++ + return plan if formula.nil? + + matches_formula = plan["formula"] == formula.name && plan["full_name"] == formula.full_name @@ -326,6 +438,27 @@ index 0000000000..ff90519c8 + raise "Kandelo publisher dependency plan is invalid JSON: #{e.message}" + end +end +diff --git a/Library/Homebrew/test.rb b/Library/Homebrew/test.rb +index b0c5227092..2d2350a54b 100644 +--- a/Library/Homebrew/test.rb ++++ b/Library/Homebrew/test.rb +@@ -8,6 +8,7 @@ old_trap = trap("INT") { exit! 130 } + require_relative "global" + require "extend/ENV" ++require "kandelo_publisher" + require "timeout" + require "formula_assertions" + require "formula_free_port" + require "fcntl" +@@ -46,7 +47,8 @@ begin + end + + ENV.extend(Stdenv) + ENV.setup_build_environment(formula:, testing_formula: true) ++ KandeloPublisher.activate_native_test_requirements!(formula, ENV) + Pathname.activate_extensions! + + run_test = proc do |_| diff --git a/Library/Homebrew/trust.rb b/Library/Homebrew/trust.rb index 07c9edd42b..3de1d9be03 100644 --- a/Library/Homebrew/trust.rb diff --git a/homebrew/patches/README.md b/homebrew/patches/README.md new file mode 100644 index 0000000000..d2c9592310 --- /dev/null +++ b/homebrew/patches/README.md @@ -0,0 +1,19 @@ +# Homebrew patch licensing + +Homebrew's source tree is distributed under the +[BSD 2-Clause License](https://github.com/Homebrew/brew/blob/4ead8619231cb15cbe15e8e8188081e347d6f7cd/LICENSE.txt). + +`0001-add-kandelo-wasm-bottle-tags.patch` was authored in the Kandelo +repository by Brandon Payton across commits `1ab41fe2a`, `6efb411f1`, and +`f84c57f40`. Kandelo's `README.md` explicitly assigns +`GPL-2.0-or-later` to platform and build-script code, and the root +`Cargo.toml` records the same project license. The platform patch has no +separate permissive-license grant in its file or commits. Distribution +metadata therefore keeps the patch at the documented Kandelo project boundary +instead of inferring a Homebrew BSD grant. The prepared `homebrew-bootstrap` +tree declares the composite SPDX expression +`BSD-2-Clause AND GPL-2.0-or-later`. + +This notice deliberately does not infer a BSD relicensing from the upstream +project's license. A future explicit grant from the patch copyright holder can +narrow the package expression without changing the prepared Homebrew bytes. diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 5d44799377..5edb12b9ef 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -189,8 +189,8 @@ export class BrowserKernel { async boot(options: BrowserKernelBootOptions): Promise<{ pid: number; exit: Promise }> { await this.initFromImage(options); - // Spawn the first process — kernel worker assigns the pid and returns - // it in the response. Pid is the single source of truth in the worker. + // The Rust ProcessTable allocates the first PID; the worker transports it + // in the response and does not maintain an allocation authority of its own. return this.spawnFirstProcess(options); } @@ -338,9 +338,9 @@ export class BrowserKernel { } /** - * Internal: send a spawn message for the first ("init") process. The - * worker allocates the pid and returns it in the response. The exit - * promise is wired up after the pid is known. + * Internal: send a spawn message for the first user process. The + * Rust kernel allocates the pid; the worker returns it in the response. + * The exit promise is wired up after the pid is known. */ private async spawnFirstProcess( options: BrowserKernelBootOptions, @@ -352,7 +352,7 @@ export class BrowserKernel { const pid = await this.request(requestId, { type: "spawn", requestId, - // No pid — the kernel worker allocates and returns it. + // No pid — the Rust kernel allocates it and the worker returns it. programPath: options.argv[0], argv: options.argv, env: this.mergeEnv(options.env ?? this.options.env), @@ -455,7 +455,7 @@ export class BrowserKernel { /** * Spawn a process whose binary already lives in the kernel-owned VFS. - * Returns the worker-allocated pid + an exit promise. + * Returns the kernel-allocated pid + an exit promise. * * This does not transfer any `programBytes` across the worker boundary — * the kernel reads the binary out of its own memfs at `programPath`. Use @@ -463,8 +463,8 @@ export class BrowserKernel { * the VFS) to avoid re-shipping multi-megabyte binaries the kernel already * has. * - * Like every top-level spawn path, the kernel worker allocates and returns - * the pid. + * Like every top-level spawn path, the Rust kernel allocates the pid and + * the worker returns it. */ async spawnFromVfs( programPath: string, diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index d648a8a2c1..96f268ba7e 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -20,6 +20,7 @@ import type { ForkFromThreadContext, ResolvedSpawnProgram, SpawnProgramResolution, + ThreadChannelAttachment, } from "./kernel-worker"; import type { KernelPointer } from "./kernel"; import { BrowserWorkerAdapter } from "./worker-adapter-browser"; @@ -30,6 +31,7 @@ import { } from "./vfs/vfs"; import { MemoryFileSystem } from "./vfs/memory-fs"; import { createClosedLazyAssetFetcherFromOwnedAssets } from "./vfs/closed-lazy-assets"; +import { resolveLazyUrl } from "./vfs/lazy-url"; import { DeviceFileSystem } from "./vfs/device-fs"; import { BrowserTimeProvider } from "./vfs/time"; import { @@ -41,6 +43,7 @@ import { TlsNetworkBackend } from "./networking/tls-network-backend"; import { patchWasmForThread } from "./worker-main"; import { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; +import { readForkContinuationAnchor } from "./fork-continuation"; import { classifiedSignalOrFallback, classifiedTrapExitStatus, @@ -557,11 +560,6 @@ function createFreshProcessMemory( }; } -function resolveLazyUrl(base: string, url: string): string { - if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("/")) return url; - return base.replace(/\/?$/, "/") + url; -} - // ── Init ── async function handleInit(msg: Extract) { @@ -678,8 +676,7 @@ async function handleInit(msg: Extract) { }, onResolveSpawn: handlePosixSpawnResolve, onSpawn: handlePosixSpawn, - onClone: (pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory) => - handleClone(pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory), + onClone: handleClone, onThreadExit: (pid, _tid, channelOffset) => handleThreadExit(pid, channelOffset), onExit: (pid, exitStatus) => handleExit(pid, exitStatus), }, @@ -781,7 +778,7 @@ async function handleInit(msg: Extract) { // ── Spawn ── async function handleSpawn(msg: Extract) { - let registeredPid: number | undefined; + let createdPid: number | undefined; try { await waitForProcessTeardowns(); @@ -806,7 +803,10 @@ async function handleSpawn(msg: Extract) return; } - const pid = kernelWorker.allocateTopLevelSpawnPid(); + const pid = kernelWorker.createProcess( + msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, + ); + createdPid = pid; const path = msg.programPath ?? msg.argv[0]; const pages = msg.maxPages ?? maxPages; const ptrWidth = detectPtrWidth(programBytes); @@ -829,9 +829,7 @@ async function handleSpawn(msg: Extract) brkBase: layout.brkBase, mmapBase: layout.mmapBase, maxAddr: layout.maxAddr, - stdio: msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, }); - registeredPid = pid; kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); if (msg.cwd) { @@ -858,7 +856,6 @@ async function handleSpawn(msg: Extract) const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes, memory, channelOffset, @@ -882,12 +879,13 @@ async function handleSpawn(msg: Extract) }); installProcessWorkerListeners(worker, pid); - registeredPid = undefined; + createdPid = undefined; respond(msg.requestId, pid); } catch (e) { - if (registeredPid !== undefined) { - kernelWorker.unregisterProcess(registeredPid); + if (createdPid !== undefined) { + kernelWorker.unregisterProcess(createdPid); + kernelWorker.removeProcessFromKernelTable(createdPid); } respondError(msg.requestId, String(e)); } @@ -1046,25 +1044,30 @@ async function handleFork( new Uint8Array(childMemory.buffer, childChannelOffset, CH_TOTAL_SIZE).fill(0); kernelWorker.registerProcess(childPid, childMemory, [childChannelOffset], { - skipKernelCreate: true, ptrWidth, maxAddr: childLayout.maxAddr, mmapBase: childLayout.mmapBase, }); kernelWorker.inheritProcessSharedMappings(parentPid, childPid); + const activeForkBufAddr = threadFork?.forkBufAddr ?? readForkContinuationAnchor( + parentMemory, + parentInfo.channelOffset - FORK_BUF_SIZE, + ptrWidth, + ); const forkReplayContext: ForkReplayContext | undefined = threadFork ? { fnPtr: threadFork.fnPtr, argPtr: threadFork.argPtr, - forkBufAddr: threadFork.forkBufAddr, + forkBufAddr: activeForkBufAddr, } - : parentInfo.forkReplayContext; - const forkBufAddr = forkReplayContext?.forkBufAddr ?? childChannelOffset - FORK_BUF_SIZE; + : parentInfo.forkReplayContext + ? { ...parentInfo.forkReplayContext, forkBufAddr: activeForkBufAddr } + : undefined; + const forkBufAddr = activeForkBufAddr; const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: parentPid, programBytes: parentInfo.programBytes, programModule: parentInfo.programModule, memory: childMemory, @@ -1154,9 +1157,9 @@ async function handleExec( return -12; // ENOMEM } - // Resolution/compilation yielded to the event loop. The numeric pid may - // now name a replacement generation; a stale continuation must not commit - // exec state against it. + // Resolution/compilation yielded to the event loop. Another exec may have + // replaced the host execution generation for this persistent PID; a stale + // continuation must not commit exec state against it. if (processes.get(pid) !== initiatingInfo || kernelWorker.isExecHandoffActive(pid) || !kernelWorker.isProcessExecutionActive(pid)) return -3; // ESRCH @@ -1207,7 +1210,6 @@ async function handleExec( const execInitData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes: bytes, programModule, memory: newMemory, @@ -1219,7 +1221,7 @@ async function handleExec( }; kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - skipKernelCreate: true, + preserveProcessState: true, ptrWidth, metadataPtrWidth: initiatingInfo.ptrWidth, brkBase: newLayout.brkBase, @@ -1309,8 +1311,7 @@ async function handleExec( * 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 - * registers it with the kernel - * (`skipKernelCreate: true` — kernel did its half), and spawns a Worker. + * 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 @@ -1368,7 +1369,6 @@ async function handlePosixSpawn( // Kernel already created the child via kernel_spawn_process. kernelWorker.registerProcess(childPid, newMemory, [newChannelOffset], { - skipKernelCreate: true, ptrWidth, brkBase: newLayout.brkBase, mmapBase: newLayout.mmapBase, @@ -1378,7 +1378,6 @@ async function handlePosixSpawn( const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: parentPid, programBytes, programModule, memory: newMemory, @@ -1432,15 +1431,10 @@ async function handlePosixSpawn( } async function handleClone( - pid: number, - tid: number, - fnPtr: number, - argPtr: number, - stackPtr: number, - tlsPtr: number, - ctidPtr: number, - memory: WebAssembly.Memory, -): Promise { + attachment: ThreadChannelAttachment, +): Promise { + const { pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory } = + attachment; const processInfo = processes.get(pid); if (!processInfo) throw new Error(`Unknown pid ${pid} for clone`); threadedProcessPids.add(pid); @@ -1460,7 +1454,7 @@ async function handleClone( // Compilation yields. A sibling pthread may have committed exec while this // clone continuation was suspended; never attach the old program/Memory to - // the replacement process that now owns the same numeric pid. + // the replacement exec image for the same process identity. if (!isCurrentProcessGeneration( processes, pid, @@ -1489,7 +1483,7 @@ async function handleClone( // thread back through its entry point. Mirrors handleClone in // host/src/node-kernel-worker-entry.ts. try { - kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + kernelWorker.attachThreadChannel(attachment, alloc.channelOffset); } catch (err) { processInfo.threadAllocator.free(alloc.basePage); throw err; @@ -1627,7 +1621,6 @@ async function handleClone( throw new Error(`Process ${pid} changed generation before thread Worker launch`); } - return tid; } function handleThreadExit(pid: number, channelOffset: number): boolean { diff --git a/host/src/browser.ts b/host/src/browser.ts index 424afa5ff0..8c9b74c797 100644 --- a/host/src/browser.ts +++ b/host/src/browser.ts @@ -2,7 +2,12 @@ export { WasmPosixKernel } from "./kernel"; export type { KernelCallbacks } from "./kernel"; export { CentralizedKernelWorker } from "./kernel-worker"; -export type { CentralizedKernelCallbacks, ProcessSnapshot, SyscallTraceEvent } from "./kernel-worker"; +export type { + CentralizedKernelCallbacks, + ProcessSnapshot, + SyscallTraceEvent, + ThreadChannelAttachment, +} from "./kernel-worker"; export { SYSCALL_NAMES } from "./kernel-worker"; export { SyscallChannel, ChannelStatus } from "./channel"; export { SharedPipeBuffer } from "./shared-pipe-buffer"; @@ -24,7 +29,6 @@ export type { HostDiagnostic } from "./host-diagnostic"; export type { HostToWorkerMessage, WorkerToHostMessage, WorkerReadyMessage, WorkerExitMessage, WorkerErrorMessage, - DeliverSignalMessage, ExecRequestMessage, ExecReplyMessage, ExecCompleteMessage, AlarmSetMessage, CentralizedWorkerInitMessage, @@ -37,6 +41,7 @@ export type { LazyDownloadListener, LazyDownloadStatus, LazyFileEntry, + LazyFetcherOptions, LazyTreeActivation, LazyTreeContent, LazyTreeDecoder, diff --git a/host/src/constants.ts b/host/src/constants.ts index b0188ca708..57fce65c35 100644 --- a/host/src/constants.ts +++ b/host/src/constants.ts @@ -3,6 +3,15 @@ import { PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_DECL_EXPORT, PROCESS_MEMORY_WASM_PAGE_SIZE, + WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, + 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, } from "./generated/abi"; /** WebAssembly page size (64 KiB) */ @@ -272,13 +281,381 @@ function containsAscii(src: Uint8Array, needle: string): boolean { * Any program that can reach `kernel.kernel_fork` must export all of these so * the host can unwind the parent and rewind the child at the fork point. */ -export const WPK_FORK_EXPORTS = [ - "wpk_fork_unwind_begin", - "wpk_fork_unwind_end", - "wpk_fork_rewind_begin", - "wpk_fork_rewind_end", - "wpk_fork_state", -] as const; +export const WPK_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map(({ name }) => name); + +interface WasmFunctionSignature { + params: number[]; + results: number[]; +} + +interface WasmForkArtifactFacts { + functionImports: Map; + functionExports: Map; + memoryPointerWidths: number[]; + linkedFrameDescriptors: Uint8Array[]; + importsKernelFork: boolean; +} + +function appendSignature( + signatures: Map, + identity: string, + signature: WasmFunctionSignature | undefined, +): void { + if (!signature) { + throw new Error(`function ${identity} refers to an unknown type`); + } + const values = signatures.get(identity) ?? []; + values.push(signature); + signatures.set(identity, values); +} + +function readLimits( + src: Uint8Array, + pos: number, +): { flags: number; next: number } { + const [flags, flagBytes] = readULEB128(src, pos); + pos += flagBytes; + const [, minBytes] = readULEB128(src, pos); + pos += minBytes; + if ((flags & 1) !== 0) { + const [, maxBytes] = readULEB128(src, pos); + pos += maxBytes; + } + return { flags, next: pos }; +} + +/** + * Parse the portions of a final Wasm module that jointly define the ABI 42 + * fork-artifact contract. + * + * WHY: names alone can look complete while the host and guest disagree about + * i32/i64 pointers. Release and resolver acceptance must validate the actual + * memory architecture, descriptor, and function types as one atomic contract. + */ +function readWasmForkArtifactFacts(programBytes: ArrayBuffer): WasmForkArtifactFacts { + const src = new Uint8Array(programBytes); + if (!hasWasmMagic(src)) throw new Error("not a wasm binary"); + + const functionTypes: WasmFunctionSignature[] = []; + const functionTypeIndices: number[] = []; + const pendingFunctionExports: Array<{ name: string; index: number }> = []; + const facts: WasmForkArtifactFacts = { + functionImports: new Map(), + functionExports: new Map(), + memoryPointerWidths: [], + linkedFrameDescriptors: [], + importsKernelFork: false, + }; + + let offset = 8; + while (offset < src.length) { + const sectionId = src[offset]; + const [sectionSize, sizeBytes] = readULEB128(src, offset + 1); + const contentOffset = offset + 1 + sizeBytes; + const sectionEnd = contentOffset + sectionSize; + if (sectionEnd > src.length) throw new Error("wasm section exceeds file size"); + let pos = contentOffset; + let requireFullyConsumed = false; + + if (sectionId === 0) { + const [name, afterName] = readName(src, pos); + if (name === WPK_FORK_LINKED_FRAME_FORMAT_SECTION) { + facts.linkedFrameDescriptors.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 }); + } + } else if (sectionId === 2) { + requireFullyConsumed = true; + const [count, countBytes] = readULEB128(src, pos); + pos += countBytes; + for (let i = 0; i < count; i++) { + const [moduleName, afterModule] = readName(src, pos); + const [fieldName, afterField] = readName(src, afterModule); + pos = afterField; + const kind = src[pos++]; + if (kind === 0) { + const [typeIndex, typeBytes] = readULEB128(src, pos); + pos += typeBytes; + functionTypeIndices.push(typeIndex); + const identity = `${moduleName}.${fieldName}`; + appendSignature(facts.functionImports, identity, functionTypes[typeIndex]); + if (identity === "kernel.kernel_fork") facts.importsKernelFork = true; + } else if (kind === 1) { + pos++; // reference type + pos = readLimits(src, pos).next; + } 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 + } else if (kind === 4) { + pos++; // tag attribute + const [, typeBytes] = readULEB128(src, pos); + pos += typeBytes; + } else { + throw new Error(`unsupported wasm import kind ${kind}`); + } + } + } else if (sectionId === 3) { + requireFullyConsumed = true; + const [count, countBytes] = readULEB128(src, pos); + pos += countBytes; + for (let i = 0; i < count; i++) { + const [typeIndex, typeBytes] = readULEB128(src, pos); + pos += typeBytes; + functionTypeIndices.push(typeIndex); + } + } else if (sectionId === 5) { + requireFullyConsumed = true; + const [count, countBytes] = readULEB128(src, pos); + pos += countBytes; + for (let i = 0; i < count; i++) { + const limits = readLimits(src, pos); + pos = limits.next; + facts.memoryPointerWidths.push((limits.flags & 4) !== 0 ? 8 : 4); + } + } else if (sectionId === 7) { + requireFullyConsumed = true; + const [count, countBytes] = readULEB128(src, pos); + pos += countBytes; + for (let i = 0; i < count; i++) { + const [name, afterName] = readName(src, pos); + pos = afterName; + const kind = src[pos++]; + const [index, indexBytes] = readULEB128(src, pos); + pos += indexBytes; + if (kind === 0) pendingFunctionExports.push({ name, index }); + } + } + + if (requireFullyConsumed && pos !== sectionEnd) { + throw new Error(`malformed wasm section ${sectionId}`); + } + offset = sectionEnd; + } + + for (const { name, index } of pendingFunctionExports) { + const typeIndex = functionTypeIndices[index]; + appendSignature(facts.functionExports, name, functionTypes[typeIndex]); + } + return facts; +} + +function validateLinkedFrameDescriptor(descriptor: Uint8Array): number { + if (descriptor.byteLength !== WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE) { + throw new Error( + `linked-frame descriptor has ${descriptor.byteLength} bytes, expected ${WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE}`, + ); + } + if (!WPK_FORK_LINKED_FRAME_FORMAT_MAGIC.every((byte, index) => descriptor[index] === byte)) { + throw new Error("linked-frame descriptor has invalid magic"); + } + const view = new DataView( + descriptor.buffer, + descriptor.byteOffset, + descriptor.byteLength, + ); + const version = view.getUint16(4, true); + if (version !== WPK_FORK_LINKED_FRAME_FORMAT_VERSION) { + throw new Error(`linked-frame descriptor version ${version} is unsupported`); + } + const declaredSize = view.getUint16(6, true); + if (declaredSize !== WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE) { + throw new Error( + `linked-frame descriptor declares size ${declaredSize}, expected ${WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE}`, + ); + } + const pointerWidth = view.getUint8(8); + const pointerFormat = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS.find( + ({ bytes }) => bytes === pointerWidth, + ); + if (!pointerFormat) { + throw new Error(`linked-frame descriptor pointer width ${pointerWidth} is unsupported`); + } + if (view.getUint8(9) !== WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT) { + throw new Error( + `linked-frame descriptor alignment ${view.getUint8(9)} is unsupported`, + ); + } + const flags = view.getUint16(10, true); + if (flags !== WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS) { + throw new Error( + `linked-frame descriptor flags 0x${flags.toString(16)} do not equal required flags 0x${WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS.toString(16)}`, + ); + } + if ( + view.getUint32(12, true) !== pointerFormat.chunkHeaderSize || + view.getUint32(16, true) !== pointerFormat.nodeHeaderSize + ) { + throw new Error( + `linked-frame descriptor header sizes do not match its ${pointerWidth}-byte pointer width`, + ); + } + return pointerFormat.bytes; +} + +function expectedWasmValueType( + value: "ptr" | "i32", + pointerWidth: number, +): number { + if (value === "i32") return 0x7f; + return pointerWidth === 8 ? 0x7e : 0x7f; +} + +function signatureMatches( + actual: WasmFunctionSignature, + params: readonly ("ptr" | "i32")[], + results: readonly ("ptr" | "i32")[], + pointerWidth: number, +): boolean { + return actual.params.length === params.length && + actual.results.length === results.length && + actual.params.every((value, index) => + value === expectedWasmValueType(params[index], pointerWidth) + ) && + actual.results.every((value, index) => + value === expectedWasmValueType(results[index], pointerWidth) + ); +} + +function signatureText( + params: readonly ("ptr" | "i32")[], + results: readonly ("ptr" | "i32")[], + pointerWidth: number, +): string { + const render = (value: "ptr" | "i32") => + value === "ptr" ? (pointerWidth === 8 ? "i64" : "i32") : "i32"; + return `(${params.map(render).join(", ")}) -> (${results.map(render).join(", ")})`; +} + +function describeForkArtifactContractFailures( + facts: WasmForkArtifactFacts, +): string[] { + const failures: string[] = []; + 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}`); + } + } + const missingExports = WPK_FORK_REQUIRED_EXPORTS + .filter(({ name }) => !facts.functionExports.has(name)) + .map(({ name }) => name); + if (missingExports.length > 0) { + failures.push( + `incomplete wasm-fork-instrument exports; missing ${missingExports.join(", ")}`, + ); + } + + let pointerWidth: number | null = null; + if (facts.linkedFrameDescriptors.length === 0) { + failures.push(`missing required ${WPK_FORK_LINKED_FRAME_FORMAT_SECTION} descriptor`); + } else if (facts.linkedFrameDescriptors.length !== 1) { + failures.push( + `has ${facts.linkedFrameDescriptors.length} ${WPK_FORK_LINKED_FRAME_FORMAT_SECTION} descriptors, expected exactly one`, + ); + } else { + try { + pointerWidth = validateLinkedFrameDescriptor(facts.linkedFrameDescriptors[0]); + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)); + } + } + + const presentFrameImports = WPK_FORK_REQUIRED_IMPORTS.filter(({ module, name }) => + facts.functionImports.has(`${module}.${name}`) + ); + const requiresFrameImports = facts.importsKernelFork || presentFrameImports.length > 0; + 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 42 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}`); + } + } + } + + if (pointerWidth !== null) { + if (facts.memoryPointerWidths.length !== 1) { + failures.push( + `ABI 42 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`, + ); + } + for (const requirement of WPK_FORK_REQUIRED_EXPORTS) { + const signatures = facts.functionExports.get(requirement.name); + if ( + signatures?.length === 1 && + !signatureMatches( + signatures[0], + requirement.params, + requirement.results, + pointerWidth, + ) + ) { + failures.push( + `ABI 42 wasm-fork-instrument export ${requirement.name} has the wrong signature; expected ${ + signatureText(requirement.params, requirement.results, pointerWidth) + }`, + ); + } + } + if (requiresFrameImports) { + for (const requirement of WPK_FORK_REQUIRED_IMPORTS) { + const identity = `${requirement.module}.${requirement.name}`; + const signatures = facts.functionImports.get(identity); + if ( + signatures?.length === 1 && + !signatureMatches( + signatures[0], + requirement.params, + requirement.results, + pointerWidth, + ) + ) { + failures.push( + `ABI 42 linked-frame import ${identity} has the wrong signature; expected ${ + signatureText(requirement.params, requirement.results, pointerWidth) + }`, + ); + } + } + } + } + + return failures; +} /** * Return import names in `module.field` form. This is intentionally a small @@ -393,8 +770,15 @@ export function wasmImportsKernelFork(programBytes: ArrayBuffer): boolean { } export function wasmHasCompleteForkInstrumentation(programBytes: ArrayBuffer): boolean { - const exports = new Set(readWasmExportNames(programBytes)); - return WPK_FORK_EXPORTS.every((name) => exports.has(name)); + try { + const facts = readWasmForkArtifactFacts(programBytes); + const hasForkSurface = WPK_FORK_REQUIRED_EXPORTS.some(({ name }) => + facts.functionExports.has(name) + ) || facts.linkedFrameDescriptors.length > 0; + return hasForkSurface && describeForkArtifactContractFailures(facts).length === 0; + } catch { + return false; + } } export function wasmIsRelocatableObject(programBytes: ArrayBuffer): boolean { @@ -433,21 +817,36 @@ export function describeWasmArtifactPolicyFailures( } const presentWpkExports = WPK_FORK_EXPORTS.filter((name) => exports.has(name)); - if (options.forbidForkInstrumentation && presentWpkExports.length > 0) { - failures.push("contains wasm-fork-instrument exports"); + const importNames = readWasmImportNames(programBytes); + const customSections = readWasmCustomSectionNames(programBytes); + const presentWpkImports = WPK_FORK_REQUIRED_IMPORTS.filter(({ module, name }) => + importNames.includes(`${module}.${name}`) + ); + const descriptorCount = customSections.filter((name) => + name === WPK_FORK_LINKED_FRAME_FORMAT_SECTION + ).length; + const hasForkArtifactSurface = + presentWpkExports.length > 0 || presentWpkImports.length > 0 || descriptorCount > 0; + if (options.forbidForkInstrumentation && hasForkArtifactSurface) { + failures.push("contains ABI 42 wasm-fork-instrument metadata, imports, or exports"); } const requireForkInstrumentation = options.requireForkInstrumentation ?? !wasmIsRelocatableObject(programBytes); - if (requireForkInstrumentation) { - const hasCompleteForkInstrumentation = presentWpkExports.length === WPK_FORK_EXPORTS.length; - if (presentWpkExports.length > 0 && !hasCompleteForkInstrumentation) { - const missing = WPK_FORK_EXPORTS.filter((name) => !exports.has(name)); - failures.push(`incomplete wasm-fork-instrument exports; missing ${missing.join(", ")}`); - } - - if (wasmImportsKernelFork(programBytes) && !hasCompleteForkInstrumentation) { - failures.push("imports kernel.kernel_fork without complete wasm-fork-instrument exports"); + if ( + requireForkInstrumentation && + (hasForkArtifactSurface || importNames.includes("kernel.kernel_fork")) + ) { + try { + failures.push( + ...describeForkArtifactContractFailures(readWasmForkArtifactFacts(programBytes)), + ); + } catch (error) { + failures.push( + `cannot validate ABI 42 fork-artifact contract: ${ + error instanceof Error ? error.message : String(error) + }`, + ); } } diff --git a/host/src/dylink.ts b/host/src/dylink.ts index c2dc9a9605..2f2d1171bc 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -6,8 +6,19 @@ * https://github.com/WebAssembly/tool-conventions/blob/main/DynamicLinking.md */ -import { ABI_VERSION } from "./generated/abi"; -import { FORK_SAVE_BUFFER_SIZE } from "./process-memory"; +import { + ABI_VERSION, + WPK_FORK_REQUIRED_EXPORTS, + WPK_FORK_REQUIRED_IMPORTS, +} from "./generated/abi"; +import { + ContinuationAllocationError, + invokeForkContinuationBegin, + LinkedForkContinuation, + readLinkedFrameFormat, + type ContinuationAllocate, + type ContinuationDeallocate, +} from "./fork-continuation"; // dylink.0 sub-section types const WASM_DYLINK_MEM_INFO = 1; @@ -19,13 +30,9 @@ const WASM_DYLINK_IMPORT_INFO = 4; const WASM_DYLINK_FLAG_TLS = 0x01; const WASM_DYLINK_FLAG_WEAK = 0x02; -export const SIDE_MODULE_FORK_EXPORTS = [ - "wpk_fork_unwind_begin", - "wpk_fork_unwind_end", - "wpk_fork_rewind_begin", - "wpk_fork_rewind_end", - "wpk_fork_state", -] as const; +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; @@ -37,6 +44,7 @@ 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. */ @@ -374,6 +382,7 @@ export interface LoadedSharedLibrary { name: string; /** Fork save buffer for an instrumented side module importing env.fork. */ forkBufAddr?: number; + forkContinuation?: LinkedForkContinuation; /** Thread-local-storage base captured from the parent instance. */ tlsBase?: number; /** Whether this module can originate a coordinated env.fork unwind. */ @@ -390,8 +399,7 @@ export interface SideModuleForkState { name: string; instance: WebAssembly.Instance; forkBufAddr: number; - /** Byte capacity reserved for this module's continuation frames. */ - forkBufSize: number; + continuation: LinkedForkContinuation; } /** @@ -406,7 +414,9 @@ 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) => number; + invokeMainFork: (expectedStateAfter: 0 | 1 | readonly (0 | 1)[]) => number; + /** Put the already-unwinding main image into allocation-failure replay. */ + beginMainAbort: (errno: number) => void; } /** @@ -455,6 +465,10 @@ export interface LoadSharedLibraryOptions { allocateMemory?: (size: number, align: number) => number; /** 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; /** GOT entries: symbol name → mutable pointer-width WebAssembly.Global */ @@ -656,6 +670,14 @@ function instantiateSharedLibrary( const importsFork = moduleImports.some((imp) => imp.module === "env" && imp.name === "fork" && imp.kind === "function" ); + const linkedFrameImportNames = 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 presentForkExports = SIDE_MODULE_FORK_EXPORTS.filter((exportName) => moduleExports.some((exp) => exp.kind === "function" && exp.name === exportName) ); @@ -740,6 +762,12 @@ function instantiateSharedLibrary( "rebuild with the current wasm-fork-instrument --entry env.fork", ); } + if (linkedFrameImportCount !== 0 && linkedFrameImportCount !== linkedFrameImportNames.length) { + 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`); + } if (claimsSideEntry && !importsFork) { throw new Error(`${name}: side-entry capability is present without an env.fork import`); } @@ -840,25 +868,22 @@ function instantiateSharedLibrary( 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; - } else if (options.allocateMemory) { - sideForkBufAddr = allocate(FORK_SAVE_BUFFER_SIZE, 16); - } else if (options.heapPointer) { - sideForkBufAddr = alignUp(options.heapPointer.value, 16); - options.heapPointer.value = sideForkBufAddr + FORK_SAVE_BUFFER_SIZE; - const neededPages = Math.ceil(options.heapPointer.value / 65536); - const currentPages = options.memory.buffer.byteLength / 65536; - if (neededPages > currentPages) { - growMemory(options.memory, neededPages - currentPages, ptrWidth); - } - } - if ( - sideForkBufAddr <= 0 - || sideForkBufAddr + FORK_SAVE_BUFFER_SIZE > options.memory.buffer.byteLength - ) { - throw new Error(`${name}: invalid side-module fork save buffer`); } } @@ -948,29 +973,56 @@ function instantiateSharedLibrary( }; const sideModuleForkImport = (): number => { - if (!instance || !options.sideModuleFork || sideForkBufAddr === 0) { + if (!instance || !options.sideModuleFork || !sideForkContinuation) { throw new Error(`${name}: side-module fork coordinator is unavailable`); } const state = forkState(); if (state === WPK_FORK_NORMAL) { - (instance.exports.wpk_fork_unwind_begin as (addr: WasmAddress) => void)( - wasmAddress(sideForkBufAddr, ptrWidth, `${name}: side-module fork save buffer`), + 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`); } - sideForkState = { + const startedState: SideModuleForkState = { name, instance, forkBufAddr: sideForkBufAddr, - forkBufSize: FORK_SAVE_BUFFER_SIZE, + continuation: sideForkContinuation!, }; - options.sideModuleFork.setActiveFork(sideForkState); - return options.sideModuleFork.invokeMainFork(WPK_FORK_UNWINDING); + 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`); } @@ -982,14 +1034,35 @@ function instantiateSharedLibrary( name, instance, forkBufAddr: sideForkBufAddr, - forkBufSize: FORK_SAVE_BUFFER_SIZE, + 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; + } + throw new Error(`${name}: env.fork reached in unexpected state ${state}`); }; @@ -1008,6 +1081,30 @@ function instantiateSharedLibrary( case "fork": if (importsFork) return sideModuleForkImport; 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`, + ); + } + 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; @@ -1027,6 +1124,7 @@ function instantiateSharedLibrary( "__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); }, }), @@ -1235,6 +1333,7 @@ function instantiateSharedLibrary( metadata, name, forkBufAddr: sideForkBufAddr || undefined, + forkContinuation: sideForkContinuation, tlsBase, forkCapable: importsFork, functionImports, diff --git a/host/src/fork-continuation.ts b/host/src/fork-continuation.ts new file mode 100644 index 0000000000..4fc4839253 --- /dev/null +++ b/host/src/fork-continuation.ts @@ -0,0 +1,851 @@ +import { WASM_PAGE_SIZE } from "./constants"; +import { + WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, + 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, +} from "./generated/abi"; +import { + checkedWasmGuestPointerOffset, + type WasmGuestPointer, +} from "./wasm-guest-pointer"; + +export const LINKED_FRAME_FORMAT_SECTION = WPK_FORK_LINKED_FRAME_FORMAT_SECTION; +export const LINKED_FRAME_FORMAT_VERSION = WPK_FORK_LINKED_FRAME_FORMAT_VERSION; +export const LINKED_FRAME_RECORD_ALIGNMENT = WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT; + +const DESCRIPTOR_SIZE = WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE; +const DESCRIPTOR_REQUIRED_FLAGS = WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS; +const CHUNK_MAGIC = 0x4843464b; // "KFCH", little-endian +const NODE_MAGIC = 0x4e43464b; // "KFCN", little-endian +const NODE_RESERVED = 1; +const NODE_COMMITTED = 2; +const NODE_CONSUMED = 3; + +export interface LinkedFrameFormatDescriptor { + version: number; + ptrWidth: 4 | 8; + alignment: number; + flags: number; + chunkHeaderSize: number; + nodeHeaderSize: number; + fixedPrefixSize: number; +} + +export type ContinuationAllocate = (size: number) => number; +export type ContinuationDeallocate = (addr: number, size: number) => void; +export type ForkContinuationGuestAddress = WasmGuestPointer; + +/** + * Invoke an instrumented continuation begin export with the module's exact + * pointer-width calling convention. + * + * WHY: WebAssembly i64 parameters require JavaScript BigInt even when the + * address itself fits in a Number. Keeping this conversion at the shared + * continuation boundary prevents main, pthread, and side-module paths from + * silently drifting apart. + */ +export function invokeForkContinuationBegin( + exported: unknown, + address: number, + ptrWidth: 4 | 8, + context: string, +): void { + if (typeof exported !== "function") { + throw new TypeError(`${context}: continuation begin export is not callable`); + } + if (!Number.isSafeInteger(address) || address <= 0) { + throw new RangeError(`${context}: invalid continuation address ${address}`); + } + const guestAddress: ForkContinuationGuestAddress = ptrWidth === 8 + ? BigInt(address) + : address; + (exported as (value: ForkContinuationGuestAddress) => void)(guestAddress); +} + +export class ContinuationAllocationError extends Error { + constructor( + readonly errno: number, + readonly requestedSize: number, + message: string, + ) { + super(message); + this.name = "ContinuationAllocationError"; + } +} + +interface AbortFailure { + errno: number; + requestedFrame?: number; + diagnostic: string; +} + +export function writeForkContinuationAnchor( + memory: WebAssembly.Memory, + anchorAddr: number, + ptrWidth: 4 | 8, + moduleBufferAddr: number, +): void { + const view = new DataView(memory.buffer); + if (ptrWidth === 8) view.setBigUint64(anchorAddr, BigInt(moduleBufferAddr), true); + else view.setUint32(anchorAddr, moduleBufferAddr, true); +} + +export function readForkContinuationAnchor( + memory: WebAssembly.Memory, + anchorAddr: number, + ptrWidth: 4 | 8, +): number { + const view = new DataView(memory.buffer); + const value = ptrWidth === 8 + ? Number(view.getBigUint64(anchorAddr, true)) + : view.getUint32(anchorAddr, true); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`invalid fork continuation anchor ${String(value)}`); + } + return value; +} + +// WHY: descriptor parsing must use the same Rust-generated layout table as +// publication guards; recomputing it here would let a future ABI change pass +// release validation and fail only when the host begins a continuation. +function linkedFramePointerFormat(ptrWidth: number) { + return WPK_FORK_LINKED_FRAME_POINTER_WIDTHS.find(({ bytes }) => bytes === ptrWidth); +} + +function alignUp(value: number, alignment: number): number { + const result = Math.ceil(value / alignment) * alignment; + if (!Number.isSafeInteger(result)) { + throw new Error(`linked continuation alignment overflow: ${value}`); + } + return result; +} + +function checkedEnd(addr: number, size: number): number { + const end = addr + size; + if ( + !Number.isSafeInteger(addr) + || !Number.isSafeInteger(size) + || addr < 0 + || size < 0 + || !Number.isSafeInteger(end) + ) { + throw new Error(`invalid linked continuation range addr=${addr} size=${size}`); + } + return end; +} + +export function readLinkedFrameFormat( + module: WebAssembly.Module, +): LinkedFrameFormatDescriptor { + const sections = WebAssembly.Module.customSections(module, LINKED_FRAME_FORMAT_SECTION); + if (sections.length !== 1) { + throw new Error( + `expected one ${LINKED_FRAME_FORMAT_SECTION} section, found ${sections.length}`, + ); + } + const bytes = new Uint8Array(sections[0]); + if (bytes.byteLength !== DESCRIPTOR_SIZE) { + throw new Error( + `linked continuation metadata has ${bytes.byteLength} bytes, expected ${DESCRIPTOR_SIZE}`, + ); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + if (!WPK_FORK_LINKED_FRAME_FORMAT_MAGIC.every((byte, index) => bytes[index] === byte)) { + throw new Error("linked continuation metadata has invalid magic"); + } + const version = view.getUint16(4, true); + if (version !== LINKED_FRAME_FORMAT_VERSION) { + throw new Error(`unsupported linked continuation metadata version ${version}`); + } + if (view.getUint16(6, true) !== DESCRIPTOR_SIZE) { + throw new Error("linked continuation metadata has an invalid declared size"); + } + const ptrWidth = view.getUint8(8); + const pointerFormat = linkedFramePointerFormat(ptrWidth); + if (!pointerFormat) { + throw new Error(`unsupported linked continuation pointer width ${ptrWidth}`); + } + const alignment = view.getUint8(9); + if (alignment !== LINKED_FRAME_RECORD_ALIGNMENT) { + throw new Error(`unsupported linked continuation alignment ${alignment}`); + } + const flags = view.getUint16(10, true); + if (flags !== DESCRIPTOR_REQUIRED_FLAGS) { + throw new Error(`unsupported linked continuation flags 0x${flags.toString(16)}`); + } + const chunkHeaderSize = view.getUint32(12, true); + const nodeHeaderSize = view.getUint32(16, true); + const fixedPrefixSize = view.getUint32(20, true); + if ( + chunkHeaderSize !== pointerFormat.chunkHeaderSize + || nodeHeaderSize !== pointerFormat.nodeHeaderSize + ) { + throw new Error("linked continuation metadata header sizes do not match pointer width"); + } + return { + version, + ptrWidth: pointerFormat.bytes, + alignment, + flags, + chunkHeaderSize, + nodeHeaderSize, + fixedPrefixSize, + }; +} + +interface PendingNode { + chunk: number; + node: number; + payload: number; + nextUsed: number; +} + +interface ContinuationChunk { + addr: number; + size: number; + nodeStart: number; + used: number; +} + +/** + * Host-side owner and validator for one module instance's linked fork frames. + * Allocations are ordinary anonymous process mappings, so kernel brk/mmap + * ownership, fork inheritance, and memory growth remain authoritative. + */ +export class LinkedForkContinuation { + private root = 0; + private activeChunk = 0; + private replayNode = 0; + private replayChunkIndex = -1; + private replayExpectedEnd = 0; + private pending: PendingNode | null = null; + private chunks: ContinuationChunk[] = []; + private committedFrames = 0; + private committedBytes = 0; + private abortFailure: AbortFailure | null = null; + + constructor( + private readonly memory: WebAssembly.Memory, + readonly format: LinkedFrameFormatDescriptor, + private readonly allocate: ContinuationAllocate, + private readonly deallocate: ContinuationDeallocate, + private readonly label: string, + ) {} + + beginUnwind(): number | bigint { + if (this.root !== 0) { + throw new Error(`${this.label}: linked continuation already active`); + } + const initialUsed = alignUp( + this.format.chunkHeaderSize + this.format.fixedPrefixSize, + this.format.alignment, + ); + const capacity = alignUp(Math.max(initialUsed, WASM_PAGE_SIZE), WASM_PAGE_SIZE); + this.committedFrames = 0; + this.committedBytes = 0; + this.abortFailure = null; + let root: number; + try { + root = this.allocateChunk(capacity, 0, 0); + } catch (error) { + if (error instanceof ContinuationAllocationError) { + this.releaseAfterFailure(error); + } + this.abortAndRelease(undefined, error); + } + this.root = root; + this.activeChunk = root; + this.writePtr(root + 8 + 4 * this.format.ptrWidth, initialUsed); + this.chunks[0]!.nodeStart = initialUsed; + this.chunks[0]!.used = initialUsed; + return this.asGuestPtr(root + this.format.chunkHeaderSize); + } + + attachForReplay(moduleBuffer: number | bigint): void { + if (this.root !== 0) { + throw new Error(`${this.label}: linked continuation already active`); + } + const moduleBufferNumber = this.fromGuestPtr(moduleBuffer); + const root = moduleBufferNumber - this.format.chunkHeaderSize; + const chunks: ContinuationChunk[] = []; + const seen = new Set(); + // WHY: address zero is reserved and each continuation mapping starts on a + // Wasm page and occupies at least one page. The current memory therefore + // supplies a hard upper bound without trusting a guest-controlled link. + const maxChunks = Math.max( + 0, + Math.floor(this.memory.buffer.byteLength / WASM_PAGE_SIZE) - 1, + ); + let chunk = root; + let previous = 0; + for (;;) { + // Check identity before dereferencing the repeated node. This rejects a + // corrupt self- or multi-node cycle at its first repeated address. + if (seen.has(chunk)) { + throw new Error(`${this.label}: linked continuation chunk cycle`); + } + if (seen.size >= maxChunks) { + throw new Error(`${this.label}: linked continuation chunk chain exceeds memory`); + } + seen.add(chunk); + const { capacity, used } = this.validateChunk(chunk, root, previous); + const nodeStart = chunks.length === 0 + ? alignUp( + this.format.chunkHeaderSize + this.format.fixedPrefixSize, + this.format.alignment, + ) + : this.format.chunkHeaderSize; + if ( + used < nodeStart + || (chunks.length > 0 && used === nodeStart) + ) { + throw new Error(`${this.label}: invalid linked continuation chunk contents`); + } + chunks.push({ addr: chunk, size: capacity, nodeStart, used }); + const next = this.readPtr(chunk + 8 + 2 * this.format.ptrWidth); + if (next === 0) { + break; + } + previous = chunk; + chunk = next; + } + const replayNode = this.readPtr(root + 8 + 5 * this.format.ptrWidth); + // WHY: release() issues one munmap for every declared chunk. Distinct + // page-aligned starts are not enough: a forged header can sit inside a + // multi-page chunk and otherwise make us release overlapping mappings. + const chunksByAddress = [...chunks].sort((left, right) => left.addr - right.addr); + for (let index = 1; index < chunksByAddress.length; index++) { + const prior = chunksByAddress[index - 1]!; + const current = chunksByAddress[index]!; + if (checkedEnd(prior.addr, prior.size) > current.addr) { + throw new Error(`${this.label}: linked continuation chunk ranges overlap`); + } + } + this.validateReplayTail(chunks, replayNode); + // Publish the reconstructed owner state only after the complete guest + // chain is valid, so a failed attachment cannot leave a partial owner. + this.root = root; + this.chunks = chunks; + this.activeChunk = chunks[chunks.length - 1]!.addr; + this.setReplayCursor(replayNode); + } + + beginReplay(): void { + if (this.root === 0 || this.pending || this.abortFailure) { + throw new Error(`${this.label}: cannot begin replay from incomplete continuation`); + } + this.resetReplay(this.readPtr(this.root + 8 + 5 * this.format.ptrWidth)); + } + + reserveFrame(payloadSize: number | bigint): number | bigint { + const size = this.fromGuestPtr(payloadSize); + if (this.root === 0 || this.activeChunk === 0) { + throw new Error(`${this.label}: frame reservation outside unwind`); + } + if (this.pending) { + throw new Error(`${this.label}: a frame reservation is already pending`); + } + if (this.abortFailure) { + throw new Error(`${this.label}: frame reservation after abort began`); + } + const nodeSize = alignUp( + this.format.nodeHeaderSize + size, + this.format.alignment, + ); + let chunk = this.activeChunk; + let used = this.readPtr(chunk + 8 + 4 * this.format.ptrWidth); + let capacity = this.readPtr(chunk + 8 + 3 * this.format.ptrWidth); + if (nodeSize > capacity - used) { + const nextCapacity = alignUp( + Math.max(WASM_PAGE_SIZE, this.format.chunkHeaderSize + nodeSize), + WASM_PAGE_SIZE, + ); + let next: number; + try { + next = this.allocateChunk(nextCapacity, this.root, chunk); + } catch (error) { + if (error instanceof ContinuationAllocationError) { + // WHY: a Wasm save callback cannot unwind through JavaScript with an + // errno. The null reservation asks the instrumented guest to replay + // and discard committed frames before the original fork returns it. + this.beginAbortReplay(error.errno, size, error.message); + return this.asGuestPtr(0); + } + this.abortAndRelease(size, error); + } + this.writePtr(chunk + 8 + 2 * this.format.ptrWidth, next); + this.activeChunk = next; + chunk = next; + used = this.format.chunkHeaderSize; + capacity = nextCapacity; + } + if (nodeSize > capacity - used) { + throw new Error(`${this.label}: allocator returned an undersized continuation chunk`); + } + const node = chunk + used; + const payload = node + this.format.nodeHeaderSize; + const previous = this.readPtr(this.root + 8 + 5 * this.format.ptrWidth); + const view = this.view(); + view.setUint32(node, NODE_MAGIC, true); + view.setUint16(node + 4, LINKED_FRAME_FORMAT_VERSION, true); + view.setUint16(node + 6, NODE_RESERVED, true); + this.writePtr(node + 8, previous); + this.writePtr(node + 8 + this.format.ptrWidth, size); + this.writePtr(node + 8 + 2 * this.format.ptrWidth, nodeSize); + this.pending = { chunk, node, payload, nextUsed: used + nodeSize }; + return this.asGuestPtr(payload); + } + + commitFrame(payload: number | bigint): void { + if (this.abortFailure) { + throw new Error(`${this.label}: frame commit after abort began`); + } + const payloadNumber = this.fromGuestPtr(payload); + const pending = this.pending; + if (!pending || pending.payload !== payloadNumber) { + throw new Error(`${this.label}: frame commit does not match the pending reservation`); + } + const active = this.chunks[this.chunks.length - 1]; + if (active?.addr !== pending.chunk) { + throw new Error(`${this.label}: pending frame belongs to an inactive chunk`); + } + this.writePtr(pending.chunk + 8 + 4 * this.format.ptrWidth, pending.nextUsed); + active.used = pending.nextUsed; + this.view().setUint16(pending.node + 6, NODE_COMMITTED, true); + // Publishing the new tail is the final write: replay can never observe a + // reserved or partially populated node through the committed chain. + 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.pending = null; + } + + nextFrame(expectedSize: number | bigint): number | bigint { + const expected = this.fromGuestPtr(expectedSize); + const node = this.replayNode; + if (this.root === 0 || node === 0) { + throw new Error(`${this.label}: linked continuation replay exhausted early`); + } + const chunk = this.replayChunk(node); + const view = this.view(); + if ( + view.getUint32(node, true) !== NODE_MAGIC + || view.getUint16(node + 4, true) !== LINKED_FRAME_FORMAT_VERSION + || view.getUint16(node + 6, true) !== NODE_COMMITTED + ) { + throw new Error(`${this.label}: invalid or uncommitted linked continuation node`); + } + const payloadSize = this.readPtr(node + 8 + this.format.ptrWidth); + const nodeSize = this.readPtr(node + 8 + 2 * this.format.ptrWidth); + if (payloadSize !== expected) { + throw new Error( + `${this.label}: linked continuation frame size ${payloadSize} does not match ${expected}`, + ); + } + const nodeEnd = checkedEnd(node, nodeSize); + if ( + nodeSize !== alignUp(this.format.nodeHeaderSize + payloadSize, this.format.alignment) + || nodeEnd !== this.replayExpectedEnd + ) { + throw new Error(`${this.label}: invalid linked continuation node bounds`); + } + 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); + } + + finishUnwind(): void { + if (this.pending) { + throw new Error(`${this.label}: unwind ended with an uncommitted frame`); + } + if (this.root === 0) { + throw new Error(`${this.label}: unwind ended without a continuation`); + } + } + + finishReplayAndRelease(): void { + if (this.abortFailure) { + throw new Error(`${this.label}: normal replay ended during abort recovery`); + } + if (this.replayNode !== 0) { + throw new Error(`${this.label}: rewind ended before all linked frames were consumed`); + } + this.release(); + } + + beginAbortReplay( + errno: number, + requestedFrame?: number, + diagnostic = `fork continuation allocation failed with errno=${errno}`, + ): void { + if (!Number.isInteger(errno) || errno <= 0) { + throw new Error(`${this.label}: invalid abort errno ${errno}`); + } + if (this.root === 0 || this.pending) { + throw new Error(`${this.label}: cannot abort-replay an incomplete continuation`); + } + if (this.abortFailure && this.abortFailure.errno !== errno) { + throw new Error(`${this.label}: conflicting continuation abort failures`); + } + this.resetReplay(this.readPtr(this.root + 8 + 5 * this.format.ptrWidth)); + this.abortFailure ??= { errno, requestedFrame, diagnostic }; + } + + abortErrno(): number { + if (!this.abortFailure) { + throw new Error(`${this.label}: no continuation abort is active`); + } + return this.abortFailure.errno; + } + + finishAbortReplayAndRelease(): void { + if (!this.abortFailure) { + throw new Error(`${this.label}: abort replay ended without an allocation failure`); + } + if (this.replayNode !== 0) { + throw new Error(`${this.label}: abort replay ended before all linked frames were consumed`); + } + this.release(); + } + + cancelUnwindAndRelease(): void { + if (this.pending) { + throw new Error(`${this.label}: cannot cancel an unwind with a pending frame`); + } + if (this.root === 0) { + throw new Error(`${this.label}: cannot cancel an inactive unwind`); + } + this.release(); + } + + abortAndRelease(requestedNextFrame?: number, cause?: unknown): never { + const details = `committed_frames=${this.committedFrames} committed_bytes=${this.committedBytes}` + + (requestedNextFrame === undefined ? "" : ` requested_next_frame=${requestedNextFrame}`) + + (cause === undefined + ? "" + : ` allocator_error=${cause instanceof Error ? cause.message : String(cause)}`); + try { + this.release(); + } finally { + throw new Error(`${this.label}: continuation allocation failed (${details})`); + } + } + + moduleBufferAddress(): number { + if (this.root === 0) throw new Error(`${this.label}: no active linked continuation`); + return this.root + this.format.chunkHeaderSize; + } + + hasActiveContinuation(): boolean { + return this.root !== 0; + } + + private allocateChunk(capacity: number, root: number, previous: number): number { + let addr: number; + try { + addr = this.allocate(capacity); + } catch (error) { + if (error instanceof ContinuationAllocationError) throw error; + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `${this.label}: continuation allocation of ${capacity} bytes failed: ${message}`, + ); + } + if ( + !Number.isSafeInteger(addr) + || addr <= 0 + || addr % WASM_PAGE_SIZE !== 0 + || checkedEnd(addr, capacity) > this.memory.buffer.byteLength + ) { + if (Number.isSafeInteger(addr) && addr > 0) { + try { this.deallocate(addr, capacity); } catch { /* preserve allocator diagnosis */ } + } + throw new Error(`${this.label}: allocator returned invalid continuation chunk 0x${addr.toString(16)}`); + } + const view = this.view(); + view.setUint32(addr, CHUNK_MAGIC, true); + view.setUint16(addr + 4, LINKED_FRAME_FORMAT_VERSION, true); + view.setUint16(addr + 6, 0, true); + this.writePtr(addr + 8, root || addr); + this.writePtr(addr + 8 + this.format.ptrWidth, previous); + this.writePtr(addr + 8 + 2 * this.format.ptrWidth, 0); + this.writePtr(addr + 8 + 3 * this.format.ptrWidth, capacity); + this.writePtr(addr + 8 + 4 * this.format.ptrWidth, this.format.chunkHeaderSize); + this.writePtr(addr + 8 + 5 * this.format.ptrWidth, 0); + this.chunks.push({ + addr, + size: capacity, + nodeStart: this.format.chunkHeaderSize, + used: this.format.chunkHeaderSize, + }); + return addr; + } + + private validateChunk( + addr: number, + root: number, + previous: number, + ): { capacity: number; used: number } { + const view = this.view(); + if ( + !Number.isSafeInteger(addr) + || addr <= 0 + || addr % WASM_PAGE_SIZE !== 0 + || checkedEnd(addr, this.format.chunkHeaderSize) > this.memory.buffer.byteLength + || view.getUint32(addr, true) !== CHUNK_MAGIC + || view.getUint16(addr + 4, true) !== LINKED_FRAME_FORMAT_VERSION + || view.getUint16(addr + 6, true) !== 0 + || this.readPtr(addr + 8) !== root + || this.readPtr(addr + 8 + this.format.ptrWidth) !== previous + ) { + throw new Error(`${this.label}: invalid linked continuation chunk at 0x${addr.toString(16)}`); + } + const capacity = this.readPtr(addr + 8 + 3 * this.format.ptrWidth); + const used = this.readPtr(addr + 8 + 4 * this.format.ptrWidth); + if ( + capacity < WASM_PAGE_SIZE + || capacity % WASM_PAGE_SIZE !== 0 + || checkedEnd(addr, capacity) > this.memory.buffer.byteLength + || used < this.format.chunkHeaderSize + || used > capacity + ) { + throw new Error(`${this.label}: invalid linked continuation chunk bounds`); + } + return { capacity, used }; + } + + private validateReplayTail( + chunks: readonly ContinuationChunk[], + node: number, + ): void { + const containsFrames = chunks.some(({ nodeStart, used }) => used > nodeStart); + // WHY: zero is the complete empty-continuation representation. Accepting a + // zero tail for stored nodes would let replay finish and release them + // without proving that the reverse chain covered every committed frame. + if (node === 0) { + if (containsFrames) { + throw new Error(`${this.label}: nonempty linked continuation has no replay tail`); + } + return; + } + if (!containsFrames) { + throw new Error(`${this.label}: empty linked continuation has a replay tail`); + } + + const chunk = chunks[chunks.length - 1]!; + const view = this.view(); + if ( + node % this.format.alignment !== 0 + || node < chunk.addr + chunk.nodeStart + || checkedEnd(node, this.format.nodeHeaderSize) > chunk.addr + chunk.used + || view.getUint32(node, true) !== NODE_MAGIC + || view.getUint16(node + 4, true) !== LINKED_FRAME_FORMAT_VERSION + || view.getUint16(node + 6, true) !== NODE_COMMITTED + ) { + throw new Error(`${this.label}: invalid linked continuation replay tail`); + } + const payloadSize = this.readPtr(node + 8 + this.format.ptrWidth); + const nodeSize = this.readPtr(node + 8 + 2 * this.format.ptrWidth); + if ( + nodeSize !== alignUp(this.format.nodeHeaderSize + payloadSize, this.format.alignment) + || checkedEnd(node, nodeSize) !== chunk.addr + chunk.used + ) { + throw new Error(`${this.label}: invalid linked continuation replay tail bounds`); + } + } + + private resetReplay(node: number): void { + // Parent and abort replay read the same guest-owned header as child + // attachment, so they must enforce the same tail/used invariant. + this.validateReplayTail(this.chunks, node); + this.setReplayCursor(node); + } + + private setReplayCursor(node: number): void { + this.replayNode = node; + if (node === 0) { + this.replayChunkIndex = -1; + this.replayExpectedEnd = 0; + return; + } + this.replayChunkIndex = this.chunks.length - 1; + const chunk = this.chunks[this.replayChunkIndex]!; + this.replayExpectedEnd = chunk.addr + chunk.used; + } + + private replayChunk(node: number): ContinuationChunk { + const chunk = this.chunks[this.replayChunkIndex]; + if ( + !chunk + || node % this.format.alignment !== 0 + || node < chunk.addr + chunk.nodeStart + || checkedEnd(node, this.format.nodeHeaderSize) > chunk.addr + chunk.used + ) { + throw new Error( + `${this.label}: frame pointer is outside the expected continuation chunk`, + ); + } + return chunk; + } + + private previousReplayPosition( + previous: number, + node: number, + ): { chunkIndex: number; expectedEnd: number } { + const chunkIndex = this.replayChunkIndex; + const chunk = this.chunks[chunkIndex]!; + if (previous === 0) { + // Every non-root chunk is created for, and must contain, a frame. Only + // the root can be empty when the first frame needs a larger chunk. + const earlierChunkHasFrames = chunkIndex > 1 || ( + chunkIndex === 1 + && this.chunks[0]!.used > this.chunks[0]!.nodeStart + ); + if (node !== chunk.addr + chunk.nodeStart || earlierChunkHasFrames) { + throw new Error(`${this.label}: linked continuation replay ended before its first frame`); + } + return { chunkIndex: -1, expectedEnd: 0 }; + } + if ( + previous >= chunk.addr + chunk.nodeStart + && checkedEnd(previous, this.format.nodeHeaderSize) <= chunk.addr + chunk.used + ) { + if (previous >= node) { + throw new Error(`${this.label}: linked continuation nodes are not reverse ordered`); + } + this.validateReplayPredecessor(previous, chunk, node); + return { chunkIndex, expectedEnd: node }; + } + + const priorChunk = this.chunks[chunkIndex - 1]; + if ( + priorChunk + && previous >= priorChunk.addr + priorChunk.nodeStart + && checkedEnd(previous, this.format.nodeHeaderSize) <= + priorChunk.addr + priorChunk.used + ) { + // WHY: chunks are appended only when the active chunk cannot fit the + // next frame. Reverse replay can therefore cross only from the first + // node of one chunk to the immediately preceding chunk. This cursor + // makes replay O(frames + chunks), independent of total chunk count. + if (node !== chunk.addr + chunk.nodeStart) { + throw new Error(`${this.label}: linked continuation replay skipped a frame`); + } + this.validateReplayPredecessor( + previous, + priorChunk, + priorChunk.addr + priorChunk.used, + ); + return { + chunkIndex: chunkIndex - 1, + expectedEnd: priorChunk.addr + priorChunk.used, + }; + } + throw new Error( + `${this.label}: frame pointer is outside the expected continuation chunk`, + ); + } + + private validateReplayPredecessor( + node: number, + chunk: ContinuationChunk, + expectedEnd: number, + ): void { + const view = this.view(); + if ( + node % this.format.alignment !== 0 + || node < chunk.addr + chunk.nodeStart + || checkedEnd(node, this.format.nodeHeaderSize) > chunk.addr + chunk.used + || view.getUint32(node, true) !== NODE_MAGIC + || view.getUint16(node + 4, true) !== LINKED_FRAME_FORMAT_VERSION + || view.getUint16(node + 6, true) !== NODE_COMMITTED + ) { + throw new Error(`${this.label}: invalid linked continuation replay predecessor`); + } + const payloadSize = this.readPtr(node + 8 + this.format.ptrWidth); + const nodeSize = this.readPtr(node + 8 + 2 * this.format.ptrWidth); + // WHY: proving the predecessor ends exactly where the current node starts + // detects a skipped or aliased frame before the current payload is exposed. + if ( + nodeSize !== alignUp(this.format.nodeHeaderSize + payloadSize, this.format.alignment) + || checkedEnd(node, nodeSize) !== expectedEnd + ) { + throw new Error(`${this.label}: linked continuation replay skipped a frame`); + } + } + + 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; + 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 releaseAfterFailure(error: unknown): never { + try { + this.release(); + } catch (releaseError) { + throw new Error( + `${this.label}: continuation cleanup after allocation failure failed: ` + + `${releaseError instanceof Error ? releaseError.message : String(releaseError)}`, + ); + } + throw error; + } + + private view(): DataView { + return new DataView(this.memory.buffer); + } + + private readPtr(addr: number): number { + const value = this.format.ptrWidth === 8 + ? Number(this.view().getBigUint64(addr, true)) + : this.view().getUint32(addr, true); + if (!Number.isSafeInteger(value)) { + throw new Error(`${this.label}: continuation pointer exceeds JavaScript addressability`); + } + return value; + } + + private writePtr(addr: number, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${this.label}: invalid continuation pointer value ${value}`); + } + if (this.format.ptrWidth === 8) this.view().setBigUint64(addr, BigInt(value), true); + else this.view().setUint32(addr, value, true); + } + + private fromGuestPtr(value: number | bigint): number { + return checkedWasmGuestPointerOffset( + value, + this.format.ptrWidth, + `${this.label}: linked continuation`, + ); + } + + private asGuestPtr(value: number): number | bigint { + return this.format.ptrWidth === 8 ? BigInt(value) : value; + } +} diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index 38fdb525a1..0c8c2498bd 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -1,10 +1,35 @@ /* 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 = 41 as const; +export const ABI_VERSION = 42 as const; export const ABI_CUSTOM_SECTION = "wasm-posix-abi" as const; export const ABI_KERNEL_EXPORT = "__abi_version" as const; +export const WPK_FORK_LINKED_FRAME_FORMAT_SECTION = "kandelo.wpk_fork.linked_frames" as const; +export const WPK_FORK_LINKED_FRAME_FORMAT_VERSION = 1 as const; +export const WPK_FORK_LINKED_FRAME_FORMAT_MAGIC = [75, 76, 67, 70] as const; +export const WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE = 24 as const; +export const WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT = 8 as const; +export const WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS = 3 as const; +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_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_reserve", params: ["ptr"], results: ["ptr"] }, +] as const; +export const WPK_FORK_REQUIRED_EXPORTS = [ + { 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: [] }, +] as const; + export const SCHED_AFFINITY_MASK_SIZE = 4 as const; export const HOST_ADAPTER_VERSION = 1 as const; @@ -25,6 +50,10 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "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", @@ -32,12 +61,20 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "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", ] as const; diff --git a/host/src/homebrew-vfs-composer.ts b/host/src/homebrew-vfs-composer.ts index 941bd64f8b..0768748b3f 100644 --- a/host/src/homebrew-vfs-composer.ts +++ b/host/src/homebrew-vfs-composer.ts @@ -345,24 +345,38 @@ export function assertHomebrewVfsMaterialization( ) { throw new Error("Homebrew embedded bottle mirror plan changed identity"); } - const pending = fs.exportLazyArchiveEntries().filter( - (entry) => entry.content !== undefined, - ); - const pendingIdentities = pending.map((entry) => { + const pendingIdentities = fs.exportLazyArchiveEntries().flatMap((entry) => { + if (entry.content === undefined) return []; + const bottleCapabilities = entry.activation?.capabilities.filter((capability) => + capability.startsWith("homebrew-bottle:") + ) ?? []; + if (bottleCapabilities.length === 0) return []; + if (bottleCapabilities.length !== 1) { + throw new Error( + `Homebrew pending deferred tree ${entry.mountPrefix} has ambiguous bottle ownership`, + ); + } const url = entry.content!.transports[0]; if (url === undefined) { throw new Error(`Homebrew pending deferred tree ${entry.mountPrefix} has no transport`); } - return { + return [{ + treeId: bottleCapabilities[0]!.slice("homebrew-bottle:".length), url, sha256: entry.content!.sha256, bytes: entry.content!.bytes, - }; - }).sort((left, right) => compareText(left.url, right.url)); + }]; + }).sort((left, right) => + compareText(left.treeId, right.treeId) || compareText(left.url, right.url) + ); const expectedDeferred = evidence.deferred.map((tree) => - ({ url: tree.url, sha256: tree.sha256, bytes: tree.bytes }) - ).sort((left, right) => compareText(left.url, right.url)); - if (JSON.stringify(pendingIdentities) !== JSON.stringify(expectedDeferred)) { + ({ treeId: tree.treeId, url: tree.url, sha256: tree.sha256, bytes: tree.bytes }) + ).sort((left, right) => + compareText(left.treeId, right.treeId) || compareText(left.url, right.url) + ); + if ( + JSON.stringify(pendingIdentities) !== JSON.stringify(expectedDeferred) + ) { throw new Error( "Homebrew pending deferred trees differ from the selected package partition", ); diff --git a/host/src/homebrew-vfs-formula-layer.ts b/host/src/homebrew-vfs-formula-layer.ts new file mode 100644 index 0000000000..65661cfe94 --- /dev/null +++ b/host/src/homebrew-vfs-formula-layer.ts @@ -0,0 +1,879 @@ +import { compareHomebrewCanonicalText } from "./homebrew-lazy-layer-descriptor"; +import { HOMEBREW_RUNTIME_LAYER_LIMITS } from "./homebrew-runtime-layer-limits"; +import { mapHomebrewBottleEntryToGuestPath } from "./homebrew-vfs-builder"; +import type { + HomebrewFederatedVfsPlan, + HomebrewVfsPackagePlan, + HomebrewVfsPlan, +} from "./homebrew-vfs-planner"; +import type { TarEntry } from "./vfs/tar"; + +export const HOMEBREW_VFS_FORMULA_LAYER_KIND = + "kandelo-homebrew-vfs-formula-layer" as const; +export const HOMEBREW_VFS_FORMULA_MANIFEST_RELATIVE_PATH = + "share/kandelo/vfs-layer.json" as const; +export const HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH = + "libexec/kandelo-vfs-layer/rootfs" as const; + +const MAX_MANIFEST_BYTES = 64 * 1024; +const FULL_PACKAGE_RE = + /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/; +const CAPABILITY_RE = /^[a-z0-9][a-z0-9._:-]*$/; + +export interface HomebrewVfsFormulaLayerManifest { + schema: 1; + kind: typeof HOMEBREW_VFS_FORMULA_LAYER_KIND; + package: string; + payload: { + root: typeof HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH; + mount_prefix: "/"; + }; + activation: { + mode: "boot-prefetch" | "first-use"; + capabilities: string[]; + roots: string[]; + }; +} + +/** + * One bottle-owned object projected from the fixed private payload subtree to + * its final guest path. The source path remains the original bottle TAR member; + * immutable runtime descriptors and release URLs are derived later. + */ +export interface HomebrewVfsFormulaLayerEntry { + path: string; + source_path: string; + type: "directory" | "file" | "symlink" | "hardlink"; + mode: number; + size: number; + /** Symlink text, or the absolute final guest path for a hard link. */ + target?: string; +} + +export interface HomebrewVfsFormulaLayerProjection { + manifest: HomebrewVfsFormulaLayerManifest; + rootPackage: HomebrewVfsPackagePlan; + /** Exact dependency-first closure, including the root Formula last. */ + packages: HomebrewVfsPackagePlan[]; + /** Ordinary Formula dependencies; this is deliberately not duplicated in the manifest. */ + dependencies: HomebrewVfsPackagePlan[]; + entries: HomebrewVfsFormulaLayerEntry[]; +} + +export interface HomebrewVfsFormulaLayerComposition { + /** Canonical full-package order, independent of caller selection order. */ + layers: HomebrewVfsFormulaLayerProjection[]; + /** Every package owned by exactly one selected layer. */ + packageOrder: string[]; + /** Canonical target inventory after cross-layer ownership preflight. */ + entries: Array; +} + +/** + * Parse the closed, URL-free manifest installed by a VFS Formula. + * + * Formula dependencies remain authoritative in Homebrew metadata. Repeating + * them here would create a second dependency graph that can drift. + */ +export function parseHomebrewVfsFormulaLayerManifest( + value: unknown, +): HomebrewVfsFormulaLayerManifest { + const root = exactRecord( + value, + ["schema", "kind", "package", "payload", "activation"], + "Homebrew VFS Formula layer manifest", + ); + if (root.schema !== 1 || root.kind !== HOMEBREW_VFS_FORMULA_LAYER_KIND) { + throw new Error( + "Homebrew VFS Formula layer manifest has an unsupported identity", + ); + } + const packageName = requireFullPackageName( + root.package, + "Homebrew VFS Formula layer package", + ); + const payload = exactRecord( + root.payload, + ["root", "mount_prefix"], + "Homebrew VFS Formula layer payload", + ); + if ( + payload.root !== HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH || + payload.mount_prefix !== "/" + ) { + throw new Error( + "Homebrew VFS Formula layer payload must use the conventional keg root and / mount", + ); + } + const activation = exactRecord( + root.activation, + ["mode", "capabilities", "roots"], + "Homebrew VFS Formula layer activation", + ); + if (activation.mode !== "boot-prefetch" && activation.mode !== "first-use") { + throw new Error("Homebrew VFS Formula layer activation mode is invalid"); + } + const capabilities = requireCanonicalStringArray( + activation.capabilities, + "Homebrew VFS Formula layer activation capabilities", + HOMEBREW_RUNTIME_LAYER_LIMITS.maxActivationCapabilities, + (capability) => + CAPABILITY_RE.test(capability) && + encodedLength(capability) <= + HOMEBREW_RUNTIME_LAYER_LIMITS.maxActivationCapabilityBytes, + ); + const roots = requireCanonicalStringArray( + activation.roots, + "Homebrew VFS Formula layer activation roots", + HOMEBREW_RUNTIME_LAYER_LIMITS.maxActivationRoots, + canonicalAbsolutePath, + ); + + return { + schema: 1, + kind: HOMEBREW_VFS_FORMULA_LAYER_KIND, + package: packageName, + payload: { + root: HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH, + mount_prefix: "/", + }, + activation: { + mode: activation.mode, + capabilities, + roots, + }, + }; +} + +/** + * Bind one resolved, single-root Homebrew closure to the manifest and payload + * carried by its root Formula bottle. + * + * This is producer-side source truth. It does not create a runtime descriptor, + * choose a public mirror, or mutate a filesystem. + */ +export function projectHomebrewVfsFormulaLayer( + plan: HomebrewVfsPlan, + rootPackage: string, + bottleEntries: readonly TarEntry[], +): HomebrewVfsFormulaLayerProjection { + const packages = validateSingleRootClosure(plan, rootPackage); + const root = packages[packages.length - 1]!; + const guestEntries = indexBottleGuestEntries(root, bottleEntries); + const manifestPath = `${root.keg}/${HOMEBREW_VFS_FORMULA_MANIFEST_RELATIVE_PATH}`; + const manifestSource = guestEntries.get(manifestPath); + if (manifestSource === undefined) { + throw new Error( + `Homebrew VFS Formula ${root.fullName} is missing ${manifestPath}`, + ); + } + if ( + manifestSource.type !== "file" || + manifestSource.data.byteLength === 0 || + manifestSource.data.byteLength > MAX_MANIFEST_BYTES + ) { + throw new Error( + `Homebrew VFS Formula ${root.fullName} manifest must be a regular file ` + + `between 1 and ${MAX_MANIFEST_BYTES} bytes`, + ); + } + + let manifestValue: unknown; + try { + manifestValue = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(manifestSource.data), + ); + } catch (error) { + throw new Error( + `Homebrew VFS Formula ${root.fullName} manifest is not valid UTF-8 JSON: ` + + errorMessage(error), + ); + } + const manifest = parseHomebrewVfsFormulaLayerManifest(manifestValue); + if (manifest.package !== root.fullName) { + throw new Error( + `Homebrew VFS Formula manifest names ${manifest.package}, expected ${root.fullName}`, + ); + } + + const payloadRoot = `${root.keg}/${HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH}`; + const payloadSource = guestEntries.get(payloadRoot); + if (payloadSource?.type !== "directory") { + throw new Error( + `Homebrew VFS Formula ${root.fullName} payload root must be a directory at ${payloadRoot}`, + ); + } + const entries = projectPayloadEntries(root, payloadRoot, guestEntries); + validateActivationOwnership(manifest, entries); + + return { + manifest, + rootPackage: root, + packages, + dependencies: packages.slice(0, -1), + entries, + }; +} + +/** + * Canonicalize selected VFS Formula layers and reject package/path ownership + * conflicts before a descriptor builder or staged filesystem can mutate state. + * + * The runtime consumer repeats this check against the exact base filesystem + * and publishes only its successfully staged result. + */ +export function preflightHomebrewVfsFormulaLayers( + selected: readonly HomebrewVfsFormulaLayerProjection[], +): HomebrewVfsFormulaLayerComposition { + if ( + selected.length === 0 || + selected.length > HOMEBREW_RUNTIME_LAYER_LIMITS.maxLayers + ) { + throw new Error( + `Homebrew VFS Formula composition must contain 1 to ` + + `${HOMEBREW_RUNTIME_LAYER_LIMITS.maxLayers} layers`, + ); + } + const layers = [...selected].sort((left, right) => + compareHomebrewCanonicalText( + left.rootPackage.fullName, + right.rootPackage.fullName, + ), + ); + const packageOwner = new Map< + string, + { owner: string; package: HomebrewVfsPackagePlan } + >(); + const pathOwner = new Map< + string, + HomebrewVfsFormulaLayerEntry & { package: string } + >(); + + for (const layer of layers) { + const owner = layer.rootPackage.fullName; + for (const pkg of layer.packages) { + const prior = packageOwner.get(pkg.fullName); + if (prior !== undefined) { + if (!sameHomebrewPackageIdentity(prior.package, pkg)) { + throw new Error( + `Homebrew VFS Formula layers ${prior.owner} and ${owner} resolve ` + + `${pkg.fullName} to different immutable package identities`, + ); + } + continue; + } + packageOwner.set(pkg.fullName, { owner, package: pkg }); + } + for (const entry of layer.entries) { + const candidate = { ...entry, package: owner }; + const prior = pathOwner.get(entry.path); + if (prior !== undefined) { + const mergeableDirectory = + prior.type === "directory" && + entry.type === "directory" && + prior.mode === entry.mode; + if (!mergeableDirectory) { + throw new Error( + `Homebrew VFS Formula layers ${prior.package} and ${owner} ` + + `conflict at ${entry.path}`, + ); + } + continue; + } + pathOwner.set(entry.path, candidate); + } + } + + for (const [path, entry] of pathOwner) { + for (const ancestor of ancestorPaths(path)) { + const ownedAncestor = pathOwner.get(ancestor); + if (ownedAncestor !== undefined && ownedAncestor.type !== "directory") { + throw new Error( + `Homebrew VFS Formula layer ${entry.package} path ${path} descends ` + + `through non-directory ${ancestor} owned by ${ownedAncestor.package}`, + ); + } + } + } + + // Each projection is bounded when it is built, but the collection limits + // belong to the final VFS. Charge the deduplicated package/path inventory so + // shared dependencies and mergeable directories count once without letting + // several individually valid layers exceed the image-wide budget. + if (packageOwner.size > HOMEBREW_RUNTIME_LAYER_LIMITS.maxPackages) { + throw new Error( + `Homebrew VFS Formula composition contains ${packageOwner.size} ` + + `packages; maximum is ${HOMEBREW_RUNTIME_LAYER_LIMITS.maxPackages}`, + ); + } + if (pathOwner.size > HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionEntries) { + throw new Error( + `Homebrew VFS Formula composition contains ${pathOwner.size} entries; ` + + `maximum is ${HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionEntries}`, + ); + } + let payloadBytes = 0; + for (const entry of pathOwner.values()) { + if (entry.type !== "file") continue; + payloadBytes += entry.size; + if ( + !Number.isSafeInteger(payloadBytes) || + payloadBytes > HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionPayloadBytes + ) { + throw new Error( + `Homebrew VFS Formula composition payload exceeds the ` + + `${HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionPayloadBytes}-byte cap`, + ); + } + } + + return { + layers, + packageOrder: [...packageOwner.keys()], + entries: [...pathOwner.values()].sort((left, right) => + compareHomebrewCanonicalText(left.path, right.path), + ), + }; +} + +/** + * Two selected layers may share an ordinary Formula dependency only when both + * immutable plans name the exact same bottle, link projection, and provenance. + * The bottle digest alone is insufficient because tap metadata owns the guest + * link manifest and dependency closure as well as the archive bytes. + */ +function sameHomebrewPackageIdentity( + left: HomebrewVfsPackagePlan, + right: HomebrewVfsPackagePlan, +): boolean { + return ( + JSON.stringify(packageIdentity(left)) === + JSON.stringify(packageIdentity(right)) + ); +} + +function packageIdentity(pkg: HomebrewVfsPackagePlan): unknown[] { + return [ + pkg.name, + pkg.fullName, + pkg.tapRepository, + pkg.tapName, + pkg.tapCommit, + pkg.kandeloRepository, + pkg.kandeloCommit, + pkg.version, + pkg.formulaRevision, + pkg.bottleRebuild, + pkg.arch, + pkg.kandeloAbi, + pkg.metadataStatus, + pkg.sourceStatus, + pkg.url, + pkg.sha256, + pkg.bytes, + pkg.cacheKeySha, + pkg.prefix, + pkg.cellar, + pkg.keg, + pkg.payloadRoot, + pkg.linkManifestPath, + pkg.linkManifest, + pkg.dependencies, + pkg.runtimeSupport, + pkg.browserCompatible, + pkg.builtFrom ?? null, + ]; +} + +function validateSingleRootClosure( + plan: HomebrewVfsPlan, + rootFullName: string, +): HomebrewVfsPackagePlan[] { + requireFullPackageName(rootFullName, "Homebrew VFS Formula root package"); + if (!Array.isArray(plan.packages) || plan.packages.length === 0) { + throw new Error("Homebrew VFS Formula plan has no packages"); + } + if (plan.packages.length > HOMEBREW_RUNTIME_LAYER_LIMITS.maxPackages) { + throw new Error("Homebrew VFS Formula plan exceeds the package-count cap"); + } + + const byFullName = new Map(); + const indexByFullName = new Map(); + for (const [index, pkg] of plan.packages.entries()) { + const fullName = requireFullPackageName( + pkg.fullName, + `Homebrew VFS Formula plan package ${index}`, + ); + const parts = fullName.split("/"); + if (pkg.tapName !== `${parts[0]}/${parts[1]}` || pkg.name !== parts[2]) { + throw new Error( + `Homebrew VFS Formula plan package ${fullName} differs from its tap or name`, + ); + } + if (byFullName.has(pkg.fullName)) { + throw new Error( + `Homebrew VFS Formula plan duplicates package ${pkg.fullName}`, + ); + } + byFullName.set(pkg.fullName, pkg); + indexByFullName.set(pkg.fullName, index); + } + const root = byFullName.get(rootFullName); + if (root === undefined) { + throw new Error( + `Homebrew VFS Formula root ${rootFullName} is absent from its plan`, + ); + } + const requestedFullNames = + "requestedFullNames" in plan + ? (plan as HomebrewFederatedVfsPlan).requestedFullNames + : plan.requestedPackages.map((name) => `${plan.tapName}/${name}`); + if ( + requestedFullNames.length !== 1 || + requestedFullNames[0] !== rootFullName + ) { + throw new Error( + `Homebrew VFS Formula plan must request only ${rootFullName}`, + ); + } + + const dependencies = new Map(); + for (const [index, pkg] of plan.packages.entries()) { + const fullNames = pkg.dependencies.map((dependency) => { + const fullName = + dependency.full_name ?? `${pkg.tapName}/${dependency.name}`; + const parsedName = requireFullPackageName( + fullName, + `Homebrew VFS Formula dependency of ${pkg.fullName}`, + ).split("/")[2]!; + if (parsedName !== dependency.name) { + throw new Error( + `Homebrew VFS Formula dependency ${fullName} does not match name ` + + `${dependency.name}`, + ); + } + return fullName; + }); + if (new Set(fullNames).size !== fullNames.length) { + throw new Error( + `Homebrew VFS Formula package ${pkg.fullName} duplicates a dependency`, + ); + } + for (const dependency of fullNames) { + const dependencyIndex = indexByFullName.get(dependency); + if (dependencyIndex === undefined) { + throw new Error( + `Homebrew VFS Formula package ${pkg.fullName} depends on missing ` + + `${dependency}`, + ); + } + if (dependencyIndex >= index) { + throw new Error( + `Homebrew VFS Formula plan is not dependency-first at ${pkg.fullName}`, + ); + } + } + dependencies.set(pkg.fullName, fullNames); + } + + const closure = new Set(); + const visit = (fullName: string) => { + if (closure.has(fullName)) return; + for (const dependency of dependencies.get(fullName) ?? []) + visit(dependency); + closure.add(fullName); + }; + visit(rootFullName); + if ( + closure.size !== plan.packages.length || + plan.packages.some((pkg) => !closure.has(pkg.fullName)) + ) { + throw new Error( + "Homebrew VFS Formula plan contains packages outside its root dependency closure", + ); + } + if (plan.packages[plan.packages.length - 1] !== root) { + throw new Error( + `Homebrew VFS Formula root ${rootFullName} must follow its dependencies`, + ); + } + return [...plan.packages]; +} + +function indexBottleGuestEntries( + pkg: HomebrewVfsPackagePlan, + entries: readonly TarEntry[], +): Map { + const sources = new Set(); + const byGuestPath = new Map(); + for (const entry of entries) { + if (sources.has(entry.path)) { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} bottle duplicates source ${entry.path}`, + ); + } + sources.add(entry.path); + const guestPath = mapHomebrewBottleEntryToGuestPath(pkg, entry.path); + if (guestPath === null) continue; + if (byGuestPath.has(guestPath)) { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} bottle maps multiple entries to ${guestPath}`, + ); + } + byGuestPath.set(guestPath, entry); + } + return byGuestPath; +} + +function projectPayloadEntries( + pkg: HomebrewVfsPackagePlan, + payloadRoot: string, + guestEntries: ReadonlyMap, +): HomebrewVfsFormulaLayerEntry[] { + const payloadPrefix = `${payloadRoot}/`; + const projected: HomebrewVfsFormulaLayerEntry[] = []; + const projectedBySource = new Map(); + const sourceByPath = new Map(); + let aggregatePayloadBytes = 0; + + for (const [guestPath, source] of guestEntries) { + sourceByPath.set(source.path, source); + if (!guestPath.startsWith(payloadPrefix)) continue; + const relativePath = guestPath.slice(payloadPrefix.length); + const path = requireAbsolutePath( + `/${relativePath}`, + `Homebrew VFS Formula ${pkg.fullName} payload target`, + ); + const size = source.type === "file" ? source.data.byteLength : 0; + aggregatePayloadBytes += size; + const entry: HomebrewVfsFormulaLayerEntry = { + path, + source_path: source.path, + type: source.type, + mode: requireMode( + source.mode, + `Homebrew VFS Formula ${pkg.fullName} payload ${path}`, + ), + size, + ...(source.type === "symlink" + ? { + target: requireSafeSymlinkTarget( + source.linkName, + path, + pkg.fullName, + ), + } + : {}), + }; + projected.push(entry); + projectedBySource.set(source.path, entry); + } + if ( + projected.length === 0 || + !projected.some((entry) => entry.type !== "directory") + ) { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} payload has no files or links`, + ); + } + if (projected.length > HOMEBREW_RUNTIME_LAYER_LIMITS.maxEntries) { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} payload exceeds the entry-count cap`, + ); + } + if ( + aggregatePayloadBytes > + HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionPayloadBytes + ) { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} payload exceeds the byte-count cap`, + ); + } + + const byPath = new Map(projected.map((entry) => [entry.path, entry])); + if (byPath.size !== projected.length) { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} payload duplicates a target path`, + ); + } + for (const entry of projected) { + for (const ancestor of ancestorPaths(entry.path)) { + const directory = byPath.get(ancestor); + if (directory?.type !== "directory") { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} payload omits directory ${ancestor}`, + ); + } + } + const source = sourceByPath.get(entry.source_path); + if (source?.type === "hardlink") { + const target = resolvePayloadHardlink( + source, + sourceByPath, + projectedBySource, + pkg.fullName, + ); + if (entry.mode !== target.mode) { + throw new Error( + `Homebrew VFS Formula ${pkg.fullName} hard link ${entry.path} ` + + "mode differs from its regular target", + ); + } + entry.target = target.path; + entry.size = target.size; + } + } + projected.sort((left, right) => + compareHomebrewCanonicalText(left.path, right.path), + ); + return projected; +} + +function resolvePayloadHardlink( + start: Extract, + sourceByPath: ReadonlyMap, + projectedBySource: ReadonlyMap, + packageName: string, +): HomebrewVfsFormulaLayerEntry & { type: "file" } { + const seen = new Set(); + let current: TarEntry = start; + while (current.type === "hardlink") { + if (seen.has(current.path)) { + throw new Error( + `Homebrew VFS Formula ${packageName} has a hard-link cycle at ` + + `${current.path}`, + ); + } + seen.add(current.path); + const target = sourceByPath.get(current.linkName); + // WHY: a hard link is another name for one concrete inode, so the lazy + // materializer must own and verify its regular target in this package. + // Symlinks differ: they preserve text that may resolve to a dependency or + // base-image path only later, after the complete guest namespace exists. + if ( + target === undefined || + projectedBySource.get(target.path) === undefined + ) { + throw new Error( + `Homebrew VFS Formula ${packageName} hard link ${start.path} ` + + "targets outside its payload", + ); + } + if (target.type !== "file" && target.type !== "hardlink") { + throw new Error( + `Homebrew VFS Formula ${packageName} hard link ${start.path} ` + + "has no regular payload target", + ); + } + current = target; + } + if (current.type !== "file") { + throw new Error( + `Homebrew VFS Formula ${packageName} hard link ${start.path} ` + + "has no regular payload target", + ); + } + return projectedBySource.get( + current.path, + )! as HomebrewVfsFormulaLayerEntry & { + type: "file"; + }; +} + +function validateActivationOwnership( + manifest: HomebrewVfsFormulaLayerManifest, + entries: readonly HomebrewVfsFormulaLayerEntry[], +): void { + for (const root of manifest.activation.roots) { + if ( + !entries.some( + (entry) => entry.path === root || entry.path.startsWith(`${root}/`), + ) + ) { + throw new Error( + `Homebrew VFS Formula activation root ${root} owns no payload path`, + ); + } + } + if (manifest.activation.mode !== "first-use") return; + for (const entry of entries) { + // WHY: shared ancestor directories are traversal scaffolding, not a reason + // to fetch a bottle. Only content-bearing entries must be covered by a + // first-use root, or an ordinary lookup such as /usr would activate every + // package that contributes descendants beneath it. + if ( + entry.type !== "directory" && + !manifest.activation.roots.some( + (root) => entry.path === root || entry.path.startsWith(`${root}/`), + ) + ) { + throw new Error( + `Homebrew VFS Formula first-use payload path ${entry.path} has no ` + + "activation root", + ); + } + } +} + +function requireSafeSymlinkTarget( + value: string, + path: string, + packageName: string, +): string { + if ( + value.length === 0 || + value.includes("\\") || + value.includes("\0") || + hasControlCharacter(value) || + encodedLength(value) > + HOMEBREW_RUNTIME_LAYER_LIMITS.maxSymlinkTargetBytes || + /^[A-Za-z][A-Za-z0-9+.-]*:/.test(value) + ) { + throw new Error( + `Homebrew VFS Formula ${packageName} symlink ${path} has an unsafe target`, + ); + } + if (value.startsWith("/")) { + // WHY: Homebrew packages commonly link to files supplied by dependencies + // or the base image. Preserve that guest-absolute reference; projection + // records the link text and never follows it while unpacking the bottle. + requireAbsolutePath( + value, + `Homebrew VFS Formula ${packageName} symlink ${path} target`, + ); + return value; + } + const resolved = path.split("/").slice(1, -1); + for (const component of value.split("/")) { + if (component === "" || component === ".") continue; + if (component === "..") { + // Parent-relative dependency links are valid until they would escape the + // guest root. This check is about the guest namespace, not the host. + if (resolved.length === 0) { + throw new Error( + `Homebrew VFS Formula ${packageName} symlink ${path} escapes /`, + ); + } + resolved.pop(); + } else { + resolved.push(component); + } + } + return value; +} + +function ancestorPaths(path: string): string[] { + const components = path.split("/").slice(1); + const ancestors: string[] = []; + for (let length = 1; length < components.length; length += 1) { + ancestors.push(`/${components.slice(0, length).join("/")}`); + } + return ancestors; +} + +function canonicalAbsolutePath(value: string): boolean { + try { + requireAbsolutePath(value, "path"); + return true; + } catch { + return false; + } +} + +function requireAbsolutePath(value: unknown, label: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + !value.startsWith("/") || + value === "/" || + value.endsWith("/") || + value.includes("\\") || + value.includes("\0") || + hasControlCharacter(value) || + value + .slice(1) + .split("/") + .some((part) => part === "" || part === "." || part === "..") || + encodedLength(value) > HOMEBREW_RUNTIME_LAYER_LIMITS.maxPathBytes + ) { + throw new Error(`${label} must be a canonical bounded absolute path`); + } + return value; +} + +function requireCanonicalStringArray( + value: unknown, + label: string, + maximum: number, + valid: (item: string) => boolean, +): string[] { + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > maximum || + value.some((item) => typeof item !== "string" || !valid(item)) + ) { + throw new Error(`${label} must contain 1 to ${maximum} valid strings`); + } + if (new Set(value).size !== value.length) { + throw new Error(`${label} contains duplicates`); + } + const canonical = [...value].sort(compareHomebrewCanonicalText); + if (value.some((item, index) => item !== canonical[index])) { + throw new Error(`${label} is not in canonical order`); + } + return [...value]; +} + +function requireFullPackageName(value: unknown, label: string): string { + if ( + typeof value !== "string" || + encodedLength(value) > HOMEBREW_RUNTIME_LAYER_LIMITS.maxRepositoryBytes || + !FULL_PACKAGE_RE.test(value) + ) { + throw new Error(`${label} must be a canonical owner/tap/formula name`); + } + return value; +} + +function requireMode(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > 0o7777) { + throw new Error(`${label} has an invalid mode`); + } + return value; +} + +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(compareHomebrewCanonicalText); + const expected = [...keys].sort(compareHomebrewCanonicalText); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + throw new Error(`${label} has unexpected or missing fields`); + } + return record; +} + +function encodedLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function hasControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const code = character.codePointAt(0)!; + return code < 0x20 || code === 0x7f; + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/host/src/index.ts b/host/src/index.ts index a0c755aae1..ed9f28b684 100644 --- a/host/src/index.ts +++ b/host/src/index.ts @@ -2,7 +2,10 @@ export { WasmPosixKernel } from "./kernel"; export type { KernelCallbacks } from "./kernel"; export { CentralizedKernelWorker } from "./kernel-worker"; export type { - CentralizedKernelCallbacks, ProcessSnapshot, SyscallTraceEvent, + CentralizedKernelCallbacks, + ProcessSnapshot, + SyscallTraceEvent, + ThreadChannelAttachment, } from "./kernel-worker"; export { SYSCALL_NAMES } from "./kernel-worker"; export { SyscallChannel, ChannelStatus } from "./channel"; @@ -31,7 +34,6 @@ export type { WorkerReadyMessage, WorkerExitMessage, WorkerErrorMessage, - DeliverSignalMessage, ExecRequestMessage, ExecReplyMessage, ExecCompleteMessage, @@ -74,6 +76,20 @@ export type { HomebrewVfsPlanOptions, HomebrewVfsTapIdentity, } from "./homebrew-vfs-planner"; +export { + HOMEBREW_VFS_FORMULA_LAYER_KIND, + HOMEBREW_VFS_FORMULA_MANIFEST_RELATIVE_PATH, + HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH, + parseHomebrewVfsFormulaLayerManifest, + preflightHomebrewVfsFormulaLayers, + projectHomebrewVfsFormulaLayer, +} from "./homebrew-vfs-formula-layer"; +export type { + HomebrewVfsFormulaLayerComposition, + HomebrewVfsFormulaLayerEntry, + HomebrewVfsFormulaLayerManifest, + HomebrewVfsFormulaLayerProjection, +} from "./homebrew-vfs-formula-layer"; export { HOMEBREW_RUNTIME_LAYER_POLICY_KIND, parseHomebrewRuntimeLayerPolicy, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 2475abde91..4d52af8e03 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -86,6 +86,7 @@ import { PROCESS_MMAP_BASE, growMemoryToCover, } from "./process-memory"; +import { readForkContinuationAnchor } from "./fork-continuation"; import type { KernelConfig, NetworkAddress, PlatformIO, TcpConnectionPeer, UdpDatagram } from "./types"; @@ -136,6 +137,7 @@ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; /** Errno values */ const E2BIG = 7; +const ESRCH = 3; const EAGAIN = 11; const EACCES = 13; const EBADF = 9; @@ -154,6 +156,19 @@ const ETIMEDOUT = 110; const EALREADY = 114; const EINPROGRESS = 115; const EINTR_ERRNO = 4; +const MAX_KERNEL_TASK_ID = 0x7fff_ffff; + +class KernelTaskBindingError extends Error { + constructor( + readonly pid: number, + readonly tid: number | undefined, + readonly errno: number, + detail?: string, + ) { + super(detail ?? `Kernel rejected tid ${tid} for process ${pid}: errno ${errno}`); + this.name = "KernelTaskBindingError"; + } +} function cstringCopySize( memory: Uint8Array, @@ -255,6 +270,7 @@ const P_PGID = 2; /** SIGCHLD */ 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. @@ -692,14 +708,13 @@ interface SysvShmMapping { } interface RegisterProcessOptions { - skipKernelCreate?: boolean; argv?: string[]; env?: string[]; ptrWidth?: 4 | 8; /** Width of the exec caller's argv/envp pointer arrays for ARG_MAX accounting. */ metadataPtrWidth?: 4 | 8; - /** Required for new kernel Process creation; ignored when skipKernelCreate is true. */ - stdio?: RegisterProcessStdio; + /** Exec replaces an existing image without resetting process-level stop state. */ + preserveProcessState?: boolean; /** Initial program break after any host-owned low control pages. */ brkBase?: number; /** Lower bound for automatic mmap allocation. */ @@ -799,7 +814,7 @@ export type ProcessWorkerStartDisposition = * * - `fnPtr` / `argPtr`: the pthread_create entry point + userdata that * the kernel-worker stored when the thread was registered through - * `addChannel`. The child Worker uses these to enter the thread + * `attachThreadChannel`. The child Worker uses these to enter the thread * function directly (skipping `_start`). * - `forkBufAddr`: the wpk_fork buffer address corresponding to the * *thread's* channel — i.e. `thread_channelOffset - FORK_BUF_SIZE`. @@ -827,6 +842,82 @@ export interface SpawnResolveError { errno: number; } +const threadChannelAttachmentBrand: unique symbol = Symbol( + "ThreadChannelAttachment", +); + +/** + * One clone allocation event that may attach exactly one host syscall channel. + * + * The Rust kernel chose `tid`. Host launch code may read these immutable values + * to initialize the thread Worker, but it cannot supply or replace the task + * identity when attaching the channel. Object identity is validated at runtime; + * copying these fields does not create another attachment authority. + */ +export interface ThreadChannelAttachment { + readonly [threadChannelAttachmentBrand]: true; + readonly pid: number; + readonly tid: number; + readonly fnPtr: number; + readonly argPtr: number; + readonly stackPtr: number; + readonly tlsPtr: number; + readonly ctidPtr: number; + readonly memory: WebAssembly.Memory; +} + +interface PendingThreadChannelAttachment { + readonly owner: CentralizedKernelWorker; + readonly pid: number; + readonly tid: number; + readonly fnPtr: number; + readonly argPtr: number; + readonly memory: WebAssembly.Memory; + attachedChannelOffset?: number; +} + +// 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 = + new WeakMap(); + +function createThreadChannelAttachment( + owner: CentralizedKernelWorker, + pid: number, + tid: number, + fnPtr: number, + argPtr: number, + stackPtr: number, + tlsPtr: number, + ctidPtr: number, + memory: WebAssembly.Memory, +): { + attachment: ThreadChannelAttachment; + pending: PendingThreadChannelAttachment; +} { + const attachment = Object.freeze({ + [threadChannelAttachmentBrand]: true as const, + pid, + tid, + fnPtr, + argPtr, + stackPtr, + tlsPtr, + ctidPtr, + memory, + }) as ThreadChannelAttachment; + const pending: PendingThreadChannelAttachment = { + owner, + pid, + tid, + fnPtr, + argPtr, + memory, + }; + pendingThreadChannelAttachments.set(attachment, pending); + return { attachment, pending }; +} + export type SpawnProgramResolution = ResolvedSpawnProgram | SpawnResolveError; function isSpawnResolveError( @@ -846,7 +937,8 @@ export interface CentralizedKernelCallbacks { * * `threadFork` is set when the parent issued the fork() syscall from a * thread spawned via pthread_create (i.e. on a channel registered - * through `addChannel(pid, offset, tid, fnPtr, argPtr)` with tid > 0). + * through a host-side `ThreadChannelAttachment` bound to the kernel's exact + * clone result, with tid > 0). * The host must: * - use the thread's `forkBufAddr` (not the main channel's) for the * child's rewind so the saved frames + saved __tls_base / @@ -869,7 +961,7 @@ export interface CentralizedKernelCallbacks { /** * Called when a process calls execve. The callback should resolve the * program path, terminate the old Worker, create a new Worker with the - * new binary, and call registerProcess with skipKernelCreate. + * new binary, and attach its channels to the existing kernel Process. * Returns 0 on success, negative errno on error. */ onExec?: ( @@ -903,8 +995,8 @@ export interface CentralizedKernelCallbacks { * 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 registers it via - * `registerProcess({ skipKernelCreate: true })`. + * instantiates a fresh Worker and attaches its channels to the Process the + * kernel already created. * * Returns 0 on success, negative errno on failure. On non-zero return * the kernel descriptor is rolled back via `kernel_remove_process`. @@ -922,9 +1014,13 @@ export interface CentralizedKernelCallbacks { /** * Called when a process calls clone (thread creation). The callback should - * spawn a thread Worker sharing the parent's Memory. Returns the TID. + * spawn a thread Worker sharing the parent's Memory. The Rust kernel has + * already allocated the task identity. The host creates a one-shot transport + * proof bound to that exact clone result; the callback may read its fields + * for Worker initialization, then must consume it with + * `attachThreadChannel`. It cannot supply or replace the PID/TID. */ - onClone?: (pid: number, tid: number, fnPtr: number, argPtr: number, stackPtr: number, tlsPtr: number, ctidPtr: number, memory: WebAssembly.Memory) => Promise; + onClone?: (attachment: ThreadChannelAttachment) => Promise; /** * Called after a pthread channel reaches SYS_EXIT and the kernel worker has @@ -973,25 +1069,6 @@ export class CentralizedKernelWorker { private execHandoffPids = new Set(); private scratchOffset = 0; private initialized = false; - private nextChildPid = 100; - - /** - * Allocate a fresh pid for a top-level spawn from a host. Skips any pids - * already in the kernel's process table (forked children, the virtual - * init at pid 1, etc.). The host is no longer expected to pick pids; - * this is the single source of truth. - */ - allocateTopLevelSpawnPid(): number { - while (this.processes.has(this.nextChildPid)) { - this.nextChildPid++; - } - return this.nextChildPid++; - } - - /** Backward-compatible name for host integrations using this allocator. */ - allocatePid(): number { - return this.allocateTopLevelSpawnPid(); - } /** * Maps a pthread syscall mailbox to its kernel/libc thread id. * @@ -1002,13 +1079,15 @@ export class CentralizedKernelWorker { * Before entering `kernel_handle_channel`, the host uses this map to bind the * selected mailbox to the current TID so gettid, set_tid_address, per-thread * signal masks, directed signals, and thread cleanup apply to the right - * pthread. + * pthread. A live non-leader TID may own exactly one mailbox; + * `attachThreadChannel` enforces that one-to-one transport mapping from a + * host-side, one-shot proof of the kernel's exact clone result. */ private channelTids = new Map(); /** * Per-thread-channel fork context: the pthread_create entry point and * userdata that were stored when the channel was registered through - * `addChannel`. `handleFork` reads this when it detects a fork() + * `attachThreadChannel`. `handleFork` reads this when it detects a fork() * arriving on a thread channel so it can route the child's rewind * back through the thread function instead of `_start`. Keyed by * `pid:channelOffset` like `channelTids`; entries are cleared by @@ -1027,21 +1106,73 @@ export class CentralizedKernelWorker { * reentrant for the same instance, this must move into the syscall header or * become an explicit `kernel_handle_channel` argument. * - * `tid = 0` means "main thread" and is the default for channels without a - * tracked TID, such as the main process worker. + * The exact first channel binds the process leader TID (`pid`). Every other + * channel must retain the kernel-allocated TID recorded when that channel + * was attached. */ private bindKernelTidForChannel(channel: ChannelInfo): void { - const tid = - this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? 0; + const tid = this.channelTids.get( + `${channel.pid}:${channel.channelOffset}`, + ); + if (tid !== undefined) { + this.bindKernelTid(channel.pid, tid); + return; + } + if (this.isMainProcessChannel(channel)) { + this.bindKernelTid(channel.pid, channel.pid); + return; + } + throw this.missingChannelTidError(channel); + } + + /** + * Bind host transport metadata to a task identity already owned by Rust. + * 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 - ((tid: number) => void) | undefined; - if (setTid) setTid(tid); + ((pid: number, tid: number) => number) | undefined; + if (!setTid) { + throw new Error("Kernel missing kernel_set_current_tid export"); + } + const result = setTid(pid, tid); + if (result < 0) { + throw new KernelTaskBindingError(pid, tid, -result); + } + } + + private validateKernelTid(pid: number, tid: number): void { + const validateTask = this.kernelInstance?.exports.kernel_validate_task as + ((pid: number, tid: number) => number) | undefined; + if (!validateTask) { + throw new Error("Kernel missing kernel_validate_task export"); + } + const result = validateTask(pid, tid); + if (result < 0) { + throw new KernelTaskBindingError(pid, tid, -result); + } } private guestTidForChannel(channel: ChannelInfo): number { - return ( - this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? - channel.pid + const tid = this.channelTids.get( + `${channel.pid}:${channel.channelOffset}`, + ); + if (tid !== undefined) return tid; + if (this.isMainProcessChannel(channel)) return channel.pid; + throw this.missingChannelTidError(channel); + } + + private isMainProcessChannel(channel: ChannelInfo): boolean { + return this.processes.get(channel.pid)?.channels[0] === channel; + } + + private missingChannelTidError(channel: ChannelInfo): KernelTaskBindingError { + return new KernelTaskBindingError( + channel.pid, + undefined, + ESRCH, + `No kernel-validated TID for non-main channel ${channel.channelOffset} of process ${channel.pid}`, ); } /** Alarm timers per process: pid → NodeJS.Timeout */ @@ -1508,7 +1639,31 @@ export class CentralizedKernelWorker { } /** - * Register a process and its thread channels with the kernel. + * 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( @@ -1518,10 +1673,39 @@ export class CentralizedKernelWorker { 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?.skipKernelCreate); + this.discardStoppedChannelStateForProcess(pid, !options?.preserveProcessState); if (options?.argv !== undefined || options?.env !== undefined) { const metadataResult = this.validateExecMetadata( @@ -1534,34 +1718,10 @@ export class CentralizedKernelWorker { } } - // A fresh registration starts a new "generation" for this pid — even - // if the same numeric pid was previously reaped (it can't be today - // since nextChildPid is monotonic, but defensive), the new process - // hasn't been reaped yet. + // Kernel task IDs are never reused. Clear any stale host lifecycle marker + // defensively before installing this task's transport registration. this.hostReaped.delete(pid); - // Create process in kernel's process table (skip if already created, e.g. by fork) - if (!options?.skipKernelCreate) { - const stdio = options?.stdio; - if (!stdio) { - throw new Error("registerProcess requires explicit stdio when creating a kernel process"); - } - const createProcess = this.kernelInstance!.exports.kernel_create_process_with_stdio as - ((pid: number, stdinKind: number, stdoutKind: number, stderrKind: number) => number) | undefined; - if (!createProcess) { - throw new Error("Kernel missing kernel_create_process_with_stdio export"); - } - const result = createProcess( - pid, - encodeStdioKind(stdio.stdin), - encodeStdioKind(stdio.stdout), - encodeStdioKind(stdio.stderr), - ); - if (result < 0) { - throw new Error(`Failed to create process ${pid}: errno ${-result}`); - } - } - if (options?.brkBase !== undefined) { if (!this.setBrkBase(pid, options.brkBase)) { throw new Error( @@ -1870,7 +2030,7 @@ export class CentralizedKernelWorker { const EINTR = 4; for (const [sleepChannel, entry] of Array.from(this.pendingSleeps.entries())) { if (!this.isRegisteredChannel(entry.channel)) continue; - this.dequeueSignalForDelivery(entry.channel, true); + 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) { @@ -1951,42 +2111,14 @@ export class CentralizedKernelWorker { const direct = this.kernelInstance!.exports .kernel_set_process_credentials as ((pid: number, uid: number, gid: number) => number) | undefined; - if (direct) { - const result = direct(pid, ids.uid ?? unchanged, ids.gid ?? unchanged); - if (result < 0) { - throw new Error( - `setCredentials failed for pid ${pid}: errno ${-result}`, - ); - } - return; + if (!direct) { + throw new Error("Kernel missing kernel_set_process_credentials export"); } - - // Compatibility with kernel.wasm builds from before the direct - // per-pid export existed: select the new process, then use the normal - // syscall exports while it is still root. gid must be applied first, - // because setting uid to a non-root value drops privilege. - const setCurrentPid = this.kernelInstance!.exports - .kernel_set_current_pid as ((pid: number) => void) | undefined; - const setgid = this.kernelInstance!.exports.kernel_setgid as - ((gid: number) => number) | undefined; - const setuid = this.kernelInstance!.exports.kernel_setuid as - ((uid: number) => number) | undefined; - if (!setCurrentPid || !setgid || !setuid) return; - - try { - setCurrentPid(pid); - if (ids.gid != null) { - const result = setgid(ids.gid); - if (result < 0) - throw new Error(`setgid failed for pid ${pid}: errno ${-result}`); - } - if (ids.uid != null) { - const result = setuid(ids.uid); - if (result < 0) - throw new Error(`setuid failed for pid ${pid}: errno ${-result}`); - } - } finally { - setCurrentPid(0); + const result = direct(pid, ids.uid ?? unchanged, ids.gid ?? unchanged); + if (result < 0) { + throw new Error( + `setCredentials failed for pid ${pid}: errno ${-result}`, + ); } } @@ -2101,6 +2233,7 @@ export class CentralizedKernelWorker { // 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); @@ -2169,12 +2302,10 @@ export class CentralizedKernelWorker { * (zombie) so the parent can still reap. */ removeProcessFromKernelTable(pid: number): void { - if (!this.initialized) return; - const removeProcess = this.kernelInstance?.exports.kernel_remove_process as - ((pid: number) => number) | undefined; - if (!removeProcess) return; - removeProcess(pid); - this.drainAndProcessWakeupEvents(); + if (!this.initialized) { + throw new Error("Kernel is not initialized for process removal"); + } + this.removeFromKernelProcessTable(pid); } private cancelPendingSleepsForProcess(pid: number): void { @@ -2193,6 +2324,7 @@ export class CentralizedKernelWorker { ); this.releaseAllSharedMemoryForProcess(pid); this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); + this.clearProcessThreadTransportState(pid); this.processes.delete(pid); this.execHandoffPids?.delete(pid); this.stdinFinite.delete(pid); @@ -2221,9 +2353,9 @@ export class CentralizedKernelWorker { // Clean up network listeners/endpoints for this process this.cleanupUdpBindings(pid); this.cleanupTcpListeners(pid); - // Clear the killed-but-not-yet-reaped guard for this pid; if the - // pid is later reused for a fresh fork+register, the new process - // gets its own reaping decision. + // 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); } @@ -2231,10 +2363,12 @@ 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. */ - kernelExecPrepare(pid: number, callerTid: number = pid): number { + kernelExecPrepare(pid: number, callerTid: number): number { const prepare = this.kernelInstance!.exports.kernel_exec_prepare as ((pid: number, callerTid: number) => number) | undefined; - if (!prepare) return 0; + if (!prepare) { + throw new Error("Kernel missing required kernel_exec_prepare export"); + } const previousPid = this.currentHandlePid; this.currentHandlePid = pid; @@ -2253,18 +2387,20 @@ export class CentralizedKernelWorker { * Returns 0 on success, negative errno on failure. * Called by onExec callbacks after confirming the target program exists. */ - kernelExecSetup(pid: number, callerTid: number = pid): number { + kernelExecSetup(pid: number, callerTid: number): number { const threadAware = this.kernelInstance!.exports .kernel_exec_setup_for_thread as ((pid: number, callerTid: number) => number) | undefined; - const legacy = this.kernelInstance!.exports.kernel_exec_setup as ( - pid: number, - ) => number; + 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 ? threadAware(pid, callerTid) : legacy(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 @@ -2600,16 +2736,14 @@ export class CentralizedKernelWorker { const sysv = this.shmMappings.get(pid); if (!sysv) return 0; - const detach = this.kernelInstance!.exports.kernel_ipc_shmdt as - ((shmid: number) => number) | undefined; + 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; - this.withKernelCurrentPid(pid, () => { - for (const mapping of sysv.values()) { - if (detach(mapping.segId) < 0) result = -EIO; - } - }); + for (const mapping of sysv.values()) { + if (detach(pid, mapping.segId) < 0) result = -EIO; + } } catch { result = -EIO; } finally { @@ -2676,16 +2810,7 @@ export class CentralizedKernelWorker { // Thread mailbox identity and fork/clear-TID metadata belong to the old // image even though exec preserves the process id. - const channelPrefix = `${pid}:`; - for (const key of this.channelTids.keys()) { - if (key.startsWith(channelPrefix)) this.channelTids.delete(key); - } - for (const key of this.threadForkContexts.keys()) { - if (key.startsWith(channelPrefix)) this.threadForkContexts.delete(key); - } - for (const key of this.threadCtidPtrs.keys()) { - if (key.startsWith(channelPrefix)) this.threadCtidPtrs.delete(key); - } + this.clearProcessThreadTransportState(pid); for (const [key, entry] of this.posixTimers) { if (key.startsWith(`${pid}:`)) { @@ -2712,6 +2837,23 @@ export class CentralizedKernelWorker { return this.execHandoffPids?.has(pid) ?? false; } + /** 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); + } + } + /** Release the exec guard only after the outer worker generation is installed. */ finishProcessExecHandoff(pid: number): void { this.execHandoffPids?.delete(pid); @@ -2722,8 +2864,23 @@ export class CentralizedKernelWorker { * Called when a zombie is reaped by wait/waitpid. */ removeFromKernelProcessTable(pid: number): void { - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as (pid: number) => number; - removeProcess(pid); + 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"); + } + 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. @@ -2731,20 +2888,26 @@ export class CentralizedKernelWorker { } /** - * Add a new channel (e.g. for a thread) to an existing process registration. - * Uses the process's existing memory. If tid is provided, tracks the mapping - * so handleExit can identify thread exits. `threadFnPtr` / `threadArgPtr` - * are stored when the thread was created via clone() so `handleFork` can - * route a fork() from this thread back through its entry point. + * 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. */ - addChannel( - pid: number, + attachThreadChannel( + attachment: ThreadChannelAttachment, channelOffset: number, - tid?: number, - threadFnPtr?: number, - threadArgPtr?: number, - expectedMemory?: WebAssembly.Memory, ): 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); + + const { pid, tid, fnPtr, argPtr, memory } = pending; if (this.execHandoffPids?.has(pid)) { throw new Error(`Process ${pid} is replacing its image`); } @@ -2753,9 +2916,43 @@ export class CentralizedKernelWorker { } const registration = this.processes.get(pid); if (!registration) throw new Error(`Process ${pid} not registered`); - if (expectedMemory && registration.memory !== expectedMemory) { + 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`, + ); + } + + 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`, + ); + } + + // 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); + + for (const [existingChannelKey, existingTid] of this.channelTids) { + if (existingTid !== tid) continue; + throw new Error( + `Kernel TID ${tid} is already attached to channel ${existingChannelKey}`, + ); + } const channel: ChannelInfo = { pid, @@ -2765,34 +2962,38 @@ export class CentralizedKernelWorker { consecutiveSyscalls: 0, }; - registration.channels.push(channel); - this.activeChannels.push(channel); - - if (tid !== undefined) { - this.channelTids.set(`${pid}:${channelOffset}`, tid); - } - if (threadFnPtr !== undefined && threadArgPtr !== undefined) { - this.threadForkContexts.set(`${pid}:${channelOffset}`, { - fnPtr: threadFnPtr, - argPtr: threadArgPtr, - }); - } + try { + registration.channels.push(channel); + this.activeChannels.push(channel); + this.channelTids.set(channelKey, tid); + this.threadForkContexts.set(channelKey, { fnPtr, argPtr }); - // 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)); + // 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)); + } } - } - // In polling mode, the poller picks up new channels automatically. - if (!this.usePolling) { - this.listenOnChannel(channel); + // In polling mode, the poller picks up new channels automatically. + if (!this.usePolling) { + this.listenOnChannel(channel); + } + 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; } } @@ -2801,19 +3002,24 @@ export class CentralizedKernelWorker { */ removeChannel(pid: number, channelOffset: number): void { const registration = this.processes.get(pid); - if (!registration) return; - - for (const channel of registration.channels) { + for (const channel of registration?.channels ?? []) { if (channel.channelOffset !== channelOffset) continue; this.retireExactChannelAsyncState(channel); } - registration.channels = registration.channels.filter( - (ch) => ch.channelOffset !== channelOffset, - ); + 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}`); } @@ -2987,7 +3193,7 @@ export class CentralizedKernelWorker { * 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 or recycled pid. + * later exec image for the same persistent PID. */ startProcessWorkerWhenRunnable( pid: number, @@ -3283,6 +3489,7 @@ export class CentralizedKernelWorker { private handleSyscall(channel: ChannelInfo): void { if (!this.isRegisteredChannel(channel)) return; + if (this.handleExitedProcessChannel(channel)) return; if (this.deferChannelWhileStopped(channel)) return; try { if (PROFILING) { @@ -3302,6 +3509,16 @@ export class CentralizedKernelWorker { } 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 @@ -3311,6 +3528,83 @@ export class CentralizedKernelWorker { } } + /** + * 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); + } 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); + } + } + + /** + * 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): 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); + } 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, + ): 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); + } + } + private _handleSyscallInner(channel: ChannelInfo): void { const processView = new DataView(channel.memory.buffer, channel.channelOffset); @@ -3403,7 +3697,7 @@ export class CentralizedKernelWorker { // --- Intercept fork/exec/clone/exit before calling kernel --- // These syscalls need special async handling that can't go through - // the blocking host_fork/host_exec imports. + // direct kernel dispatch or the blocking host_exec import. if (syscallNr === SYS_FORK || syscallNr === SYS_VFORK) { if (logging) console.error(logEntry); @@ -3955,7 +4249,6 @@ export class CentralizedKernelWorker { offset: KernelPointer, pid: number, ) => number; - this.currentHandlePid = channel.pid; try { this.bindKernelTidForChannel(channel); } catch (err) { @@ -3965,6 +4258,7 @@ export class CentralizedKernelWorker { } throw err; } + 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 @@ -4294,14 +4588,9 @@ export class CentralizedKernelWorker { // 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. - // A successful mq_timedsend may synchronously route a notification to a - // different process, which resets the kernel's ambient TID to the shared - // signal context. Rebind only on that uncommon path; ordinary syscall - // completion stays free of another host-to-kernel call. - const deliveredSignal = this.dequeueSignalForDelivery( - channel, - routedMqNotification, - ); + // 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; // --- Blocking syscall handling --- @@ -4388,16 +4677,10 @@ export class CentralizedKernelWorker { origArgs[1] >>> 0, origArgs[0] >>> 0, ); - const interruptedDirectedWait = - this.interruptWaitingChildForDirectedSignal( - channel.pid, - origArgs[0], - ); - if (!interruptedDirectedWait) { - // Unknown/stale TIDs intentionally fall back to shared delivery in - // kernel_tkill; preserve that compatibility path. - this.interruptWaitingChildrenForGeneratedSignal(origArgs[1]); - } + this.interruptWaitingChildForDirectedSignal( + channel.pid, + origArgs[0], + ); } else { this.interruptWaitingChildrenForGeneratedSignal(origArgs[1]); } @@ -4432,10 +4715,7 @@ export class CentralizedKernelWorker { * this after the syscall returns and invokes the handler. Returns the * handler signal number, or zero when no caught handler was dequeued. */ - private dequeueSignalForDelivery( - channel: ChannelInfo, - bindTidForAsyncCompletion = false, - ): number { + private dequeueSignalForDelivery(channel: ChannelInfo): number { const preparedSignals = this.resumePreparedSignals; if (preparedSignals?.has(channel)) { const existingSignal = new DataView( @@ -4449,17 +4729,25 @@ export class CentralizedKernelWorker { } const dequeueSignal = this.kernelInstance!.exports.kernel_dequeue_signal as - ((pid: number, outPtr: KernelPointer) => number) | undefined; + ((pid: number, tid: number, outPtr: KernelPointer) => number) | undefined; if (!dequeueSignal) return 0; - // Normal syscall paths bind the channel before entering the kernel. Async - // completions can run after another thread changed the ambient TID, so - // those callers request an exact-channel rebind here. - if (bindTidForAsyncCompletion) this.bindKernelTidForChannel(channel); - // 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, this.toKernelPtr(sigOutOffset)); + const sigResult = dequeueSignal( + channel.pid, + tid, + this.toKernelPtr(sigOutOffset), + ); + if (sigResult < 0) { + throw new KernelTaskBindingError( + channel.pid, + tid, + -sigResult, + `Kernel rejected signal dequeue for tid ${tid} in process ${channel.pid}`, + ); + } 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) @@ -4754,9 +5042,9 @@ export class CentralizedKernelWorker { * parent notification after a resume-time stop or exit. */ private resumeStoppedProcess(pid: number): boolean { - // Wake events carry a pid, not a host generation token. A delayed event - // must not release a replacement process that has since stopped again or - // recycled the same numeric pid. + // 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; @@ -4806,7 +5094,7 @@ export class CentralizedKernelWorker { preparedSignals.add(channel); } else { preparedSignals.delete(channel); - deliveredSignal = this.dequeueSignalForDelivery(channel, true); + deliveredSignal = this.dequeueSignalForDelivery(channel); if (deliveredSignal > 0) preparedSignals.add(channel); } @@ -5316,7 +5604,8 @@ export class CentralizedKernelWorker { this.pollScheduled = false; if (!this.pollMC || this.activeChannels.length === 0) return; - // Snapshot to handle mutations during iteration (addChannel/removeChannel) + // Snapshot to handle mutations during iteration + // (attachThreadChannel/removeChannel). const channels = this.activeChannels.slice(); for (const channel of channels) { if (!this.isRegisteredChannel(channel)) continue; @@ -6142,12 +6431,13 @@ export class CentralizedKernelWorker { if (!registration) return; - // Resolve target channel: main thread has tid == pid; other threads are - // tracked in channelTids by their clone-assigned tid. + // 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 mappedTid = this.channelTids.get(`${channel.pid}:${ch.channelOffset}`); - const effectiveTid = mappedTid !== undefined ? mappedTid : channel.pid; + const effectiveTid = this.guestTidForChannel(ch); if (effectiveTid === targetTid) { target = ch; break; @@ -6955,7 +7245,7 @@ export class CentralizedKernelWorker { errVal: number, ): void { // Check if a signal became pending during the sleep - this.dequeueSignalForDelivery(channel, true); + this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return; // If a signal was dequeued, return EINTR instead of success @@ -7024,8 +7314,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -7103,7 +7393,7 @@ export class CentralizedKernelWorker { origArgs: number[], interruptCaughtSignal: boolean, ): boolean { - const deliveredSignal = this.dequeueSignalForDelivery(channel, true); + const deliveredSignal = this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return true; if (interruptCaughtSignal && deliveredSignal > 0) { this.completeChannel(channel, syscallNr, origArgs, undefined, -1, EINTR_ERRNO); @@ -7209,8 +7499,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -7378,8 +7668,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -7507,8 +7797,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -7569,8 +7859,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -7614,7 +7904,7 @@ export class CentralizedKernelWorker { /** Complete or reap an epoll wait when its kernel signal boundary fired. */ private completeEpollSignalOutcome(channel: ChannelInfo): boolean { - const deliveredSignal = this.dequeueSignalForDelivery(channel, true); + const deliveredSignal = this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return true; if (deliveredSignal > 0) { this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); @@ -7744,8 +8034,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -8104,7 +8394,7 @@ export class CentralizedKernelWorker { positioned: boolean, ): number | null { const prepare = this.kernelInstance!.exports.kernel_prepare_write_operation as - | ((pid: number, fd: number, offset: bigint, len: number, positioned: number) => bigint) + | ((pid: number, tid: number, fd: number, offset: bigint, len: number, positioned: number) => bigint) | undefined; if (!prepare) { throw new Error( @@ -8113,11 +8403,11 @@ export class CentralizedKernelWorker { } let result: number; + const tid = this.guestTidForChannel(channel); this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); try { result = Number( - prepare(channel.pid, fd, BigInt(offset), requestedLen, positioned ? 1 : 0), + prepare(channel.pid, tid, fd, BigInt(offset), requestedLen, positioned ? 1 : 0), ); } catch (err) { console.error( @@ -8229,8 +8519,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -8311,8 +8601,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); } - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -8424,8 +8714,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); } - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } catch (err) { @@ -8536,8 +8826,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); } - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } catch (err) { @@ -8673,8 +8963,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -8747,8 +9037,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); } - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -8908,8 +9198,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -9042,8 +9332,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -9123,6 +9413,7 @@ export class CentralizedKernelWorker { } const parentPid = channel.pid; + const callerTid = this.guestTidForChannel(channel); // Publish the parent's private views before creating any kernel child. // A backing refresh can fail; keeping this fallible work ahead of // kernel_fork_process avoids leaking a committed child/zombie or reserved @@ -9134,27 +9425,18 @@ export class CentralizedKernelWorker { return; } - // The host knows about live workers, while the kernel also owns zombie and - // limbo records until they are reaped. Retry candidates rejected with - // EEXIST so fork cannot collide with a kernel-owned pid that has no live - // host registration. + // 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 - (parentPid: number, childPid: number) => number; - let childPid = 0; - let forkResult = -EEXIST; - for (let attempts = 0; attempts < 4096; attempts++) { - while (this.processes.has(this.nextChildPid)) { - this.nextChildPid++; - } - childPid = this.nextChildPid++; - forkResult = kernelForkProcess(parentPid, childPid); - if (forkResult === 0 || -forkResult !== EEXIST) break; - } - if (forkResult < 0) { + (parentPid: number, callerTid: number) => number; + const forkResult = kernelForkProcess(parentPid, callerTid); + if (forkResult <= 0) { // Fork failed in kernel (e.g., ESRCH, ENOMEM) - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, (-forkResult) >>> 0); + const errno = forkResult < 0 ? (-forkResult) >>> 0 : EIO; + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, errno); return; } + const childPid = forkResult >>> 0; // Clear fork_child flag immediately. With wpk_fork instrumentation, the // child resumes from the fork point and never checks this flag. Without @@ -9164,14 +9446,6 @@ export class CentralizedKernelWorker { ((pid: number) => number) | undefined; if (clearForkChild) clearForkChild(childPid); - // Clear the child's blocked signal mask. With wpk_fork instrumentation, - // musl's __restore_sigs after fork() runs in the child, but we clear it - // here too for safety. Without fork instrumentation, the child re-executes - // _start and never gets __restore_sigs. - const resetSignalMask = this.kernelInstance!.exports.kernel_reset_signal_mask as - ((pid: number) => number) | undefined; - if (resetSignalMask) resetSignalMask(childPid); - // If the syscall arrived on a thread channel (registered via clone() // with tid > 0), the wpk_fork save buffer is at THIS channel's offset // and the unwind frames are rooted in the pthread entry function, not @@ -9186,7 +9460,11 @@ export class CentralizedKernelWorker { ? { fnPtr: threadCtx.fnPtr, argPtr: threadCtx.argPtr, - forkBufAddr: channel.channelOffset - FORK_BUF_SIZE, + forkBufAddr: readForkContinuationAnchor( + channel.memory, + channel.channelOffset - FORK_BUF_SIZE, + this.processes.get(parentPid)?.ptrWidth ?? 4, + ), slotStart: callerSlotStart, slotLen: callerSlotLen, } @@ -9196,7 +9474,19 @@ export class CentralizedKernelWorker { try { this.reserveHostRegionAt(childPid, threadFork.slotStart, threadFork.slotLen); } catch (err) { - this.removeFromKernelProcessTable(childPid); + try { + this.removeFromKernelProcessTable(childPid); + } catch (rollbackError) { + this.terminateForKernelProtocolFailure( + channel, + `could not roll back fork child ${childPid}: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + }`, + ); + 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); @@ -9213,8 +9503,27 @@ export class CentralizedKernelWorker { if (err !== undefined) { console.error(`[kernel-worker] fork worker launch failed: ${String(err)}`); } - try { this.rollbackChildHostRegistration(childPid); } catch { /* best-effort */ } - try { this.removeFromKernelProcessTable(childPid); } catch { /* best-effort */ } + try { + this.rollbackChildHostRegistration(childPid); + } catch (hostRollbackError) { + console.error( + `[kernel-worker] fork child ${childPid} host rollback failed:`, + hostRollbackError, + ); + } + try { + this.removeFromKernelProcessTable(childPid); + } catch (rollbackError) { + this.terminateForKernelProtocolFailure( + channel, + `could not roll back fork child ${childPid}: ${ + rollbackError instanceof Error + ? rollbackError.message + : String(rollbackError) + }`, + ); + return; + } if (this.isAsyncChannelProcessActive(channel)) { this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); } @@ -9270,6 +9579,7 @@ export class CentralizedKernelWorker { */ private handleSpawn(channel: ChannelInfo, origArgs: number[]): void { const parentPid = channel.pid; + const callerTid = this.guestTidForChannel(channel); const pathPtr = origArgs[0]; const pathLen = origArgs[1]; const blobPtr = origArgs[2]; @@ -9349,7 +9659,7 @@ export class CentralizedKernelWorker { return; } this.handleSpawnAfterResolve( - channel, origArgs, parentPid, pidOutPtr, blobBytes, blobLen, resolved, envp, + channel, origArgs, parentPid, callerTid, pidOutPtr, blobBytes, blobLen, resolved, envp, ); }).catch((err) => { if (!this.isAsyncChannelProcessActive(channel)) return; @@ -9367,6 +9677,7 @@ export class CentralizedKernelWorker { channel: ChannelInfo, origArgs: number[], parentPid: number, + callerTid: number, pidOutPtr: number, blobBytes: Uint8Array, blobLen: number, @@ -9383,28 +9694,50 @@ export class CentralizedKernelWorker { // ── Ask the kernel to build the child descriptor ── const kernelSpawn = this.kernelInstance!.exports.kernel_spawn_process as - (parentPid: number, blobPtr: KernelPointer, blobLen: KernelPointer) => number; + ( + parentPid: number, + callerTid: number, + blobPtr: KernelPointer, + blobLen: KernelPointer, + ) => number; const result = kernelSpawn( parentPid, + callerTid, this.toKernelPtr(this.scratchOffset), this.toKernelPtr(blobLen), ); - if (result < 0) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, (-result) >>> 0); + if (result <= 0) { + const errno = result < 0 ? (-result) >>> 0 : EIO; + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); return; } const childPid = result >>> 0; - // Bump host-side nextChildPid watermark so a subsequent fork() in the - // parent can't collide with the kernel's allocation. - if (childPid >= this.nextChildPid) this.nextChildPid = childPid + 1; - const rollbackSpawn = (errno: number, err?: unknown) => { if (err !== undefined) { console.error(`[kernel] spawn error for parent ${parentPid}:`, err); } - try { this.rollbackChildHostRegistration(childPid); } catch { /* best-effort */ } - try { this.removeFromKernelProcessTable(childPid); } catch { /* best-effort */ } + 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); } @@ -9595,9 +9928,9 @@ export class CentralizedKernelWorker { // Call the async exec handler FIRST — onExec returns ENOENT early if the // program doesn't exist, allowing posix_spawnp/execvpe PATH search to retry. - // kernel_exec_setup and prepareProcessForExec are deferred until after - // onExec confirms the program exists (returns 0). - const callerTid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? channel.pid; + // The exact kernel exec prepare/commit sequence and host teardown are + // deferred until after onExec confirms the program exists (returns 0). + 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. @@ -9719,7 +10052,7 @@ export class CentralizedKernelWorker { return; } - const callerTid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? 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); @@ -9754,6 +10087,24 @@ 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]; + const processBytes = new Uint8Array(channel.memory.buffer); + 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); + return; + } + const ctidPtr = (flags & CLONE_CHILD_CLEARTID) !== 0 ? rawCtidPtr : 0; + if (ctidPtr !== 0 && !validTaskWord(ctidPtr)) { + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, EFAULT); + return; + } + // 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); @@ -9764,8 +10115,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -9775,56 +10126,145 @@ export class CentralizedKernelWorker { const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); const errVal = kernelView.getUint32(CH_ERRNO, true); - if (retVal < 0) { - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, retVal, errVal); + if (retVal <= 0) { + const errno = retVal < 0 ? errVal : EIO; + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, errno); 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; + } + }; - // CLONE_PARENT_SETTID: write TID to ptid_ptr in process memory. - // The host writes this because ptid_ptr is in process memory, not kernel - // memory. - const CLONE_PARENT_SETTID = 0x00100000; - const flags = origArgs[0]; - const ptidPtr = origArgs[2]; - if (flags & CLONE_PARENT_SETTID && ptidPtr !== 0) { - const procView = new DataView(channel.memory.buffer); - procView.setInt32(ptidPtr, tid, true); - } - - // Read fnPtr and argPtr from the channel's CH_DATA area (written by kernel_clone stub) - // These are always written as u32 by the glue (even on wasm64, table indices are i32) - const processView = new DataView(channel.memory.buffer, channel.channelOffset); - const fnPtr = processView.getUint32(CH_DATA, true); - const argPtr = processView.getUint32(CH_DATA + 4, true); - const stackPtr = origArgs[1]; - const tlsPtr = origArgs[3]; - const ctidPtr = origArgs[4]; + 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; + } + + // Read fnPtr and argPtr from CH_DATA (written by the clone glue). Wasm + // table indices remain u32 even for a wasm64 process. + const processView = new DataView(channel.memory.buffer, channel.channelOffset); + const fnPtr = processView.getUint32(CH_DATA, true); + const argPtr = processView.getUint32(CH_DATA + 4, true); + 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); + } - // Register the clear-TID pointer before starting the host Worker. A very - // short-lived pthread can reach SYS_EXIT before onClone resolves. - if (ctidPtr !== 0) { - this.threadCtidPtrs.set(`${channel.pid}:${tid}`, ctidPtr); + const createdAttachment = createThreadChannelAttachment( + this, + channel.pid, + tid, + fnPtr, + argPtr, + stackPtr, + tlsPtr, + ctidPtr, + channel.memory, + ); + cloneAttachment = createdAttachment.attachment; + pendingAttachment = createdAttachment.pending; + launch = Promise.resolve(this.callbacks.onClone(cloneAttachment)); + } catch (error) { + try { + rollback(); + } catch (rollbackError) { + throw rollbackError; + } + throw error; } - this.callbacks.onClone( - channel.pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, channel.memory, - ).then((assignedTid) => { + 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 (assignedTid !== tid && ctidPtr !== 0) { - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); - this.threadCtidPtrs.set(`${channel.pid}:${assignedTid}`, ctidPtr); + 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}`, + ); + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 12); + return; } - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, assignedTid, 0); + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, tid, 0); }).catch((err) => { - if (!this.isAsyncChannelProcessActive(channel)) return; - if (ctidPtr !== 0) { - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); + 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) + }`, + ); + } + return; } + if (!this.isAsyncChannelProcessActive(channel)) return; console.error(`[kernel-worker] onClone failed: ${err}`); this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 12); // ENOMEM }); @@ -9834,27 +10274,22 @@ export class CentralizedKernelWorker { * Handle SYS_EXIT/SYS_EXIT_GROUP: notify the kernel and clean up. * * For SYS_EXIT from a non-main channel (thread exit): notify kernel, - * remove channel, and let the host terminate the backing Worker. If an - * older host entry has no thread-exit callback, fall back to completing the - * channel for compatibility. + * remove channel, complete its mailbox, and let the host terminate the + * 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 { const exitStatus = origArgs[0]; - const registration = this.processes.get(channel.pid); // Check if this is a thread exit (non-main channel + SYS_EXIT) - const isMainChannel = registration && registration.channels.length > 0 && - registration.channels[0].channelOffset === channel.channelOffset; + const isMainChannel = this.isMainProcessChannel(channel); if (syscallNr === SYS_EXIT && !isMainChannel) { // Thread exit: finalize kernel-side thread state, complete the channel, // then ask the host to tear down the backing Worker (browser + Node both // wire onThreadExit). - const tidKey = `${channel.pid}:${channel.channelOffset}`; - const tid = this.channelTids.get(tidKey) ?? 0; - if (tid > 0) - this.finalizeThreadExit(channel.pid, tid, channel.channelOffset); + const tid = this.guestTidForChannel(channel); + this.finalizeThreadExit(channel.pid, tid, channel.channelOffset); // 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 @@ -9869,9 +10304,7 @@ export class CentralizedKernelWorker { // 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); - if (tid > 0) { - this.callbacks.onThreadExit?.(channel.pid, tid, channel.channelOffset); - } + this.callbacks.onThreadExit?.(channel.pid, tid, channel.channelOffset); return; } @@ -9891,8 +10324,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS, BigInt(exitStatus), true); const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } catch { @@ -9902,6 +10335,34 @@ export class CentralizedKernelWorker { } } + // `kernel_exit` is a non-returning export, but a trap by itself does not + // prove that Rust committed the exit transition. Do not turn an arbitrary + // kernel trap 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; + 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) + }`, + ); + return; + } + if (processState !== PROCESS_STATE_EXITED) { + this.terminateForKernelProtocolFailure( + channel, + `kernel exit left process ${channel.pid} in state ${processState}`, + ); + return; + } + // 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. @@ -9918,7 +10379,7 @@ export class CentralizedKernelWorker { 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.completeChannelRaw(channel, 0, 0); + this.completeProcessExitHandshake(channel, syscallNr); this.scheduleWakeBlockedRetries(); if (this.callbacks.onExit) this.callbacks.onExit(exitingPid, exitStatus); return; @@ -9929,7 +10390,7 @@ export class CentralizedKernelWorker { // 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.completeChannelRaw(channel, 0, 0); + this.completeProcessExitHandshake(channel, syscallNr); // 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 @@ -9951,9 +10412,9 @@ export class CentralizedKernelWorker { this.discardStoppedChannelStateForProcess(exitingPid); // Idempotency guard — both handleExit and reapKilledProcessesAfterSyscall // can route here for the same pid; do the parent-wakeup work exactly - // once per generation. Cleared by deactivateProcess + registerProcess - // so a recycled pid (currently impossible with monotonic nextChildPid, - // but defensive) starts fresh. + // once for this kernel task ID. The allocator never recycles task IDs; + // deactivateProcess and same-PID exec registration only clear retired + // host transport state. if (this.hostReaped.has(exitingPid)) return; // Mark the transition before publishing shared mappings. A final writeback // can itself cross the kernel and rediscover the same Exited process; the @@ -10030,12 +10491,21 @@ export class CentralizedKernelWorker { pid: number, signum: number = 11 /* SIGSEGV */, ): void { - this.discardStoppedChannelStateForProcess(pid); if (this.hostReaped.has(pid)) return; const markSignaled = this.kernelInstance!.exports .kernel_mark_process_signaled as ((pid: number, signum: number) => number) | undefined; - if (markSignaled && markSignaled(pid, signum) < 0) return; + if (!markSignaled) { + throw new Error("Kernel missing required kernel_mark_process_signaled export"); + } + const result = markSignaled(pid, signum); + if (result !== 0) { + const detail = result < 0 ? `errno ${-result}` : `invalid result ${result}`; + throw new Error( + `Kernel rejected signal-death transition for process ${pid}: ${detail}`, + ); + } + this.discardStoppedChannelStateForProcess(pid); this.hostReaped.add(pid); this.releaseAllSharedMemoryForProcess(pid); // Signal termination closes Rust-owned advisory locks. Consume that wake @@ -10063,7 +10533,7 @@ export class CentralizedKernelWorker { const pids = Array.from(this.processes.keys()); for (const pid of pids) { if (this.getProcessExitSignal(pid) <= 0) continue; - if (this.hostReaped.has(pid)) continue; // already reaped this generation + if (this.hostReaped.has(pid)) continue; // already handled for this task ID // Cancel any pending blocking-syscall timers — the process is gone. this.cancelPendingSleepsForProcess(pid); @@ -10133,8 +10603,8 @@ export class CentralizedKernelWorker { /** Track pids the host has already reaped (prevents double-reaping * when reapKilledProcessesAfterSyscall is called multiple times for - * the same already-Exited process). Cleared when the pid is - * re-allocated by a fresh fork+register. */ + * the same already-Exited process). Kernel task IDs are monotonic and + * never reused; entries are cleared only with retired host transport state. */ private hostReaped = new Set(); /** @@ -10173,7 +10643,7 @@ export class CentralizedKernelWorker { } const eventMask = this.wait4EventMask(options); - const poll = this.pollWaitableChild(parentPid, targetPid, eventMask, 0); + const poll = this.pollWaitableChild(channel, targetPid, eventMask, 0); if (poll.kind === "error") { this.completeWaitpid(channel, origArgs, -1, poll.errno); return; @@ -10225,20 +10695,22 @@ export class CentralizedKernelWorker { } private pollWaitableChild( - parentPid: number, + channel: ChannelInfo, targetPid: number, eventMask: number, flags: number, ): WaitPollResult { const waitPoll = this.kernelInstance!.exports.kernel_wait_child_poll as ( parentPid: number, + callerTid: number, targetPid: number, eventMask: number, flags: number, resultPtr: KernelPointer, ) => number; const result = waitPoll( - parentPid, + channel.pid, + this.guestTidForChannel(channel), targetPid, eventMask, flags, @@ -10352,7 +10824,7 @@ export class CentralizedKernelWorker { // 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, true); + this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return; this.completeChannel( channel, @@ -10370,7 +10842,7 @@ export class CentralizedKernelWorker { retVal: number, errVal: number, ): void { - this.dequeueSignalForDelivery(channel, true); + this.dequeueSignalForDelivery(channel); if (this.finishSignalTermination(channel)) return; this.completeChannel( channel, @@ -10388,7 +10860,7 @@ export class CentralizedKernelWorker { * reissues wait4/waitid when the delivered action has SA_RESTART. */ private interruptWaiterWithPendingSignal(waiter: WaitingForChild): boolean { - const deliveredSignal = this.dequeueSignalForDelivery(waiter.channel, true); + const deliveredSignal = this.dequeueSignalForDelivery(waiter.channel); if (this.finishSignalTermination(waiter.channel)) return true; if (deliveredSignal <= 0) return false; @@ -10502,7 +10974,7 @@ export class CentralizedKernelWorker { const pollFlags = waiter.syscallNr === SYS_WAITID ? waiter.options & WAIT_WNOWAIT : 0; const waiterPoll = this.pollWaitableChild( - waiter.parentPid, + waiter.channel, waiter.pid, eventMask, pollFlags, @@ -10566,7 +11038,7 @@ export class CentralizedKernelWorker { // a process-group change silently consume or reap an eligible event. const pollFlags = WAIT_WNOWAIT; const poll = this.pollWaitableChild( - waiter.parentPid, + waiter.channel, waiter.pid, eventMask, pollFlags, @@ -10643,7 +11115,7 @@ export class CentralizedKernelWorker { } const poll = this.pollWaitableChild( - parentPid, + channel, waitPid, eventMask, options & WAIT_WNOWAIT, @@ -10896,11 +11368,40 @@ export class CentralizedKernelWorker { * Removes thread state from the process's thread table. */ notifyThreadExit(pid: number, tid: number): void { - if (!this.kernelInstance) return; + if (!this.kernelInstance) { + throw new Error("Kernel is not initialized for thread cleanup"); + } const threadExit = this.kernelInstance.exports.kernel_thread_exit as ((pid: number, tid: number) => number) | undefined; - if (threadExit) { - threadExit(pid, tid); + 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; + throw new KernelTaskBindingError( + pid, + tid, + errno, + `Kernel could not remove tid ${tid} from process ${pid}: errno ${errno}`, + ); + } + } + + /** + * Roll back a TID that Rust committed before host Worker launch failed. + * ESRCH is idempotent success here: an entry-layer failure path or exec may + * 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 { + try { + this.notifyThreadExit(pid, tid); + } catch (error) { + if (error instanceof KernelTaskBindingError && error.errno === ESRCH) { + return; + } + throw error; } } @@ -10914,28 +11415,45 @@ export class CentralizedKernelWorker { * futex word used by joiners. */ finalizeThreadExit(pid: number, tid: number, channelOffset: number): void { - const tidKey = `${pid}:${channelOffset}`; - this.channelTids.delete(tidKey); - this.threadForkContexts.delete(tidKey); - const ctidKey = `${pid}:${tid}`; const ctidPtr = this.threadCtidPtrs.get(ctidKey); - if (ctidPtr && ctidPtr !== 0) { - this.threadCtidPtrs.delete(ctidKey); - const channel = this.activeChannels.find( - (ch) => ch.pid === pid && ch.channelOffset === channelOffset, - ); - const memory = channel?.memory ?? this.processes.get(pid)?.memory; - if (memory) { + const channel = this.activeChannels.find( + (ch) => ch.pid === pid && ch.channelOffset === channelOffset, + ); + const memory = channel?.memory ?? this.processes.get(pid)?.memory; + + // 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); + try { + if (ctidPtr && ctidPtr !== 0) { + if (!memory) { + throw new KernelTaskBindingError( + pid, + tid, + EFAULT, + `Missing process memory for clear-TID of tid ${tid} in process ${pid}`, + ); + } + const bytes = new Uint8Array(memory.buffer); + if ((ctidPtr & 3) !== 0 || !isValidMemoryRange(bytes, ctidPtr, 4)) { + throw new KernelTaskBindingError( + pid, + tid, + EFAULT, + `Invalid clear-TID pointer ${ctidPtr} for tid ${tid} in process ${pid}`, + ); + } const procView = new DataView(memory.buffer); procView.setInt32(ctidPtr, 0, true); const i32View = new Int32Array(memory.buffer); Atomics.notify(i32View, ctidPtr >>> 2, 1); } + } finally { + this.threadCtidPtrs.delete(ctidKey); + this.removeChannel(pid, channelOffset); } - - this.notifyThreadExit(pid, tid); - this.removeChannel(pid, channelOffset); } /** Queue one host-scheduled expiration through the ABI-required kernel path. */ @@ -11038,12 +11556,15 @@ export class CentralizedKernelWorker { 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. + try { + this.bindKernelTid(targetPid, targetPid); + } catch { + return; + } this.currentHandlePid = targetPid; - // Host-originated process signals are shared deliveries. Force tid=0 so - // the kernel does not consult state left over from a prior dispatch. - const setTid = this.kernelInstance.exports.kernel_set_current_tid as - ((tid: number) => void) | undefined; - if (setTid) setTid(0); try { handleChannel(this.toKernelPtr(this.scratchOffset), targetPid); } catch (err) { @@ -11615,9 +12136,9 @@ export class CentralizedKernelWorker { const previousPid = this.currentHandlePid; let hostHandle: number | null = null; - this.currentHandlePid = channel.pid; try { this.bindKernelTidForChannel(channel as ChannelInfo); + this.currentHandlePid = channel.pid; hostHandle = this.kernel.withFstatHandleCapture(() => handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid) ).handle; @@ -11702,9 +12223,9 @@ export class CentralizedKernelWorker { } const previousPid = this.currentHandlePid; - this.currentHandlePid = channel.pid; try { this.bindKernelTidForChannel(channel as ChannelInfo); + this.currentHandlePid = channel.pid; handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } catch { return { kind: "error", errno: EIO }; @@ -12772,8 +13293,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } catch { @@ -12903,8 +13424,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); if (this.finishSignalTermination(channel)) return false; @@ -13123,20 +13644,6 @@ export class CentralizedKernelWorker { } } - private withKernelCurrentPid(pid: number, operation: () => T): T { - const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as - ((pid: number) => void) | undefined; - const previousPid = this.currentHandlePid; - this.currentHandlePid = pid; - if (setCurrentPid) setCurrentPid(pid); - try { - return operation(); - } finally { - this.currentHandlePid = previousPid; - if (setCurrentPid) setCurrentPid(previousPid); - } - } - private hasPeerSysvShmMapping(pid: number, mapAddr: number, segId: number): boolean { for (const [otherPid, mappings] of this.shmMappings) { for (const [otherAddr, mapping] of mappings) { @@ -13156,13 +13663,11 @@ export class CentralizedKernelWorker { if (!pidMap) return true; const processMem = new Uint8Array(process.memory.buffer); let success = true; - this.withKernelCurrentPid(process.pid, () => { - 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; - } - }); + 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; + } return success; } @@ -13172,13 +13677,11 @@ export class CentralizedKernelWorker { const registration = this.processes.get(pid); if (!registration) continue; const processMem = new Uint8Array(registration.memory.buffer); - this.withKernelCurrentPid(pid, () => { - for (const [mapAddr, mapping] of mappings) { - if (mapping.segId === segId) { - this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping); - } + for (const [mapAddr, mapping] of mappings) { + if (mapping.segId === segId) { + this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping); } - }); + } } } @@ -13321,47 +13824,46 @@ export class CentralizedKernelWorker { 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 as - ((shmid: number, shmaddr: number, flags: number) => number) | undefined; - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as - ((shmid: number) => number) | undefined; + 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 + ((pid: number, shmid: number) => number) | undefined; if (!kernelShmat || !kernelShmdt) throw new Error("Kernel lacks SysV SHM inheritance exports"); const childMem = new Uint8Array(child.memory.buffer); const childMap = new Map(); - this.withKernelCurrentPid(childPid, () => { - 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( - 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(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, - }); + 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)}`); } - } catch (err) { - for (const mapping of childMap.values()) kernelShmdt(mapping.segId); - childMap.clear(); - throw err; + 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, + }); } - }); + } catch (err) { + for (const mapping of childMap.values()) kernelShmdt(childPid, mapping.segId); + childMap.clear(); + throw err; + } if (childMap.size > 0) this.shmMappings.set(childPid, childMap); } @@ -13375,12 +13877,10 @@ export class CentralizedKernelWorker { if (publish && registration) { this.syncSysvShmMappingsFromProcess(registration, { force: true }); } - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as - ((shmid: number) => number) | undefined; + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt_for_process as + ((pid: number, shmid: number) => number) | undefined; if (kernelShmdt) { - this.withKernelCurrentPid(pid, () => { - for (const mapping of pidMap.values()) kernelShmdt(mapping.segId); - }); + for (const mapping of pidMap.values()) kernelShmdt(pid, mapping.segId); } this.shmMappings.delete(pid); } @@ -13444,11 +13944,6 @@ export class CentralizedKernelWorker { } } - /** Set the next child PID to allocate. */ - setNextChildPid(pid: number): void { - this.nextChildPid = pid; - } - /** * Set the mmap address space ceiling for a process. * Must be called before the process worker starts to prevent mmap @@ -14655,8 +15150,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); kernelMem.fill(0, dataStart, dataStart + 72); - this.currentHandlePid = channel.pid; 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)); @@ -14683,8 +15178,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); kernelMem.fill(0, dataStart, dataStart + maxBytes); - this.currentHandlePid = channel.pid; 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)); @@ -14713,8 +15208,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - this.currentHandlePid = channel.pid; 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)); @@ -14734,8 +15229,8 @@ export class CentralizedKernelWorker { kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - this.currentHandlePid = channel.pid; 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)); @@ -14757,8 +15252,8 @@ export class CentralizedKernelWorker { const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; const previousPid = this.currentHandlePid; - this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); + this.currentHandlePid = channel.pid; try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } finally { @@ -14777,18 +15272,23 @@ export class CentralizedKernelWorker { /** shmat: allocate a process interval and attach it to authoritative bytes. */ private handleIpcShmat(channel: ChannelInfo, args: number[]): void { const [shmid, shmaddr, flags] = args; + const callerTid = this.guestTidForChannel(channel); + this.validateKernelTid(channel.pid, callerTid); // A previously sole observer may not have published at ordinary boundaries. // Force it current before this new attachment reads the segment. this.syncSysvShmSegmentFromMappedProcesses(shmid); - const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat as - (shmid: number, shmaddr: number, flags: number) => number; - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as - (shmid: number) => number; - const sizeOrErr = this.withKernelCurrentPid( + const kernelShmat = this.kernelInstance!.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, - () => kernelShmat(shmid, shmaddr, flags), + callerTid, + shmid, + shmaddr, + flags, ); if (sizeOrErr < 0) { this.completeChannelRaw(channel, sizeOrErr, -sizeOrErr); @@ -14806,7 +15306,7 @@ export class CentralizedKernelWorker { if (this.hostReaped?.has(channel.pid)) return; } try { - this.withKernelCurrentPid(channel.pid, () => kernelShmdt(shmid)); + kernelShmdt(channel.pid, shmid); } catch {} }; @@ -14845,10 +15345,7 @@ export class CentralizedKernelWorker { allocatedAddr, [shmaddr, size, prot, 0x22, -1, 0], ); - const snapshot = this.withKernelCurrentPid( - channel.pid, - () => this.readSysvShmRange(shmid, 0, size), - ); + const snapshot = this.readSysvShmRange(shmid, 0, size); const processMem = new Uint8Array(channel.memory.buffer); if (!snapshot || allocatedAddr + size > processMem.length) { rollback(); @@ -14886,6 +15383,8 @@ export class CentralizedKernelWorker { /** shmdt: publish this attachment, detach exactly once, and unmap it. */ private handleIpcShmdt(channel: ChannelInfo, args: number[]): void { + const callerTid = this.guestTidForChannel(channel); + this.validateKernelTid(channel.pid, callerTid); const addr = args[0] >>> 0; const pidMappings = this.shmMappings.get(channel.pid); if (!pidMappings) { @@ -14901,21 +15400,19 @@ export class CentralizedKernelWorker { } const processMem = new Uint8Array(channel.memory.buffer); - const synced = this.withKernelCurrentPid( - channel.pid, - () => this.mergeAndRefreshSysvShmMapping(processMem, addr, mapping), - ); + const synced = this.mergeAndRefreshSysvShmMapping(processMem, addr, mapping); if (!synced) { this.completeChannelRaw(channel, -EIO, EIO); this.relistenChannel(channel); return; } - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as - (shmid: number) => number; - const result = this.withKernelCurrentPid( + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt_for_task as + (pid: number, tid: number, shmid: number) => number; + const result = kernelShmdt( channel.pid, - () => kernelShmdt(mapping.segId), + callerTid, + mapping.segId, ); if (result < 0) { diff --git a/host/src/kernel.ts b/host/src/kernel.ts index f099ec694d..6ea7585d54 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -217,13 +217,10 @@ const WASM_STATFS_SIZE = 72; const WASM_DIRENT_SIZE = STRUCT_SIZE_WASM_DIRENT; export interface KernelCallbacks { - onKill?: (pid: number, signal: number) => number; onExec?: (path: string) => number; onAlarm?: (seconds: number) => number; onPosixTimer?: (timerId: number, signo: number, valueMs: number, intervalMs: number) => number; - onFork?: (forkSab: SharedArrayBuffer) => void; onWaitpid?: (targetPid: number, options: number) => void; - onClone?: (fnPtr: number, arg: number, stackPtr: number, tlsPtr: number, ctidPtr: number) => number; onNetListen?: (fd: number, port: number, addr: [number, number, number, number]) => number; onUdpBind?: (handle: number, addr: [number, number, number, number], port: number) => number; onUdpUnbind?: (handle: number) => number; @@ -266,7 +263,6 @@ export class WasmPosixKernel { private sharedPipes = new Map(); private signalWakeSab: SharedArrayBuffer | null = null; private programFuncTable: WebAssembly.Table | null = null; - private forkSab: SharedArrayBuffer | null = null; private waitpidSab: SharedArrayBuffer | null = null; /** * A backend directory iterator may already have advanced before the host @@ -291,8 +287,6 @@ export class WasmPosixKernel { /** Active synchronous host_fstat capture used by mmap preflight. */ private fstatHandleCapture: { handle: number | null } | null = null; isThreadWorker = false; - /** PID for this kernel instance (set by the worker) */ - pid = 0; /** * Live `/dev/fb0` mappings the kernel has reported via * `host_bind_framebuffer`. Renderers (canvas in browser, no-op in @@ -561,10 +555,6 @@ export class WasmPosixKernel { this.signalWakeSab = sab; } - registerForkSab(sab: SharedArrayBuffer): void { - this.forkSab = sab; - } - registerWaitpidSab(sab: SharedArrayBuffer): void { this.waitpidSab = sab; } @@ -697,9 +687,6 @@ export class WasmPosixKernel { host_fchown: (handle: bigint, uid: number, gid: number): number => { return this.hostFchown(handle, uid, gid); }, - host_kill: (pid: number, sig: number): number => { - return this.hostKill(pid, sig); - }, host_exec: (pathPtr: bigint, pathLen: number): number => { return this.hostExec(Number(pathPtr), pathLen); }, @@ -807,18 +794,12 @@ export class WasmPosixKernel { host_getaddrinfo: (namePtr: bigint, nameLen: number, resultPtr: bigint, resultLen: number): number => { return this.hostGetaddrinfo(Number(namePtr), nameLen, Number(resultPtr), resultLen); }, - host_fork: (): number => { - return this.hostFork(); - }, host_futex_wait: (addr: bigint, expected: number, timeoutLo: number, timeoutHi: number): number => { return this.hostFutexWait(Number(addr), expected, timeoutLo, timeoutHi); }, host_futex_wake: (addr: bigint, count: number): number => { return this.hostFutexWake(Number(addr), count); }, - host_clone: (fnPtr: bigint, arg: bigint, stackPtr: bigint, tlsPtr: bigint, ctidPtr: bigint): number => { - return this.hostClone(Number(fnPtr), Number(arg), Number(stackPtr), Number(tlsPtr), Number(ctidPtr)); - }, host_is_thread_worker: (): number => { return this.isThreadWorker ? 1 : 0; }, @@ -1968,15 +1949,6 @@ export class WasmPosixKernel { } } - // ---- Phase 13d: Cross-process kill ---- - - private hostKill(pid: number, sig: number): number { - if (this.callbacks.onKill) { - return this.callbacks.onKill(pid, sig); - } - return -3; // -ESRCH: no callback means can't reach other processes - } - // ---- Phase 13e: Exec ---- private hostExec(pathPtr: number, pathLen: number): number { @@ -2684,40 +2656,6 @@ export class WasmPosixKernel { } } - /** - * host_fork() -> i32 - * Guest-initiated fork. Posts fork_request to host, blocks on Atomics.wait - * until host signals back with child PID via forkSab. - * - * forkSab layout: Int32Array(2) on SharedArrayBuffer(8) - * [0] = flag (0 = waiting, 1 = done) - * [1] = result (child PID or negative errno) - */ - private hostFork(): number { - if (!this.forkSab) { - return -38; // -ENOSYS - } - - const view = new Int32Array(this.forkSab); - - // Reset flag - Atomics.store(view, 0, 0); - Atomics.store(view, 1, 0); - - // Notify host via callback - if (this.callbacks.onFork) { - this.callbacks.onFork(this.forkSab); - } else { - return -38; // -ENOSYS — no fork handler registered - } - - // Block until host signals completion - Atomics.wait(view, 0, 0); - - // Read result (child PID or negative errno) - return Atomics.load(view, 1); - } - private hostFutexWait(addr: number, expected: number, timeoutLo: number, timeoutHi: number): number { if (!this.memory) return -22; // -EINVAL @@ -2753,11 +2691,4 @@ export class WasmPosixKernel { return Atomics.notify(i32view, index, count); } - private hostClone(fnPtr: number, arg: number, stackPtr: number, tlsPtr: number, ctidPtr: number): number { - if (this.callbacks.onClone) { - return this.callbacks.onClone(fnPtr, arg, stackPtr, tlsPtr, ctidPtr); - } - return -38; // -ENOSYS — no clone handler registered - } - } diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index 12d6328807..11eb216872 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -99,6 +99,11 @@ export interface NodeKernelHostOptions { * to a VFS-only world yet. */ rootfsImage?: "default" | ArrayBuffer | Uint8Array; + /** + * Resolve relative lazy URLs embedded in rootfsImage before transport. + * This is the Node peer of BrowserKernel's lazyUrlBase contract. + */ + rootfsLazyUrlBase?: string; /** * Exhaustive exact URL-to-byte transport for lazy entries in rootfsImage. * Intended for offline and pre-publication acceptance: when set, unbound @@ -161,6 +166,12 @@ export class NodeKernelHost { if (this.options.rootfsLazyAssets !== undefined && rootfsImage === null) { throw new Error("rootfsLazyAssets requires rootfsImage"); } + if (this.options.rootfsLazyUrlBase !== undefined && rootfsImage === null) { + throw new Error("rootfsLazyUrlBase requires rootfsImage"); + } + if (this.options.rootfsLazyUrlBase === "") { + throw new Error("rootfsLazyUrlBase must not be empty"); + } const rootfsLazyAssets = this.options.rootfsLazyAssets === undefined ? undefined : snapshotClosedLazyAssets(this.options.rootfsLazyAssets); @@ -233,6 +244,7 @@ export class NodeKernelHost { }, execPrograms: this.options.execPrograms, rootfsImage: rootfsImage ?? undefined, + rootfsLazyUrlBase: this.options.rootfsLazyUrlBase, rootfsLazyAssets, extraMounts: this.options.extraMounts, enableTcpNetwork: this.options.enableTcpNetwork, @@ -287,9 +299,9 @@ export class NodeKernelHost { ) { this.unclaimedExitStatuses.delete(pid); } else if (unclaimedExitStatus !== undefined) { - // PIDs can be reused. An older unclaimed exit for the same numeric PID - // must not satisfy this new spawn, or callers observe an immediate - // success while the new process is still running. + // Defensively discard a stale unclaimed exit for this numeric identity. + // The current kernel never reuses task IDs, so a nonmatching sequence is + // host bookkeeping from an obsolete generation, not this spawn's exit. this.unclaimedExitStatuses.delete(pid); } const exitPromise = unclaimedExitStatus !== undefined && diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index effa45b7ea..c23c5896ef 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -41,6 +41,8 @@ export interface InitMessage { * (custom-io / legacy path). */ rootfsImage?: ArrayBuffer; + /** Base used to resolve relative lazy URLs embedded in rootfsImage. */ + rootfsLazyUrlBase?: string; /** Exhaustive exact-byte lazy transport for this rootfs; no network fallback. */ rootfsLazyAssets?: ClosedLazyAsset[]; extraMounts?: Array<{ diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index dcfef1f71b..45adced395 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -28,6 +28,7 @@ import type { ForkFromThreadContext, ResolvedSpawnProgram, SpawnProgramResolution, + ThreadChannelAttachment, } from "./kernel-worker"; import { NodePlatformIO } from "./platform/node"; import { @@ -43,6 +44,7 @@ import { } from "./vfs"; import type { MountConfig } from "./vfs/types"; import { createClosedLazyAssetFetcherFromOwnedAssets } from "./vfs/closed-lazy-assets"; +import { resolveLazyUrl } from "./vfs/lazy-url"; import { TcpNetworkBackend } from "./networking/tcp-backend"; import { findRepoRoot } from "./binary-resolver"; import { NodeWorkerAdapter } from "./worker-adapter"; @@ -50,6 +52,7 @@ 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 { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD, WASM_PAGE_SIZE } from "./constants"; import { @@ -259,6 +262,17 @@ async function finalizeProcessWorker( if (intentionallyTerminated.has(worker as object)) return; const cur = processes.get(pid); if (!cur || cur.worker !== worker) return; + + // A kernel-side exit callback may already be draining this exact Worker + // generation. Its teardown deliberately keeps channels registered until + // every backing Worker is gone, so a trailing worker-main exit/error event + // must not race in here and deactivate the pid early. The browser entry + // funnels the same events through finishProcessExit(), whose teardown-map + // guard provides this ordering directly. + if (processTeardowns.has(worker)) { + reportProcessExit(pid, exitStatus); + return; + } vmInterruptTimers.clear(pid); // Synthesize a signal-style reap *before* `deactivateProcess` in @@ -276,7 +290,7 @@ async function finalizeProcessWorker( // Report while this worker is still known to be the current generation. // Its asynchronous termination must not report an exit for an exec - // replacement that has since reused the pid. + // replacement that has since installed a new generation under the same pid. reportProcessExit(pid, exitStatus); await terminateThreadWorkers(pid); await terminateTrackedWorker(worker); @@ -541,6 +555,7 @@ function buildVirtualPlatformIO( uid?: number; gid?: number; }>, + rootfsLazyUrlBase?: InitMessage["rootfsLazyUrlBase"], rootfsLazyAssets?: InitMessage["rootfsLazyAssets"], ): VirtualPlatformIO { const bootSessionDir = mkdtempSync(join(tmpdir(), "wasm-posix-session-")); @@ -573,6 +588,10 @@ function buildVirtualPlatformIO( : null; if (rootfsMemfs) { ensureMountParentDirectories(rootfsMemfs, extras.map((m) => m.mountPoint)); + if (rootfsLazyUrlBase !== undefined) { + rootfsMemfs.rewriteLazyFileUrls((url) => resolveLazyUrl(rootfsLazyUrlBase, url)); + rootfsMemfs.rewriteLazyArchiveUrls((url) => resolveLazyUrl(rootfsLazyUrlBase, url)); + } rootfsMemfs.subscribeLazyDownloads((event) => { post({ type: "lazy_download", event }); }); @@ -614,7 +633,12 @@ async function handleInit(msg: InitMessage) { workerAdapter = new NodeWorkerAdapter(); const io: PlatformIO = msg.rootfsImage - ? buildVirtualPlatformIO(msg.rootfsImage, msg.extraMounts, msg.rootfsLazyAssets) + ? buildVirtualPlatformIO( + msg.rootfsImage, + msg.extraMounts, + msg.rootfsLazyUrlBase, + msg.rootfsLazyAssets, + ) : new NodePlatformIO(); vfsExecIO = msg.rootfsImage ? io : null; if (msg.enableTcpNetwork) { @@ -681,17 +705,17 @@ async function handleInit(msg: InitMessage) { // --- Spawn --- function handleSpawn(msg: SpawnMessage) { - let registeredPid: number | undefined; + let createdPid: number | undefined; try { - // Shared source of truth with fork(); a worker-local counter would let a - // spawn reuse a kernel-owned pid and fail kernel_create with EEXIST. - const pid = kernelWorker.allocateTopLevelSpawnPid(); - if (!isWasmModuleBytes(msg.programBytes)) { respondError(msg.requestId, "ENOEXEC: program is not a WebAssembly module"); return; } + const pid = kernelWorker.createProcess( + msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, + ); + createdPid = pid; const ptrWidth = detectPtrWidth(msg.programBytes); const { memory, @@ -707,9 +731,7 @@ function handleSpawn(msg: SpawnMessage) { brkBase: layout.brkBase, mmapBase: layout.mmapBase, maxAddr: layout.maxAddr, - stdio: msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, }); - registeredPid = pid; kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); if (msg.cwd) { @@ -743,7 +765,6 @@ function handleSpawn(msg: SpawnMessage) { const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes: msg.programBytes, programModule: msg.programModule, memory, @@ -790,12 +811,13 @@ function handleSpawn(msg: SpawnMessage) { }); installCrashSafetyNet(worker, pid); - registeredPid = undefined; + createdPid = undefined; respond(msg.requestId, pid); } catch (e) { - if (registeredPid !== undefined) { - kernelWorker.unregisterProcess(registeredPid); + if (createdPid !== undefined) { + kernelWorker.unregisterProcess(createdPid); + kernelWorker.removeProcessFromKernelTable(createdPid); } respondError(msg.requestId, String(e)); } @@ -835,7 +857,6 @@ async function handleFork( new Uint8Array(childMemory.buffer, childChannelOffset, CH_TOTAL_SIZE).fill(0); kernelWorker.registerProcess(childPid, childMemory, [childChannelOffset], { - skipKernelCreate: true, ptrWidth, maxAddr: childLayout.maxAddr, mmapBase: childLayout.mmapBase, @@ -843,19 +864,25 @@ async function handleFork( kernelWorker.inheritProcessSharedMappings(parentPid, childPid); const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; + const activeForkBufAddr = threadFork?.forkBufAddr ?? readForkContinuationAnchor( + parentMemory, + parentInfo.channelOffset - FORK_BUF_SIZE, + ptrWidth, + ); const forkReplayContext: ForkReplayContext | undefined = threadFork ? { fnPtr: threadFork.fnPtr, argPtr: threadFork.argPtr, - forkBufAddr: threadFork.forkBufAddr, + forkBufAddr: activeForkBufAddr, } - : parentInfo.forkReplayContext; - const forkBufAddr = forkReplayContext?.forkBufAddr ?? childChannelOffset - FORK_BUF_SIZE; + : parentInfo.forkReplayContext + ? { ...parentInfo.forkReplayContext, forkBufAddr: activeForkBufAddr } + : undefined; + const forkBufAddr = activeForkBufAddr; const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: parentPid, programBytes: parentProgram, programModule: parentInfo.programModule, memory: childMemory, @@ -954,9 +981,9 @@ async function handleExec( return -12; // ENOMEM before the exec commit point } - // Resolution/compilation yielded to the event loop. The numeric pid may - // now name a replacement generation; a stale continuation must not commit - // exec state against it. + // Resolution/compilation yielded to the event loop. Another exec may have + // replaced the host execution generation for this persistent PID; a stale + // continuation must not commit exec state against it. if (processes.get(pid) !== initiatingInfo || kernelWorker.isExecHandoffActive(pid) || !kernelWorker.isProcessExecutionActive(pid)) return -3; // ESRCH @@ -999,7 +1026,6 @@ async function handleExec( const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes, programModule, memory: newMemory, @@ -1011,7 +1037,7 @@ async function handleExec( }; kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - skipKernelCreate: true, + preserveProcessState: true, ptrWidth: newPtrWidth, metadataPtrWidth: initiatingInfo.ptrWidth, brkBase: newLayout.brkBase, @@ -1120,9 +1146,8 @@ async function handleExec( * 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, registers it with the kernel via - * `registerProcess({ skipKernelCreate: true })`, and launches a Worker - * for it. + * 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 @@ -1176,10 +1201,8 @@ async function handlePosixSpawn( } = createFreshProcessMemory(childPid, programBytes, ptrWidth); const channelOffset = layout.channelOffset; - // The kernel already created the child Process via kernel_spawn_process, - // so skip the kernelCreate side of registerProcess. + // The kernel already created the child Process via kernel_spawn_process. kernelWorker.registerProcess(childPid, memory, [channelOffset], { - skipKernelCreate: true, ptrWidth, brkBase: layout.brkBase, mmapBase: layout.mmapBase, @@ -1189,7 +1212,6 @@ async function handlePosixSpawn( const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: parentPid, programBytes, programModule, memory, @@ -1257,15 +1279,10 @@ async function handlePosixSpawn( } async function handleClone( - pid: number, - tid: number, - fnPtr: number, - argPtr: number, - stackPtr: number, - tlsPtr: number, - ctidPtr: number, - memory: WebAssembly.Memory, -): Promise { + attachment: ThreadChannelAttachment, +): Promise { + const { pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory } = + attachment; const processInfo = processes.get(pid); if (!processInfo) throw new Error(`Unknown pid ${pid} for clone`); @@ -1280,7 +1297,7 @@ async function handleClone( // Compilation yields. A sibling pthread may have committed exec while this // clone continuation was suspended; never attach the old program/Memory to - // the replacement process that now owns the same numeric pid. + // the replacement exec image for the same process identity. if (!isCurrentProcessGeneration( processes, pid, @@ -1308,7 +1325,7 @@ async function handleClone( // this thread back through its entry point (see ForkFromThreadContext // in kernel-worker.ts). try { - kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + kernelWorker.attachThreadChannel(attachment, alloc.channelOffset); } catch (err) { processInfo.threadAllocator.free(alloc.basePage); throw err; @@ -1440,7 +1457,6 @@ async function handleClone( throw new Error(`Process ${pid} changed generation before thread Worker launch`); } - return tid; } function handleThreadExit(pid: number, channelOffset: number): boolean { diff --git a/host/src/vfs/closed-lazy-assets.ts b/host/src/vfs/closed-lazy-assets.ts index f9f9a1482c..9e8228521a 100644 --- a/host/src/vfs/closed-lazy-assets.ts +++ b/host/src/vfs/closed-lazy-assets.ts @@ -11,10 +11,164 @@ export interface ClosedLazyAsset { bytes: Uint8Array; } +/** + * One acceptance-only transport source whose bytes are bound to the canonical + * HTTPS URL stored in a deferred VFS tree only after verification. `sourceUrl` + * may be a canonical root-relative URL, an absolute HTTPS URL, or a loopback + * HTTP URL used by local acceptance. Fetches omit credentials and referrers + * and reject redirects; the exact size and SHA-256 declared here remain the + * authority. + */ +export interface ClosedLazyAssetSource { + url: string; + sourceUrl: string; + sha256: string; + size: number; +} + +type FetchLike = (input: string | URL, init?: RequestInit) => Promise; + const SHA256_RE = /^[0-9a-f]{64}$/; export const MAX_CLOSED_LAZY_ASSETS = 128; export const MAX_CLOSED_LAZY_ASSET_BYTES = 512 * 1024 * 1024; +/** + * Fetch and verify acceptance-only sources before giving them canonical lazy + * transport identities. This never treats the source URL as VFS authority: + * only the separately declared HTTPS URL, digest, and size survive. A caller + * abort or first source failure stops new work and closes every active body + * before the loader rejects with that exact first reason. + */ +export async function loadClosedLazyAssetSources( + sources: readonly ClosedLazyAssetSource[], + options: { + fetchImpl?: FetchLike; + maxConcurrency?: number; + signal?: AbortSignal; + } = {}, +): Promise { + const validated = validateClosedLazyAssetSources(sources); + const fetchImpl = options.fetchImpl ?? fetch; + const maxConcurrency = options.maxConcurrency ?? 4; + if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1 || maxConcurrency > 16) { + throw new Error( + "closed lazy asset source concurrency must be an integer from 1 to 16", + ); + } + + const controller = new AbortController(); + let firstFailure: { reason: unknown } | undefined; + const fail = (reason: unknown): void => { + if (firstFailure !== undefined) return; + firstFailure = { reason }; + controller.abort(reason); + }; + + const callerSignal = options.signal; + const onCallerAbort = (): void => fail(callerSignal!.reason); + let callerListenerAdded = false; + if (callerSignal?.aborted) { + fail(callerSignal.reason); + } else if (callerSignal !== undefined) { + callerSignal.addEventListener("abort", onCallerAbort, { once: true }); + callerListenerAdded = true; + } + + const output = new Array(validated.length); + let next = 0; + const loadOne = async ( + source: ClosedLazyAssetSource, + ): Promise => { + const diagnosticUrl = redactSourceUrl(source.sourceUrl); + try { + throwIfAborted(controller.signal); + const response = await fetchImpl(source.sourceUrl, { + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + redirect: "error", + signal: controller.signal, + }); + if (controller.signal.aborted) { + await cancelResponseBody(response, controller.signal.reason); + throw controller.signal.reason; + } + if (response.redirected) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} followed a redirect`, + ); + fail(error); + await cancelResponseBody(response, error); + throw error; + } + if (!response.ok) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} returned HTTP ${response.status}`, + ); + fail(error); + await cancelResponseBody(response, error); + throw error; + } + const bytes = await readExactResponseBytes( + response, + source.size, + diagnosticUrl, + controller.signal, + fail, + ); + throwIfAborted(controller.signal); + const actualSha256 = hex( + new Uint8Array( + await crypto.subtle.digest("SHA-256", bytes.buffer), + ), + ); + throwIfAborted(controller.signal); + if (actualSha256 !== source.sha256) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} changed SHA-256`, + ); + fail(error); + throw error; + } + return { + url: source.url, + sha256: source.sha256, + size: source.size, + bytes, + }; + } catch (reason) { + fail(reason); + throw reason; + } + }; + + try { + const workers = Array.from( + { length: Math.min(maxConcurrency, validated.length) }, + async () => { + while (firstFailure === undefined) { + const index = next; + next += 1; + if (index >= validated.length) return; + try { + output[index] = await loadOne(validated[index]!); + } catch (reason) { + fail(reason); + return; + } + } + }, + ); + await Promise.all(workers); + if (firstFailure !== undefined) throw firstFailure.reason; + return output; + } finally { + if (callerListenerAdded) { + callerSignal!.removeEventListener("abort", onCallerAbort); + } + } +} + /** Validate and snapshot a bounded, canonical HTTPS URL-to-byte binding. */ export function snapshotClosedLazyAssets( assets: readonly ClosedLazyAsset[], @@ -36,32 +190,25 @@ function validateClosedLazyAssets( } const seen = new Set(); let totalBytes = 0; - return assets.map((asset, index) => { + const validated = new Array(assets.length); + for (let index = 0; index < assets.length; index += 1) { + if (!Object.hasOwn(assets, index)) { + throw new Error(`closed lazy asset ${index} is missing`); + } + const asset = assets[index]; if (typeof asset !== "object" || asset === null) { throw new Error(`closed lazy asset ${index} is not an object`); } const { url, sha256, size, bytes } = asset; if ( - typeof url !== "string" || !SHA256_RE.test(sha256) || + typeof url !== "string" || typeof sha256 !== "string" || + !SHA256_RE.test(sha256) || !Number.isSafeInteger(size) || size <= 0 || !(bytes instanceof Uint8Array) ) { throw new Error(`closed lazy asset ${index} has invalid fields`); } - let parsed: URL; - try { - parsed = new URL(url); - } catch (error) { - throw new Error(`closed lazy asset ${index} URL is invalid`, { cause: error }); - } - if ( - parsed.protocol !== "https:" || parsed.username !== "" || - parsed.password !== "" || parsed.hash !== "" || parsed.href !== url - ) { - throw new Error( - `closed lazy asset ${index} must use one canonical credential-free HTTPS URL`, - ); - } + validateCanonicalClosedUrl(url, `closed lazy asset ${index}`); if (seen.has(url)) { throw new Error(`closed lazy assets duplicate URL ${url}`); } @@ -86,8 +233,15 @@ function validateClosedLazyAssets( `closed lazy asset ${index} ownership requires one whole ordinary ArrayBuffer`, ); } - return { url, sha256, size, bytes: copy ? copyBytes(bytes) : bytes }; - }); + validated[index] = { url, sha256, size, bytes }; + } + if (!copy) return validated; + return validated.map(({ url, sha256, size, bytes }) => ({ + url, + sha256, + size, + bytes: copyBytes(bytes), + })); } /** @@ -144,6 +298,203 @@ function hex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } +function validateClosedLazyAssetSources( + sources: readonly ClosedLazyAssetSource[], +): ClosedLazyAssetSource[] { + if (!Array.isArray(sources) || sources.length === 0) { + throw new Error("closed lazy asset sources must contain at least one binding"); + } + if (sources.length > MAX_CLOSED_LAZY_ASSETS) { + throw new Error( + `closed lazy asset sources exceed ${MAX_CLOSED_LAZY_ASSETS} bindings`, + ); + } + const seen = new Set(); + let totalBytes = 0; + const validated = new Array(sources.length); + for (let index = 0; index < sources.length; index += 1) { + if (!Object.hasOwn(sources, index)) { + throw new Error(`closed lazy asset source ${index} is missing`); + } + const source = sources[index]; + if (typeof source !== "object" || source === null) { + throw new Error(`closed lazy asset source ${index} is not an object`); + } + const { url, sourceUrl, sha256, size } = source; + if ( + typeof url !== "string" || typeof sourceUrl !== "string" || + sourceUrl.length === 0 || typeof sha256 !== "string" || + !SHA256_RE.test(sha256) || + !Number.isSafeInteger(size) || size <= 0 + ) { + throw new Error(`closed lazy asset source ${index} has invalid fields`); + } + validateCanonicalClosedUrl(url, `closed lazy asset source ${index}`); + validateClosedSourceUrl(sourceUrl, index); + if (seen.has(url)) { + throw new Error(`closed lazy asset sources duplicate URL ${url}`); + } + totalBytes += size; + if (!Number.isSafeInteger(totalBytes) || totalBytes > MAX_CLOSED_LAZY_ASSET_BYTES) { + throw new Error( + `closed lazy asset sources exceed ${MAX_CLOSED_LAZY_ASSET_BYTES} bytes`, + ); + } + seen.add(url); + validated[index] = { url, sourceUrl, sha256, size }; + } + return validated; +} + +function validateCanonicalClosedUrl(url: string, label: string): void { + let parsed: URL; + try { + parsed = new URL(url); + } catch (error) { + throw new Error(`${label} URL is invalid`, { cause: error }); + } + if ( + parsed.protocol !== "https:" || parsed.username !== "" || + parsed.password !== "" || parsed.hash !== "" || url.includes("#") || + parsed.href !== url + ) { + throw new Error( + `${label} must use one canonical credential-free HTTPS URL`, + ); + } +} + +function validateClosedSourceUrl(sourceUrl: string, index: number): void { + const validationOrigin = "https://closed-source.invalid"; + let parsed: URL; + try { + parsed = new URL(sourceUrl, `${validationOrigin}/`); + } catch (error) { + throw new Error(`closed lazy asset source ${index} fetch URL is invalid`, { + cause: error, + }); + } + const relative = sourceUrl.startsWith("/"); + const serializedRelative = parsed.href.slice(validationOrigin.length); + const allowedProtocol = parsed.protocol === "https:" || + (parsed.protocol === "http:" && isLoopbackHostname(parsed.hostname)); + if ( + !allowedProtocol || + parsed.username !== "" || parsed.password !== "" || parsed.hash !== "" || + sourceUrl.includes("#") || + (relative + ? parsed.origin !== validationOrigin || serializedRelative !== sourceUrl + : parsed.href !== sourceUrl) + ) { + throw new Error( + `closed lazy asset source ${index} fetch URL must be canonical ` + + `root-relative, HTTPS, or loopback HTTP`, + ); + } +} + +function isLoopbackHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || + hostname === "[::1]"; +} + +async function readExactResponseBytes( + response: Response, + expectedBytes: number, + diagnosticUrl: string, + signal: AbortSignal, + fail: (reason: unknown) => void, +): Promise> { + if (response.body === null) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} has no response body`, + ); + fail(error); + throw error; + } + const output = new Uint8Array(expectedBytes); + const reader = response.body.getReader(); + let cancelPromise: Promise | undefined; + const cancel = (reason: unknown): Promise => { + if (cancelPromise !== undefined) return cancelPromise; + try { + cancelPromise = reader.cancel(reason).then( + () => {}, + () => {}, + ); + } catch { + cancelPromise = Promise.resolve(); + } + return cancelPromise; + }; + const onAbort = (): void => { + void cancel(signal.reason); + }; + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener("abort", onAbort, { once: true }); + } + let offset = 0; + try { + throwIfAborted(signal); + while (true) { + const { done, value } = await reader.read(); + throwIfAborted(signal); + if (done) break; + if (value.byteLength > expectedBytes - offset) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} exceeds ${expectedBytes} bytes`, + ); + fail(error); + await cancel(error); + throw error; + } + output.set(value, offset); + offset += value.byteLength; + } + if (offset !== expectedBytes) { + const error = new Error( + `closed lazy asset source ${diagnosticUrl} has ${offset} bytes, ` + + `expected ${expectedBytes}`, + ); + fail(error); + throw error; + } + return output; + } catch (reason) { + fail(reason); + throw reason; + } finally { + signal.removeEventListener("abort", onAbort); + if (signal.aborted) await cancel(signal.reason); + if (cancelPromise !== undefined) await cancelPromise; + reader.releaseLock(); + } +} + +async function cancelResponseBody( + response: Response, + reason: unknown, +): Promise { + if (response.body === null) return; + try { + await response.body.cancel(reason); + } catch { + // Cleanup failures must not replace the original transport failure. + } +} + +function throwIfAborted(signal: AbortSignal): void { + if (signal.aborted) throw signal.reason; +} + +function redactSourceUrl(sourceUrl: string): string { + const queryIndex = sourceUrl.indexOf("?"); + if (queryIndex === -1) return sourceUrl; + return `${sourceUrl.slice(0, queryIndex)}?`; +} + function copyBytes(bytes: Uint8Array): Uint8Array { const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index 10a59359b2..aa9c7b7a9f 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -10,17 +10,22 @@ export { export type { VfsDeferredTreeUsage } from "./deferred-tree-limits"; export { createClosedLazyAssetFetcher, + loadClosedLazyAssetSources, MAX_CLOSED_LAZY_ASSETS, MAX_CLOSED_LAZY_ASSET_BYTES, snapshotClosedLazyAssets, } from "./closed-lazy-assets"; -export type { ClosedLazyAsset } from "./closed-lazy-assets"; +export type { + ClosedLazyAsset, + ClosedLazyAssetSource, +} from "./closed-lazy-assets"; export type { LazyDownloadEvent, LazyDownloadKind, LazyDownloadListener, LazyDownloadStatus, LazyFileEntry, + LazyFetcherOptions, LazyTreeActivation, LazyTreeContent, LazyTreeDecoder, diff --git a/host/src/vfs/lazy-url.ts b/host/src/vfs/lazy-url.ts new file mode 100644 index 0000000000..a777e1b0fd --- /dev/null +++ b/host/src/vfs/lazy-url.ts @@ -0,0 +1,5 @@ +/** Resolve one image-owned relative lazy asset without rewriting absolute URLs. */ +export function resolveLazyUrl(base: string, url: string): string { + if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("/")) return url; + return base.replace(/\/?$/, "/") + url; +} diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index 47935a6383..e8c90dc07d 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -57,7 +57,23 @@ export interface LazyDownloadEvent { export type LazyDownloadListener = (event: LazyDownloadEvent) => void; -type LazyFetch = (url: string) => Promise; +type LazyFetch = ( + url: string, + init?: { signal?: AbortSignal }, +) => Promise; + +export interface LazyFetcherOptions { + /** + * Explicit cancellation provenance shared with the fetcher. MemoryFS passes + * this exact signal into every attempt and rethrows its reason unchanged. + */ + signal?: AbortSignal; +} + +interface LazyTransport { + fetcher: LazyFetch; + signal?: AbortSignal; +} interface LazyPreparation { status: "pending" | "fulfilled" | "rejected"; @@ -113,6 +129,8 @@ export interface LazyTreeContent { sourceEntryCount: number; /** Byte-identical transport mirrors, tried in declared order. */ 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. */ source?: LazyTreeSourceInventory; } @@ -153,6 +171,12 @@ export interface LazyTreeRegistrationEntry { inodeGroup?: string; } +/** POSIX owner applied before a lazy tree becomes observable as deferred. */ +export interface LazyTreeRegistrationOwner { + uid: number; + gid: number; +} + export interface LazyTreeActivation { mode: "boot-prefetch" | "first-use"; capabilities: string[]; @@ -301,11 +325,41 @@ const MAX_LAZY_TREE_CAPABILITIES = VFS_DEFERRED_TREE_LIMITS.maxActivationCapabilities; const MAX_LAZY_TREE_ACTIVATION_ROOTS = VFS_DEFERRED_TREE_LIMITS.maxActivationRoots; +const MAX_LAZY_TREE_OWNER_ID = 0xffff_fffe; +const MAX_LAZY_TRANSPORT_ATTEMPTS = 3; +const LAZY_TRANSPORT_RETRY_BASE_MS = 250; +const MAX_LAZY_TRANSPORT_RETRY_DELAY_MS = 5_000; const SHA256_RE = /^[0-9a-f]{64}$/; const SERIALIZED_LEGACY_ARCHIVE_KIND = "kandelo-legacy-zip-v1"; const SERIALIZED_DEFERRED_TREE_V1_KIND = "kandelo-deferred-tree-v1"; const SERIALIZED_DEFERRED_TREE_V2_KIND = "kandelo-deferred-tree-v2"; +const TRANSIENT_NETWORK_ERROR_CODES = new Set([ + "ECONNABORTED", + "ECONNREFUSED", + "ECONNRESET", + "EHOSTUNREACH", + "ENETDOWN", + "ENETRESET", + "ENETUNREACH", + "EPIPE", + "ETIMEDOUT", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_SOCKET", +]); + +class LazyHttpResponseError extends Error { + constructor( + readonly status: number, + readonly retryAfterMs: number | undefined, + ) { + super(`HTTP ${status}`); + this.name = "LazyHttpResponseError"; + } +} + interface PlannedLazyArchiveEntry { entry: ZipEntry; archivePath: string; @@ -586,6 +640,151 @@ function parseContentLength(headers: Headers | undefined): number | undefined { return Number.isFinite(value) && value >= 0 ? value : undefined; } +function isTransientHttpStatus(status: number): boolean { + return status === 408 || status === 429 || (status >= 500 && status <= 599); +} + +function parseRetryAfterMs( + headers: Headers | undefined, + now = Date.now(), +): number | undefined { + const raw = headers?.get("retry-after")?.trim(); + if (!raw) return undefined; + let delayMs: number; + if (/^\d+$/.test(raw)) { + delayMs = Number(raw) * 1_000; + } else { + const retryAt = Date.parse(raw); + if (!Number.isFinite(retryAt)) return undefined; + delayMs = Math.max(0, retryAt - now); + } + if (!Number.isSafeInteger(delayMs) || delayMs < 0) return undefined; + // WHY: Retry-After is advisory input from a remote server. Capping it keeps + // one deferred open from parking a guest process for an attacker-chosen time. + return Math.min(delayMs, MAX_LAZY_TRANSPORT_RETRY_DELAY_MS); +} + +function errorCause(error: unknown): unknown { + if (typeof error !== "object" || error === null || !("cause" in error)) { + return undefined; + } + return (error as { cause?: unknown }).cause; +} + +function errorName(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("name" in error)) { + return undefined; + } + return typeof (error as { name?: unknown }).name === "string" + ? (error as { name: string }).name + : undefined; +} + +function errorCode(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("code" in error)) { + return undefined; + } + return typeof (error as { code?: unknown }).code === "string" + ? (error as { code: string }).code + : undefined; +} + +function errorChainSome( + error: unknown, + predicate: (candidate: unknown) => boolean, +): boolean { + const seen = new Set(); + let candidate: unknown = error; + for (let depth = 0; candidate !== undefined && depth < 8; depth += 1) { + if (seen.has(candidate)) return false; + seen.add(candidate); + if (predicate(candidate)) return true; + candidate = errorCause(candidate); + } + return false; +} + +function isAbortFailure(error: unknown): boolean { + return errorChainSome(error, (candidate) => + errorName(candidate) === "AbortError" || + errorCode(candidate) === "ABORT_ERR" + ); +} + +function isTransientNetworkFailure(error: unknown): boolean { + if (isAbortFailure(error)) return false; + // Fetch intentionally exposes network failures as TypeError in browsers. + // Node's fetch adds transport codes on its bounded `cause` chain, while + // DOM-backed streams may use NetworkError or TimeoutError instead. + return errorChainSome(error, (candidate) => { + const name = errorName(candidate); + const code = errorCode(candidate); + return candidate instanceof TypeError || + name === "NetworkError" || + name === "TimeoutError" || + (code !== undefined && TRANSIENT_NETWORK_ERROR_CODES.has(code)); + }); +} + +function lazyTransportRetryDelayMs( + error: unknown, + failedAttempt: number, +): number | null { + if (error instanceof LazyHttpResponseError) { + if (!isTransientHttpStatus(error.status)) return null; + if (error.retryAfterMs !== undefined) return error.retryAfterMs; + } else if (!isTransientNetworkFailure(error)) { + return null; + } + return Math.min( + LAZY_TRANSPORT_RETRY_BASE_MS * (2 ** failedAttempt), + MAX_LAZY_TRANSPORT_RETRY_DELAY_MS, + ); +} + +function throwIfLazyTransportAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw signal.reason; +} + +function waitForLazyTransportRetry( + delayMs: number, + signal: AbortSignal | undefined, +): Promise { + throwIfLazyTransportAborted(signal); + if (delayMs === 0) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => finish(false), delayMs); + const onAbort = (): void => finish(true, signal!.reason); + let settled = false; + function finish(aborted: boolean, reason?: unknown): void { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + if (aborted) { + reject(reason); + } else { + resolve(); + } + } + signal?.addEventListener("abort", onAbort, { once: true }); + // The signal can abort between the initial check and listener install. + if (signal?.aborted) onAbort(); + }); +} + +async function cancelResponseBody( + response: Response, + reason: unknown, +): Promise { + try { + await response.body?.cancel(reason); + } catch { + // A failed transport may already have errored its stream. Cancellation is + // resource cleanup and must not replace the diagnostic that caused it. + } +} + function concatChunks(chunks: Uint8Array[], total: number): Uint8Array { if (chunks.length === 1) return chunks[0]; const out = new Uint8Array(total); @@ -712,6 +911,8 @@ function validateLazyTreeContent( const initial = value as Record | null; const hasSource = typeof initial === "object" && initial !== null && !Array.isArray(initial) && initial.source !== undefined; + const hasModePolicy = typeof initial === "object" && initial !== null && + !Array.isArray(initial) && initial.modePolicy !== undefined; const record = exactLazyTreeRecord(value, [ "decoder", "mediaType", @@ -720,6 +921,7 @@ function validateLazyTreeContent( "expandedBytes", "sourceEntryCount", "transports", + ...(hasModePolicy ? ["modePolicy"] : []), ...(hasSource ? ["source"] : []), ], "Lazy tree content"); const expectedMediaType = record.decoder === "zip-v1" @@ -765,6 +967,13 @@ function validateLazyTreeContent( const source = hasSource ? validateLazyTreeSourceInventory(record.source, record.decoder) : undefined; + const modePolicy = hasModePolicy ? record.modePolicy : undefined; + if ( + modePolicy !== undefined && + (modePolicy !== "portable-posix-v1" || record.decoder !== "zip-v1" || hasSource) + ) { + throw new Error("Lazy tree mode policy is invalid for its decoder"); + } if (source !== undefined && source.entries.length !== sourceEntryCount) { throw new Error("Lazy tree source inventory count differs from its content"); } @@ -776,6 +985,7 @@ function validateLazyTreeContent( expandedBytes, sourceEntryCount, transports, + ...(modePolicy === undefined ? {} : { modePolicy }), ...(source === undefined ? {} : { source }), }; } @@ -971,6 +1181,30 @@ interface ValidatedLazyTreeDefinition { canonicalByGroup: Map; } +function validateLazyTreeRegistrationOwner( + value: unknown, +): LazyTreeRegistrationOwner { + const record = exactLazyTreeRecord( + value, + ["uid", "gid"], + "Lazy tree registration owner", + ); + return { + uid: requireLazyTreeInteger( + record.uid, + "Lazy tree registration owner uid", + 0, + MAX_LAZY_TREE_OWNER_ID, + ), + gid: requireLazyTreeInteger( + record.gid, + "Lazy tree registration owner gid", + 0, + MAX_LAZY_TREE_OWNER_ID, + ), + }; +} + function validateLazyTreeDefinition( contentValue: unknown, entriesValue: unknown, @@ -1782,7 +2016,9 @@ export class MemoryFileSystem implements FileSystemBackend { private lazyDownloadListeners = new Set(); /** One in-flight fetch/commit per lazy file or archive group. */ private lazyPreparations = new Map(); - private lazyFetch: LazyFetch = (url) => globalThis.fetch(url); + private lazyTransport: LazyTransport = { + fetcher: (url, init) => globalThis.fetch(url, init), + }; private constructor(fs: SharedFS, metadata: VfsImageMetadata | null = null) { this.fs = fs; @@ -2204,9 +2440,20 @@ export class MemoryFileSystem implements FileSystemBackend { return () => this.lazyDownloadListeners.delete(listener); } - /** Install the host-specific transport used for lazy file and archive URLs. */ - setLazyFetcher(fetcher: LazyFetch): void { - this.lazyFetch = fetcher; + /** + * Install the host-specific transport used for lazy file and archive URLs. + * Register a signal here rather than closing over one invisibly: Fetch + * rejects with `AbortSignal.reason` unchanged, which may otherwise look like + * a retryable TypeError or an ordinary mirror failure. + */ + setLazyFetcher( + fetcher: LazyFetch, + options: LazyFetcherOptions = {}, + ): void { + this.lazyTransport = { + fetcher, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }; } private emitLazyDownload(event: Omit): void { @@ -2229,7 +2476,7 @@ export class MemoryFileSystem implements FileSystemBackend { mountPrefix?: string; fallbackTotalBytes?: number; integrity?: LazyArchiveIntegrity; - }): Promise { + }, transport: LazyTransport): Promise { let loadedBytes = 0; let totalBytes = details.integrity?.bytes ?? details.fallbackTotalBytes; const base = { @@ -2240,40 +2487,111 @@ export class MemoryFileSystem implements FileSystemBackend { mountPrefix: details.mountPrefix, }; - this.emitLazyDownload({ - ...base, - status: "started", - loadedBytes, - totalBytes, - }); + for (let attempt = 0; attempt < MAX_LAZY_TRANSPORT_ATTEMPTS; attempt += 1) { + loadedBytes = 0; + this.emitLazyDownload({ + ...base, + status: "started", + loadedBytes, + totalBytes, + }); + try { + throwIfLazyTransportAborted(transport.signal); + // WHY: preserve the historical one-argument callback shape unless the + // caller explicitly opted into signal forwarding. + const resp = transport.signal === undefined + ? await transport.fetcher(details.url) + : await transport.fetcher(details.url, { signal: transport.signal }); + if (transport.signal?.aborted) { + await cancelResponseBody(resp, transport.signal.reason); + throw transport.signal.reason; + } + if (!resp.ok) { + const error = new LazyHttpResponseError( + resp.status, + parseRetryAfterMs(resp.headers), + ); + await cancelResponseBody(resp, error); + throw error; + } - try { - const resp = await this.lazyFetch(details.url); - if (!resp.ok) { - throw new Error(`HTTP ${resp.status}`); - } + totalBytes = parseContentLength(resp.headers) ?? totalBytes; + if ( + details.integrity && + totalBytes !== undefined && + totalBytes !== details.integrity.bytes + ) { + const error = new Error( + `Lazy ${details.kind} byte count ${totalBytes} does not match ` + + `expected ${details.integrity.bytes}`, + ); + await cancelResponseBody(resp, error); + throw error; + } + if (!resp.body) { + const data = new Uint8Array(await resp.arrayBuffer()); + throwIfLazyTransportAborted(transport.signal); + loadedBytes = data.byteLength; + await assertLazyIntegrity(data, details.kind, details.integrity); + throwIfLazyTransportAborted(transport.signal); + this.emitLazyDownload({ + ...base, + status: "progress", + loadedBytes, + totalBytes: totalBytes ?? loadedBytes, + }); + this.emitLazyDownload({ + ...base, + status: "complete", + loadedBytes, + totalBytes: totalBytes ?? loadedBytes, + }); + return data; + } - totalBytes = parseContentLength(resp.headers) ?? totalBytes; - if ( - details.integrity && - totalBytes !== undefined && - totalBytes !== details.integrity.bytes - ) { - throw new Error( - `Lazy ${details.kind} byte count ${totalBytes} does not match ` + - `expected ${details.integrity.bytes}`, - ); - } - if (!resp.body) { - const data = new Uint8Array(await resp.arrayBuffer()); - loadedBytes = data.byteLength; + const reader = resp.body.getReader(); + const chunks: Uint8Array[] = []; + try { + try { + while (true) { + const { done, value } = await reader.read(); + throwIfLazyTransportAborted(transport.signal); + if (done) break; + if (!value) continue; + chunks.push(value); + loadedBytes += value.byteLength; + if (details.integrity && loadedBytes > details.integrity.bytes) { + // WHY: throw the authoritative bound violation first. The + // enclosing catch cancels best-effort; a rejecting stream + // cleanup must never turn integrity failure into a retry. + throw new Error( + `Lazy ${details.kind} exceeded expected byte count ` + + `${details.integrity.bytes}`, + ); + } + this.emitLazyDownload({ + ...base, + status: "progress", + loadedBytes, + totalBytes, + }); + } + } catch (error) { + try { + await reader.cancel(error); + } catch { + // Preserve the read failure; the stream may already be errored. + } + throw error; + } + } finally { + reader.releaseLock(); + } + + const data = concatChunks(chunks, loadedBytes); + throwIfLazyTransportAborted(transport.signal); await assertLazyIntegrity(data, details.kind, details.integrity); - this.emitLazyDownload({ - ...base, - status: "progress", - loadedBytes, - totalBytes: totalBytes ?? loadedBytes, - }); + throwIfLazyTransportAborted(transport.signal); this.emitLazyDownload({ ...base, status: "complete", @@ -2281,55 +2599,58 @@ export class MemoryFileSystem implements FileSystemBackend { totalBytes: totalBytes ?? loadedBytes, }); return data; - } - - const reader = resp.body.getReader(); - const chunks: Uint8Array[] = []; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - chunks.push(value); - loadedBytes += value.byteLength; - if (details.integrity && loadedBytes > details.integrity.bytes) { - await reader.cancel(); - throw new Error( - `Lazy ${details.kind} exceeded expected byte count ` + - `${details.integrity.bytes}`, - ); - } + } catch (err) { + if (transport.signal?.aborted) { + const reason = transport.signal.reason; + const message = reason instanceof Error ? reason.message : String(reason); this.emitLazyDownload({ ...base, - status: "progress", + status: "error", loadedBytes, totalBytes, + error: message, }); + throw reason; } - } finally { - reader.releaseLock(); + const retryDelay = attempt + 1 < MAX_LAZY_TRANSPORT_ATTEMPTS + ? lazyTransportRetryDelayMs(err, attempt) + : null; + if (retryDelay !== null) { + // WHY: a failed attempt never supplies bytes to the decoder or VFS. + // Retrying only closed transport failures preserves truthful + // integrity/decode errors while surviving an ephemeral CDN edge. + try { + await waitForLazyTransportRetry(retryDelay, transport.signal); + } catch (waitError) { + const reason = transport.signal?.aborted + ? transport.signal.reason + : waitError; + const message = reason instanceof Error + ? reason.message + : String(reason); + this.emitLazyDownload({ + ...base, + status: "error", + loadedBytes, + totalBytes, + error: message, + }); + throw reason; + } + continue; + } + const message = err instanceof Error ? err.message : String(err); + this.emitLazyDownload({ + ...base, + status: "error", + loadedBytes, + totalBytes, + error: message, + }); + throw err; } - - const data = concatChunks(chunks, loadedBytes); - await assertLazyIntegrity(data, details.kind, details.integrity); - this.emitLazyDownload({ - ...base, - status: "complete", - loadedBytes, - totalBytes: totalBytes ?? loadedBytes, - }); - return data; - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - this.emitLazyDownload({ - ...base, - status: "error", - loadedBytes, - totalBytes, - error: message, - }); - throw err; } + throw new Error("Lazy transport retry state became unreachable"); } /** @@ -2508,6 +2829,7 @@ export class MemoryFileSystem implements FileSystemBackend { entriesValue: readonly LazyTreeRegistrationEntry[], mountPrefix = "/", activationValue?: LazyTreeActivation, + ownerValue?: LazyTreeRegistrationOwner, ): LazyTreeGroup { return this.registerLazyTreeInternal( contentValue, @@ -2515,6 +2837,7 @@ export class MemoryFileSystem implements FileSystemBackend { mountPrefix, activationValue, false, + ownerValue, ); } @@ -2524,6 +2847,7 @@ export class MemoryFileSystem implements FileSystemBackend { mountPrefix: string, activationValue: LazyTreeActivation | undefined, allowTransportlessDirectMaterialization: boolean, + ownerValue: LazyTreeRegistrationOwner | undefined, ): LazyTreeGroup { this.assertCanRegisterPendingLazyArchiveGroup(); const canonicalMountPrefix = normalizeLazyArchiveMountPrefix(mountPrefix); @@ -2544,6 +2868,9 @@ export class MemoryFileSystem implements FileSystemBackend { }, allowTransportlessDirectMaterialization ? 0 : 1, ); + const owner = ownerValue === undefined + ? undefined + : validateLazyTreeRegistrationOwner(ownerValue); const group: LazyTreeGroup = { content, @@ -2628,10 +2955,6 @@ export class MemoryFileSystem implements FileSystemBackend { inodeGroup: entry.inodeGroup, }; group.entries.set(entry.vfsPath, metadata); - this.lazyArchiveInodes.set( - MemoryFileSystem.inodeKey(st.ino, st.generation), - group, - ); } for (const entry of entries) { @@ -2660,6 +2983,22 @@ export class MemoryFileSystem implements FileSystemBackend { }); } + if (owner !== undefined) { + // WHY: ownership is part of the package namespace contract. Apply it + // before publishing any lazy-inode metadata or returning a direct + // materialization handle, so callers cannot observe a registered tree + // whose stubs still carry SharedFS's default owner. + for (const entry of entries) { + this.lchown(entry.vfsPath, owner.uid, owner.gid); + } + } + for (const entry of group.entries.values()) { + if (entry.isSymlink || entry.generation === undefined) continue; + this.lazyArchiveInodes.set( + MemoryFileSystem.inodeKey(entry.ino, entry.generation), + group, + ); + } this.lazyArchiveGroups.push(group); return group; } @@ -2673,6 +3012,7 @@ export class MemoryFileSystem implements FileSystemBackend { entriesValue: readonly LazyTreeRegistrationEntry[], mountPrefix = "/", activationValue?: LazyTreeActivation, + ownerValue?: LazyTreeRegistrationOwner, ): DeferredTreeMaterializationHandle { const group = this.registerLazyTreeInternal( contentValue, @@ -2680,6 +3020,7 @@ export class MemoryFileSystem implements FileSystemBackend { mountPrefix, activationValue, true, + ownerValue, ); const handle = Object.freeze({ [DEFERRED_TREE_MATERIALIZATION_HANDLE]: true as const, @@ -3296,16 +3637,18 @@ export class MemoryFileSystem implements FileSystemBackend { const key = MemoryFileSystem.inodeKey(st.ino, st.generation); const entry = this.lazyFiles.get(key); if (entry) { + const transport = this.lazyTransport; const data = await this.fetchLazyBytes({ id: `file:${st.ino}`, kind: "file", url: entry.url, path: entry.path, fallbackTotalBytes: entry.size, - }); + }, transport); for (let attempt = 0; attempt < 3; attempt++) { if (this.lazyFiles.get(key) !== entry) return false; for (const candidate of new Set([path, ...entry.paths])) { + throwIfLazyTransportAborted(transport.signal); const materialized = this.fs.replaceIfIdentity( candidate, entry.ino, @@ -3418,14 +3761,23 @@ export class MemoryFileSystem implements FileSystemBackend { : entry.isSymlink ? "symlink" : "file"; + const actualMode = content.modePolicy === "portable-posix-v1" + ? actualType === "directory" + ? 0o755 + : actualType === "symlink" + ? 0o777 + : (entry.mode & 0o111) !== 0 + ? 0o755 + : 0o644 + : entry.mode & 0o7777; if ( actualType !== expected.type || - (entry.mode & 0o7777) !== expected.mode + actualMode !== expected.mode ) { throw new Error(`Lazy ZIP tree member ${sourcePath} differs from inventory`); } if (entry.isDirectory) { - decoded.set(sourcePath, { type: "directory", mode: entry.mode }); + decoded.set(sourcePath, { type: "directory", mode: actualMode }); } else { const member = extractZipEntryBounded(data, entry, expected.size); if (entry.isSymlink) { @@ -3437,13 +3789,13 @@ export class MemoryFileSystem implements FileSystemBackend { } decoded.set(sourcePath, { type: "symlink", - mode: entry.mode, + mode: actualMode, target, }); } else { decoded.set(sourcePath, { type: "file", - mode: entry.mode, + mode: actualMode, data: member, }); } @@ -3624,6 +3976,7 @@ export class MemoryFileSystem implements FileSystemBackend { ): Promise { if (group.materialized) return; const genericTree = group.content !== undefined && group.inventory !== undefined; + const transport = this.lazyTransport; const transports = genericTree ? group.content!.transports : [group.url]; const failures: string[] = []; @@ -3636,12 +3989,18 @@ export class MemoryFileSystem implements FileSystemBackend { url, mountPrefix: group.mountPrefix, integrity: group.integrity, - }); + }, transport); break; } catch (error) { + // WHY: explicit cancellation belongs to the caller/worker lifecycle, + // not to one mirror. Check its exact reason before compatibility + // fallbacks inspect the error's shape. + throwIfLazyTransportAborted(transport.signal); + if (isAbortFailure(error)) throw error; failures.push(error instanceof Error ? error.message : String(error)); } } + throwIfLazyTransportAborted(transport.signal); if (archiveData === null) { throw new Error( `All ${transports.length} lazy ${genericTree ? "tree" : "archive"} ` + @@ -3649,20 +4008,30 @@ export class MemoryFileSystem implements FileSystemBackend { ); } - await this.materializeArchiveBytes(group, archiveData, requested); + throwIfLazyTransportAborted(transport.signal); + await this.materializeArchiveBytes( + group, + archiveData, + requested, + transport.signal, + ); } private async materializeArchiveBytes( group: LazyArchiveGroup, archiveData: Uint8Array, requested?: { path: string; ino: number; generation: number }, + signal?: AbortSignal, ): Promise { + throwIfLazyTransportAborted(signal); if (group.materialized) return; const genericTree = group.content !== undefined && group.inventory !== undefined; const decodedTreeFiles = genericTree ? await this.decodeAndValidateLazyTree(group, archiveData) : null; + throwIfLazyTransportAborted(signal); const { parseZipCentralDirectory, extractZipEntry } = await import("./zip"); + throwIfLazyTransportAborted(signal); const zipEntries = decodedTreeFiles ? [] : parseZipCentralDirectory(archiveData); const zipLookup = new Map(); for (const ze of zipEntries) { @@ -3771,6 +4140,7 @@ export class MemoryFileSystem implements FileSystemBackend { } if (pending.size > 0) { + throwIfLazyTransportAborted(signal); const committed = this.fs.replaceManyIfIdentities( Array.from(pending.values(), (replacement) => ({ paths: Array.from(replacement.paths), @@ -3787,6 +4157,9 @@ export class MemoryFileSystem implements FileSystemBackend { } } + // Metadata-only groups have no regular replacement above, so retain the + // same last cancellation boundary before publishing materialized state. + throwIfLazyTransportAborted(signal); for (const [key, replacement] of pending) { this.lazyArchiveInodes.delete(key); for (const alias of group.entries.values()) { diff --git a/host/src/vfs/package-deferred-tree.ts b/host/src/vfs/package-deferred-tree.ts new file mode 100644 index 0000000000..45a546d124 --- /dev/null +++ b/host/src/vfs/package-deferred-tree.ts @@ -0,0 +1,651 @@ +import { createHash } from "node:crypto"; + +import { + MemoryFileSystem, + type DeferredTreeMaterializationHandle, + type LazyTreeActivation, + type LazyTreeContent, + type LazyTreeRegistrationEntry, +} from "./memory-fs"; +import { + extractZipEntryBounded, + parseZipCentralDirectory, + type ZipEntry, +} from "./zip"; +import { VFS_DEFERRED_TREE_LIMITS } from "./deferred-tree-limits"; +import { ENOENT, SFSError } from "./sharedfs-vendor"; + +const S_IFMT = 0xf000; +const S_IFREG = 0x8000; +const S_IFDIR = 0x4000; +const S_IFLNK = 0xa000; +// uid_t/gid_t -1 is the POSIX chown "leave unchanged" sentinel and therefore +// cannot truthfully identify the owner declared by a package descriptor. +const MAX_OWNER_ID = 0xffff_fffe; +const PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9+._-]*$/; +const OUTPUT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9+._-]*$/; +const TREE_ID_RE = /^[a-z0-9][a-z0-9+._/-]*$/; +const textEncoder = new TextEncoder(); + +export interface PackageDeferredZipTreeSpec { + schema: 1; + kind: "kandelo-package-deferred-zip-tree"; + id: string; + /** Distribution meaning; source trees are not package/bottle payloads. */ + content_role: "source-tree" | "runtime-tree"; + package: { + name: string; + output: string; + }; + archive: { + url: string; + mode_policy: "portable-posix-v1"; + }; + mount_prefix: string; + owner: { + uid: number; + gid: number; + }; + activation: LazyTreeActivation; +} + +export interface PackageDeferredZipTreeDescriptor { + schema: 1; + kind: "kandelo-package-deferred-zip-tree"; + id: string; + content_role: PackageDeferredZipTreeSpec["content_role"]; + package: PackageDeferredZipTreeSpec["package"]; + archive: PackageDeferredZipTreeSpec["archive"] & { + decoder: "zip-v1"; + media_type: "application/zip"; + sha256: string; + bytes: number; + expanded_bytes: number; + source_entry_count: number; + }; + mount_prefix: string; + owner: PackageDeferredZipTreeSpec["owner"]; + activation: LazyTreeActivation; + inventory: Array<{ + vfs_path: string; + source_path: string; + type: "directory" | "file" | "symlink"; + mode: number; + size: number; + target?: string; + inode_group?: string; + }>; +} + +export interface DerivedPackageDeferredZipTree { + descriptor: PackageDeferredZipTreeDescriptor; + descriptorBytes: Uint8Array; + descriptorSha256: string; + content: LazyTreeContent; + entries: LazyTreeRegistrationEntry[]; +} + +export interface RegisteredPackageDeferredZipTree extends + DerivedPackageDeferredZipTree { + materialization: DeferredTreeMaterializationHandle; +} + +/** Parse the reviewable recipe without trusting unknown fields or host paths. */ +export function parsePackageDeferredZipTreeSpec( + value: unknown, +): PackageDeferredZipTreeSpec { + const record = exactRecord(value, [ + "schema", + "kind", + "id", + "content_role", + "package", + "archive", + "mount_prefix", + "owner", + "activation", + ], "package deferred ZIP tree spec"); + const packageRecord = exactRecord( + record.package, + ["name", "output"], + "package deferred ZIP tree package", + ); + const archive = exactRecord( + record.archive, + ["url", "mode_policy"], + "package deferred ZIP tree archive", + ); + const owner = exactRecord( + record.owner, + ["uid", "gid"], + "package deferred ZIP tree owner", + ); + const activation = exactRecord( + record.activation, + ["mode", "capabilities", "roots"], + "package deferred ZIP tree activation", + ); + if ( + record.schema !== 1 || + record.kind !== "kandelo-package-deferred-zip-tree" || + typeof record.id !== "string" || !TREE_ID_RE.test(record.id) || + utf8Length(record.id) > 255 || + record.id.includes("//") || record.id.endsWith("/") || + (record.content_role !== "source-tree" && record.content_role !== "runtime-tree") || + typeof packageRecord.name !== "string" || + !PACKAGE_NAME_RE.test(packageRecord.name) || utf8Length(packageRecord.name) > 255 || + typeof packageRecord.output !== "string" || + !OUTPUT_NAME_RE.test(packageRecord.output) || utf8Length(packageRecord.output) > 255 || + typeof archive.url !== "string" || !isRelativeAssetUrl(archive.url) || + archive.url !== packageRecord.output || + archive.mode_policy !== "portable-posix-v1" || + typeof record.mount_prefix !== "string" || + utf8Length(record.mount_prefix) > VFS_DEFERRED_TREE_LIMITS.maxPathBytes || + canonicalAbsolutePath(record.mount_prefix, true) !== record.mount_prefix || + !isOwnerId(owner.uid) || !isOwnerId(owner.gid) || + (activation.mode !== "first-use" && activation.mode !== "boot-prefetch") || + !Array.isArray(activation.capabilities) || activation.capabilities.length === 0 || + activation.capabilities.length > + VFS_DEFERRED_TREE_LIMITS.maxActivationCapabilities || + !activation.capabilities.every((capability) => + typeof capability === "string" && + utf8Length(capability) <= VFS_DEFERRED_TREE_LIMITS.maxActivationCapabilityBytes && + /^[a-z0-9][a-z0-9:._-]*$/.test(capability) + ) || + new Set(activation.capabilities).size !== activation.capabilities.length || + !Array.isArray(activation.roots) || activation.roots.length === 0 || + activation.roots.length > VFS_DEFERRED_TREE_LIMITS.maxActivationRoots || + !activation.roots.every((root) => + typeof root === "string" && + utf8Length(root) <= VFS_DEFERRED_TREE_LIMITS.maxPathBytes && + canonicalAbsolutePath(root, true) === root + ) || + new Set(activation.roots).size !== activation.roots.length + ) { + throw new Error("package deferred ZIP tree spec is invalid"); + } + for (const root of activation.roots as string[]) { + if ( + record.mount_prefix !== "/" && + root !== record.mount_prefix && + !root.startsWith(`${record.mount_prefix}/`) + ) { + throw new Error(`package deferred ZIP tree activation root escapes its mount: ${root}`); + } + } + return { + schema: 1, + kind: "kandelo-package-deferred-zip-tree", + id: record.id, + content_role: record.content_role, + package: { + name: packageRecord.name, + output: packageRecord.output, + }, + archive: { + url: archive.url, + mode_policy: "portable-posix-v1", + }, + mount_prefix: record.mount_prefix, + owner: { uid: owner.uid, gid: owner.gid }, + activation: { + mode: activation.mode, + capabilities: [...activation.capabilities] as string[], + roots: [...activation.roots] as string[], + }, + }; +} + +/** + * Derive the complete typed-tree contract from one exact package output. + * The returned descriptor is the only recipe used by lazy registration and + * build-time eager materialization. + */ +export function derivePackageDeferredZipTree( + specValue: unknown, + archiveBytes: Uint8Array, +): DerivedPackageDeferredZipTree { + const spec = parsePackageDeferredZipTreeSpec(specValue); + if (!(archiveBytes instanceof Uint8Array) || archiveBytes.byteLength === 0) { + throw new Error("package deferred ZIP tree archive is empty"); + } + if (archiveBytes.byteLength > VFS_DEFERRED_TREE_LIMITS.maxArchiveBytes) { + throw new Error("package deferred ZIP tree archive exceeds the byte limit"); + } + const zipEntries = parseZipCentralDirectory(archiveBytes); + if (zipEntries.length === 0) { + throw new Error("package deferred ZIP tree archive has no entries"); + } + if (zipEntries.length > VFS_DEFERRED_TREE_LIMITS.maxEntries) { + throw new Error("package deferred ZIP tree archive has too many entries"); + } + const expandedBytes = zipEntries.reduce((total, entry) => { + const next = total + entry.uncompressedSize; + if ( + !Number.isSafeInteger(next) || + next > VFS_DEFERRED_TREE_LIMITS.maxExpandedBytes + ) { + throw new Error("package deferred ZIP tree expanded size exceeds the limit"); + } + return next; + }, 0); + const entries = zipEntries.map((entry, index) => + deriveEntry(spec, archiveBytes, entry, index) + ); + assertCompleteDirectoryInventory(spec.mount_prefix, entries); + const sha256 = createHash("sha256").update(archiveBytes).digest("hex"); + const content: LazyTreeContent = { + decoder: "zip-v1", + mediaType: "application/zip", + sha256, + bytes: archiveBytes.byteLength, + expandedBytes, + sourceEntryCount: zipEntries.length, + transports: [spec.archive.url], + modePolicy: spec.archive.mode_policy, + }; + const descriptor: PackageDeferredZipTreeDescriptor = { + schema: 1, + kind: "kandelo-package-deferred-zip-tree", + id: spec.id, + content_role: spec.content_role, + package: { ...spec.package }, + archive: { + ...spec.archive, + decoder: "zip-v1", + media_type: "application/zip", + sha256, + bytes: archiveBytes.byteLength, + expanded_bytes: expandedBytes, + source_entry_count: zipEntries.length, + }, + mount_prefix: spec.mount_prefix, + owner: { ...spec.owner }, + activation: { + mode: spec.activation.mode, + capabilities: [...spec.activation.capabilities], + roots: [...spec.activation.roots], + }, + inventory: entries.map((entry) => ({ + vfs_path: entry.vfsPath, + source_path: entry.sourcePath, + type: entry.type as "directory" | "file" | "symlink", + mode: entry.mode, + size: entry.size, + ...(entry.target === undefined ? {} : { target: entry.target }), + ...(entry.inodeGroup === undefined ? {} : { inode_group: entry.inodeGroup }), + })), + }; + const descriptorBytes = canonicalJsonBytes(descriptor); + return { + descriptor, + descriptorBytes, + descriptorSha256: createHash("sha256").update(descriptorBytes).digest("hex"), + content, + entries, + }; +} + +/** Register one derived package tree and preserve its declared POSIX owner. */ +export function registerPackageDeferredZipTree( + fs: MemoryFileSystem, + derived: DerivedPackageDeferredZipTree, +): RegisteredPackageDeferredZipTree { + preflightNamespace(fs, derived.descriptor, derived.entries); + const materialization = fs.registerLazyTreeWithMaterializationHandle( + derived.content, + derived.entries, + derived.descriptor.mount_prefix, + derived.descriptor.activation, + derived.descriptor.owner, + ); + return { ...derived, materialization }; +} + +/** Materialize the same registered descriptor from its exact package bytes. */ +export async function materializePackageDeferredZipTree( + fs: MemoryFileSystem, + registered: RegisteredPackageDeferredZipTree, + archiveBytes: Uint8Array, +): Promise { + if ( + archiveBytes.byteLength !== registered.content.bytes || + createHash("sha256").update(archiveBytes).digest("hex") !== + registered.content.sha256 + ) { + throw new Error("package deferred ZIP tree materialization bytes changed identity"); + } + if (!await fs.materializeRegisteredDeferredTree( + registered.materialization, + archiveBytes, + )) { + throw new Error("package deferred ZIP tree was already materialized"); + } +} + +/** Prove the same descriptor survived either lazy serialization or eager pour. */ +export function assertPackageDeferredZipTreeState( + fs: MemoryFileSystem, + derived: DerivedPackageDeferredZipTree, + expected: "deferred" | "materialized", +): void { + const matching = fs.exportLazyArchiveEntries().filter((tree) => + tree.content?.sha256 === derived.content.sha256 && + tree.content.bytes === derived.content.bytes + ); + if (expected === "deferred") { + if (matching.length !== 1) { + throw new Error( + `package deferred ZIP tree ${derived.descriptor.id} is not pending exactly once`, + ); + } + const tree = matching[0]!; + if ( + tree.mountPrefix !== derived.descriptor.mount_prefix || + JSON.stringify(tree.content) !== JSON.stringify(derived.content) || + JSON.stringify(tree.inventory) !== JSON.stringify(derived.entries) || + JSON.stringify(tree.activation) !== JSON.stringify(derived.descriptor.activation) + ) { + throw new Error( + `package deferred ZIP tree ${derived.descriptor.id} changed descriptor`, + ); + } + } else if (matching.length !== 0) { + throw new Error( + `materialized package ZIP tree ${derived.descriptor.id} remains pending`, + ); + } + + for (const entry of derived.entries) { + const stat = fs.lstat(entry.vfsPath); + const expectedType = entry.type === "directory" + ? S_IFDIR + : entry.type === "symlink" + ? S_IFLNK + : S_IFREG; + if ( + (stat.mode & S_IFMT) !== expectedType || + (stat.mode & 0o7777) !== entry.mode || + stat.uid !== derived.descriptor.owner.uid || + stat.gid !== derived.descriptor.owner.gid || + (entry.type !== "directory" && stat.size !== entry.size) || + (entry.type === "file" && + fs.isPathDeferred(entry.vfsPath) !== (expected === "deferred")) || + (entry.type === "symlink" && fs.readlink(entry.vfsPath) !== entry.target) + ) { + throw new Error( + `package deferred ZIP tree ${derived.descriptor.id} changed ${entry.vfsPath}`, + ); + } + } +} + +function deriveEntry( + spec: PackageDeferredZipTreeSpec, + archiveBytes: Uint8Array, + entry: ZipEntry, + index: number, +): LazyTreeRegistrationEntry { + if (entry.isDirectory !== entry.fileName.endsWith("/")) { + throw new Error(`package deferred ZIP entry ${index} has inconsistent directory metadata`); + } + const sourcePath = canonicalRelativePath( + entry.isDirectory ? entry.fileName.slice(0, -1) : entry.fileName, + ); + const vfsPath = spec.mount_prefix === "/" + ? `/${sourcePath}` + : `${spec.mount_prefix}/${sourcePath}`; + const fileType = entry.creatorOS === 3 ? entry.mode & S_IFMT : 0; + if (entry.compressionMethod !== 0 && entry.compressionMethod !== 8) { + throw new Error( + `package deferred ZIP entry ${sourcePath} uses unsupported compression`, + ); + } + if ( + fileType !== 0 && fileType !== S_IFREG && fileType !== S_IFDIR && + fileType !== S_IFLNK + ) { + throw new Error(`package deferred ZIP entry ${sourcePath} has unsupported file type`); + } + if (entry.isDirectory) { + if (fileType !== 0 && fileType !== S_IFDIR || entry.uncompressedSize !== 0) { + throw new Error(`package deferred ZIP directory ${sourcePath} is invalid`); + } + extractZipEntryBounded(archiveBytes, entry, 0); + return { + vfsPath, + sourcePath, + type: "directory", + mode: 0o755, + size: 0, + }; + } + if (entry.isSymlink) { + if (fileType !== S_IFLNK) { + throw new Error(`package deferred ZIP symlink ${sourcePath} is invalid`); + } + if ( + entry.uncompressedSize > VFS_DEFERRED_TREE_LIMITS.maxSymlinkTargetBytes + ) { + throw new Error( + `package deferred ZIP symlink ${sourcePath} target is too large`, + ); + } + const targetBytes = extractZipEntryBounded( + archiveBytes, + entry, + entry.uncompressedSize, + ); + if (targetBytes.byteLength === 0 || targetBytes.includes(0)) { + throw new Error(`package deferred ZIP symlink ${sourcePath} has an invalid target`); + } + let target: string; + try { + target = new TextDecoder("utf-8", { fatal: true }).decode(targetBytes); + } catch (error) { + throw new Error( + `package deferred ZIP symlink ${sourcePath} target is not UTF-8`, + { cause: error }, + ); + } + if (!bytesEqual(targetBytes, new TextEncoder().encode(target))) { + throw new Error( + `package deferred ZIP symlink ${sourcePath} target is not byte-preserving`, + ); + } + // WHY: a symlink target is guest namespace text, not an archive extraction + // path. Registration creates the link itself without following it, and + // packages legitimately use absolute or parent-relative links to reach + // files supplied by the base image or a dependency. + return { + vfsPath, + sourcePath, + type: "symlink", + mode: 0o777, + size: targetBytes.byteLength, + target, + }; + } + if (fileType !== 0 && fileType !== S_IFREG) { + throw new Error(`package deferred ZIP file ${sourcePath} is invalid`); + } + extractZipEntryBounded(archiveBytes, entry, entry.uncompressedSize); + // WHY: producer umasks and host-specific permission bits are not part of the + // package contract. Preserve executability while giving eager and lazy + // materialization the same portable mode. + const executable = (entry.mode & 0o111) !== 0; + return { + vfsPath, + sourcePath, + type: "file", + mode: executable ? 0o755 : 0o644, + size: entry.uncompressedSize, + inodeGroup: `zip:${sourcePath}`, + }; +} + +function assertCompleteDirectoryInventory( + mountPrefix: string, + entries: readonly LazyTreeRegistrationEntry[], +): void { + const paths = new Set(entries.map((entry) => entry.vfsPath)); + const types = new Map(entries.map((entry) => [entry.vfsPath, entry.type])); + if (paths.size !== entries.length) { + throw new Error("package deferred ZIP tree contains duplicate paths"); + } + for (const entry of entries) { + let parent = entry.vfsPath.slice(0, entry.vfsPath.lastIndexOf("/")) || "/"; + while (parent !== "/" && parent !== mountPrefix) { + if (!paths.has(parent) || types.get(parent) !== "directory") { + throw new Error( + `package deferred ZIP tree omits directory entry ${parent}`, + ); + } + parent = parent.slice(0, parent.lastIndexOf("/")) || "/"; + } + } +} + +function preflightNamespace( + fs: MemoryFileSystem, + descriptor: PackageDeferredZipTreeDescriptor, + entries: readonly LazyTreeRegistrationEntry[], +): void { + const entryByPath = new Map(entries.map((entry) => [entry.vfsPath, entry])); + const requiredPaths = new Set(); + for (const entry of entries) { + let path = entry.vfsPath; + while (path !== "/") { + requiredPaths.add(path); + path = path.slice(0, path.lastIndexOf("/")) || "/"; + } + } + const orderedPaths = [...requiredPaths].sort((left, right) => + left.split("/").length - right.split("/").length || + compareUnicodeScalars(left, right) + ); + // WHY: reject the whole layer before registration mutates the namespace. + // Existing directories are shareable only when their package-visible + // ownership and mode agree, so a layer cannot silently rewrite the base. + for (const path of orderedPaths) { + let existing; + try { + existing = fs.lstat(path); + } catch (error) { + if (error instanceof SFSError && error.code === ENOENT) continue; + throw error; + } + const entry = entryByPath.get(path); + if (entry === undefined) { + if ((existing.mode & S_IFMT) !== S_IFDIR) { + throw new Error( + `package deferred ZIP tree ancestor collides at ${path}`, + ); + } + continue; + } + if ( + entry.type !== "directory" || + (existing.mode & S_IFMT) !== S_IFDIR || + (existing.mode & 0o7777) !== entry.mode || + existing.uid !== descriptor.owner.uid || + existing.gid !== descriptor.owner.gid + ) { + throw new Error( + `package deferred ZIP tree collides with the base at ${path}`, + ); + } + } +} + +function exactRecord( + value: unknown, + fields: 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; + if ( + JSON.stringify(Object.keys(record).sort()) !== + JSON.stringify([...fields].sort()) + ) { + throw new Error(`${label} has unsupported fields`); + } + return record; +} + +function canonicalRelativePath(value: string): string { + if ( + value.length === 0 || value.startsWith("/") || value.includes("\\") || + value.includes("\0") || + utf8Length(value) > VFS_DEFERRED_TREE_LIMITS.maxPathBytes || + value.split("/").some((segment) => segment === "" || segment === "." || segment === "..") + ) { + throw new Error(`package deferred ZIP member is not a canonical relative path: ${value}`); + } + return value; +} + +function canonicalAbsolutePath(value: string, allowRoot: boolean): string | null { + if (allowRoot && value === "/") return value; + if ( + !value.startsWith("/") || value.endsWith("/") || value.includes("\\") || + value.includes("\0") || + value.slice(1).split("/").some((segment) => + segment === "" || segment === "." || segment === ".." + ) + ) return null; + return value; +} + +function isRelativeAssetUrl(value: string): boolean { + return ( + value.length > 0 && value.length <= 255 && !value.startsWith("/") && + !value.includes("\\") && !value.includes("\0") && + !/^[a-z][a-z0-9+.-]*:/i.test(value) && + value.split("/").every((segment) => + segment !== "" && segment !== "." && segment !== ".." + ) + ); +} + +function isOwnerId(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 && + (value as number) <= MAX_OWNER_ID; +} + +function canonicalJsonBytes(value: unknown): Uint8Array { + return new TextEncoder().encode(`${JSON.stringify(sortJson(value))}\n`); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (value === null || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => compareUnicodeScalars(left, right)) + .map(([key, item]) => [key, sortJson(item)]), + ); +} + +function compareUnicodeScalars(left: string, right: string): number { + const leftScalars = Array.from(left, (value) => value.codePointAt(0)!); + const rightScalars = Array.from(right, (value) => value.codePointAt(0)!); + for (let index = 0; index < Math.min(leftScalars.length, rightScalars.length); index++) { + if (leftScalars[index] !== rightScalars[index]) { + return leftScalars[index]! < rightScalars[index]! ? -1 : 1; + } + } + return leftScalars.length - rightScalars.length; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + return left.byteLength === right.byteLength && + left.every((byte, index) => byte === right[index]); +} + +function utf8Length(value: string): number { + return textEncoder.encode(value).byteLength; +} diff --git a/host/src/vm-interrupt-timer.ts b/host/src/vm-interrupt-timer.ts index 9886097156..d9df6d09e2 100644 --- a/host/src/vm-interrupt-timer.ts +++ b/host/src/vm-interrupt-timer.ts @@ -5,10 +5,10 @@ * CPU-bound Wasm loop. The kernel worker owns this timer instead and writes * the runtime's interrupt flags through the process's shared memory. * - * The generation object is deliberately part of every entry. A numeric PID - * can be reused by exec or a later process, so neither a queued timer callback - * nor a stale worker message may act on whatever generation happens to own the - * PID later. + * The generation object is deliberately part of every entry. Task IDs are + * never reassigned, but exec preserves its PID while replacing the host-side + * execution generation. Neither a queued timer callback nor a stale worker + * message may act on that replacement image. */ export const MAX_VM_INTERRUPT_TIMER_DELAY_MS = 0x7fffffff; @@ -138,7 +138,7 @@ export class VmInterruptTimerManager< return true; } - /** Cancel only when the caller still owns the current PID generation. */ + /** Cancel only when the caller still owns the current execution generation. */ cancel(pid: number, generation: Generation): boolean { if (this.currentGeneration(pid) !== generation) return false; this.clear(pid, generation); diff --git a/host/src/wasm-guest-pointer.ts b/host/src/wasm-guest-pointer.ts new file mode 100644 index 0000000000..3993e75754 --- /dev/null +++ b/host/src/wasm-guest-pointer.ts @@ -0,0 +1,50 @@ +export type WasmGuestPointer = number | bigint; + +const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); +const MIN_SIGNED_WASM32 = -0x8000_0000; +const MAX_UNSIGNED_WASM32 = 0xffff_ffff; +const MIN_SIGNED_WASM64 = -(1n << 63n); +const MAX_UNSIGNED_WASM64 = (1n << 64n) - 1n; + +/** + * Convert the JavaScript representation of a guest pointer-sized value into + * the exact non-negative offset used by host memory APIs. + * + * WHY: WebAssembly exposes i32 imports as signed JavaScript numbers, so an + * ordinary memory32 pointer with its high bit set arrives as a negative value. + * Reinterpreting its bits as unsigned restores the address the guest supplied. + * Memory64 i64 values must remain BigInt; accepting Number there would silently + * permit precision loss before this boundary can validate it. + */ +export function checkedWasmGuestPointerOffset( + value: WasmGuestPointer, + ptrWidth: 4 | 8, + context: string, +): number { + let unsigned: bigint; + if (ptrWidth === 4) { + if ( + typeof value !== "number" + || !Number.isSafeInteger(value) + || value < MIN_SIGNED_WASM32 + || value > MAX_UNSIGNED_WASM32 + ) { + throw new TypeError(`${context}: expected an exact memory32 pointer`); + } + unsigned = BigInt(value >>> 0); + } else { + if ( + typeof value !== "bigint" + || value < MIN_SIGNED_WASM64 + || value > MAX_UNSIGNED_WASM64 + ) { + throw new TypeError(`${context}: expected an exact memory64 pointer`); + } + unsigned = BigInt.asUintN(64, value); + } + + if (unsigned > MAX_SAFE_BIGINT) { + throw new RangeError(`${context}: pointer exceeds JavaScript's exact address range`); + } + return Number(unsigned); +} diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index ba4c33e701..56b9a8e3aa 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -36,11 +36,24 @@ import { CH_SYSCALL, CH_TOTAL_SIZE, HOST_INTERCEPTED_SYSCALLS, + WPK_FORK_REQUIRED_EXPORTS, + WPK_FORK_REQUIRED_IMPORTS, } from "./generated/abi"; import { FORK_SAVE_BUFFER_SIZE, FORK_SAVE_CONTROL_PREFIX_SIZE, } from "./process-memory"; +import { + ContinuationAllocationError, + invokeForkContinuationBegin, + LinkedForkContinuation, + readLinkedFrameFormat, + writeForkContinuationAnchor, +} from "./fork-continuation"; +import { + checkedWasmGuestPointerOffset, + type WasmGuestPointer, +} from "./wasm-guest-pointer"; // WASI detection helpers are tiny and live in their own file so we can // import them eagerly without dragging in the 1300-line WasiShim class. // The shim itself is dynamically imported below, only when a worker @@ -61,6 +74,71 @@ const SYS_MMAP_NR = ABI_SYSCALLS.Mmap; const PROT_READ_WRITE = 3; const MAP_PRIVATE_ANONYMOUS = 0x22; +function continuationMmap( + memory: WebAssembly.Memory, + channelOffset: number, + size: number, + label: string, +): number { + const base = channelOffset; + let view = new DataView(memory.buffer); + 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 + 4 * CH_ARG_SIZE, -1n, true); + view.setBigInt64(base + CH_ARGS + 5 * CH_ARG_SIZE, 0n, true); + 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") { /* */ } + + 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); + Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); + if (err || result < 0) { + const errno = err || -result; + throw new ContinuationAllocationError( + errno, + size, + `${label}: mmap(${size}) failed errno=${errno}`, + ); + } + return result; +} + +function continuationMunmap( + memory: WebAssembly.Memory, + channelOffset: number, + addr: number, + size: number, + label: string, +): void { + const base = channelOffset; + const view = new DataView(memory.buffer); + view.setInt32(base + CH_SYSCALL, ABI_SYSCALLS.Munmap, true); + view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, BigInt(addr), true); + view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(size), true); + for (let i = 2; i < 6; i++) { + view.setBigInt64(base + CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + 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") { /* */ } + 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); + 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}`); + } +} + /** * Build kernel.* import stubs for channel-mode Wasm modules. * Both process and thread workers need these because the musl overlay CRT @@ -201,59 +279,15 @@ export interface DlopenSupport { 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; /** Clear a fork parent's copied archive lock in the child's private memory. */ resetForkChildLock: () => void; } -type WasmPointer = number | bigint; - const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER); -const MIN_SIGNED_WASM32 = -0x8000_0000; -const MAX_UNSIGNED_WASM32 = 0xffff_ffff; -const MIN_SIGNED_WASM64 = -(1n << 63n); -const MAX_UNSIGNED_WASM64 = (1n << 64n) - 1n; - -/** - * Convert a Wasm pointer into the exact JavaScript offset required by typed - * array constructors. WebAssembly exposes memory32 i32 parameters as numbers - * and memory64 i64 parameters as bigints. Normalize the signed JS view back to - * the pointer's unsigned bit pattern, then reject any address JavaScript - * cannot represent exactly. - */ -function checkedWasmPointerOffset( - value: WasmPointer, - ptrWidth: 4 | 8, - context: string, -): number { - let unsigned: bigint; - if (ptrWidth === 4) { - if ( - typeof value !== "number" - || !Number.isSafeInteger(value) - || value < MIN_SIGNED_WASM32 - || value > MAX_UNSIGNED_WASM32 - ) { - throw new TypeError(`${context}: expected an exact memory32 pointer`); - } - unsigned = BigInt(value >>> 0); - } else { - if ( - typeof value !== "bigint" - || value < MIN_SIGNED_WASM64 - || value > MAX_UNSIGNED_WASM64 - ) { - throw new TypeError(`${context}: expected an exact memory64 pointer`); - } - unsigned = BigInt.asUintN(64, value); - } - - if (unsigned > MAX_SAFE_BIGINT) { - throw new RangeError(`${context}: pointer exceeds JavaScript's exact address range`); - } - return Number(unsigned); -} function checkedWasmByteLength(value: number | bigint, context: string): number { if (typeof value === "number" && !Number.isSafeInteger(value)) { @@ -268,12 +302,12 @@ function checkedWasmByteLength(value: number | bigint, context: string): number function checkedWasmMemoryRange( memory: WebAssembly.Memory, - pointer: WasmPointer, + pointer: WasmGuestPointer, lengthValue: number | bigint, ptrWidth: 4 | 8, context: string, ): { offset: number; length: number } { - const offset = checkedWasmPointerOffset(pointer, ptrWidth, context); + const offset = checkedWasmGuestPointerOffset(pointer, ptrWidth, context); const length = checkedWasmByteLength(lengthValue, context); const memoryLength = memory.buffer.byteLength; if (offset > memoryLength || length > memoryLength - offset) { @@ -342,6 +376,7 @@ export function buildDlopenImports( longjmpTag: WebAssembly.Tag | undefined, cppExceptionTag: WebAssembly.Tag | undefined, mainHasDylinkForkRole: boolean, + beginMainForkAbort?: (errno: number) => void, ): DlopenSupport { let linker: DynamicLinker | null = null; const loadedLibraries = new Map(); @@ -379,6 +414,7 @@ export function buildDlopenImports( } }; const linkerAllocations = new Map(); + const archiveEntries = new Map(); let hostDlopenError: string | null = null; let mainDlopenDepth = 0; const acquireMainDlopenLock = (): boolean => { @@ -537,6 +573,12 @@ export function buildDlopenImports( ); } 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) => { @@ -547,25 +589,36 @@ export function buildDlopenImports( || activeSideFork.name !== state.name || activeSideFork.instance !== state.instance || activeSideFork.forkBufAddr !== state.forkBufAddr - || activeSideFork.forkBufSize !== state.forkBufSize || 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): number => { + invokeMainFork: (expectedStateAfter: 0 | 1 | readonly (0 | 1)[]): number => { const result = Number((mainFork as () => number)()); const actualState = Number((mainForkState as () => number)()); - if (actualState !== expectedStateAfter) { + 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 ${expectedStateAfter}`, + `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; @@ -575,6 +628,19 @@ 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", + ), globalSymbols, got: new Map(), loadedLibraries, @@ -610,6 +676,7 @@ export function buildDlopenImports( const totalSize = entrySize + nameAligned + bytes.length; const entry = allocateMemory(totalSize, 8); + archiveEntries.set(name, entry); const namePtr = entry + entrySize; const bytesPtr = namePtr + nameAligned; @@ -659,6 +726,16 @@ export function buildDlopenImports( } }; + 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(); @@ -708,6 +785,7 @@ export function buildDlopenImports( 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. @@ -765,7 +843,7 @@ export function buildDlopenImports( name: loaded.name, instance: loaded.instance, forkBufAddr: persisted, - forkBufSize: FORK_BUF_SIZE, + continuation: loaded.forkContinuation!, }; return activeSideFork; }; @@ -785,14 +863,44 @@ export function buildDlopenImports( if (sideForkState(state) !== 0) { throw new Error(`${state.name}: expected NORMAL before side-module rewind`); } - (state.instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( + 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, + ptrWidth, + `${state.name}: side-module linked fork rewind`, ); 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, + ptrWidth, + `${state.name}: side-module linked fork abort`, + ); + if (sideForkState(state) !== 3) { + throw new Error(`${state.name}: side-module abort did not enter ABORT_UNWINDING`); + } + }; + const assertNoActiveSideModuleFork = (): void => { const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); if (activeSideFork || persisted !== 0) { @@ -808,8 +916,8 @@ export function buildDlopenImports( }; const imports: Record = { - __wasm_dlopen: (bytesPtr: WasmPointer, bytesLen: number | bigint, - namePtr: WasmPointer, nameLen: number | bigint): number => { + __wasm_dlopen: (bytesPtr: WasmGuestPointer, bytesLen: number | bigint, + namePtr: WasmGuestPointer, nameLen: number | bigint): number => { if (!acquireMainDlopenLock()) return 0; hostDlopenError = null; try { @@ -875,7 +983,7 @@ export function buildDlopenImports( __wasm_dlsym: ( handle: number, - namePtr: WasmPointer, + namePtr: WasmGuestPointer, nameLen: number | bigint, ): number => { // See __wasm_dlopen above: copy off the shared buffer before @@ -902,7 +1010,7 @@ export function buildDlopenImports( return getLinker().dlclose(handle); }, - __wasm_dlerror: (bufPtr: WasmPointer, bufMax: number | bigint): number => { + __wasm_dlerror: (bufPtr: WasmGuestPointer, bufMax: number | bigint): number => { const err = hostDlopenError ?? getLinker().dlerror(); hostDlopenError = null; if (!err) return 0; @@ -926,6 +1034,7 @@ export function buildDlopenImports( replayDlopens, completeSideModuleForkUnwind, beginSideModuleForkRewind, + beginSideModuleForkAbort, assertNoActiveSideModuleFork, resetForkChildLock, }; @@ -949,6 +1058,8 @@ function buildImportObject( vmInterruptPtr: number, seconds: number, ) => void, + forkContinuation?: LinkedForkContinuation, + onContinuationAbort?: () => void, ): WebAssembly.Imports { const envImports: Record = { memory }; /** Convert wasm64 BigInt pointer to number (safe since addresses < 4GB) */ @@ -960,6 +1071,32 @@ function buildImportObject( // 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 linkedFrameImports = WPK_FORK_REQUIRED_IMPORTS.filter( + ({ module }) => module === "env", + ); + 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) { + if (!forkContinuation) { + throw new Error("linked fork instrumentation requested without continuation storage"); + } + 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 (ptrWidth === 8) { envImports.__channel_base = new WebAssembly.Global({ value: "i64", mutable: true }, BigInt(channelOffset)); @@ -1210,13 +1347,15 @@ function buildImportObject( return importObject; } -/** Size of the fork save buffer used by wpk_fork_* instrumentation */ +/** Legacy control-page geometry retained as the per-channel anchor location. */ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; /** - * Detect a fork-continuation save-buffer overrun after an unwind completes. + * Detect a legacy contiguous fork-save-buffer overrun after unwind. * - * The instrumentation keeps `current_pos` — the pointer-width integer at the + * ABI 42 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 * `forkBufAddr + frames_start_offset` and advanced by every saved frame. After * unwind it is therefore the high-water linear-memory address written (see @@ -1254,9 +1393,9 @@ export function forkSaveBufferOverrun( * main buffer cannot protect this continuation. */ export function finalizeSideModuleForkUnwind( - memory: WebAssembly.Memory, + _memory: WebAssembly.Memory, state: SideModuleForkState, - ptrWidth: 4 | 8, + _ptrWidth: 4 | 8, ): void { const sideForkState = (): number => Number((state.instance.exports.wpk_fork_state as () => number)()); @@ -1268,21 +1407,7 @@ export function finalizeSideModuleForkUnwind( throw new Error(`${state.name}: side-module unwind did not return to NORMAL`); } - const overrun = forkSaveBufferOverrun( - memory, - state.forkBufAddr, - ptrWidth, - state.forkBufSize, - ); - if (overrun > 0) { - throw new Error( - `${state.name}: side-module fork() continuation save buffer overflow — ` + - `the call stack at fork() needed ${state.forkBufSize + overrun} bytes ` + - `but only ${state.forkBufSize} (FORK_SAVE_BUFFER_SIZE) are reserved; ` + - `the side-module stack is too deep/wide to fork here. This is a ` + - `platform limit of the fork continuation buffer, not a defect in the program.`, - ); - } + state.continuation.finishUnwind(); } // Host-private control slots below the process main channel's fork buffer. @@ -1332,13 +1457,7 @@ const DLOPEN_LOCK_MAX_READERS = 0x7fff_ffff; const DLOPEN_ENTRY_SIZE_WASM32 = 40; const DLOPEN_ENTRY_SIZE_WASM64 = 72; -const WPK_FORK_EXPORTS = [ - "wpk_fork_unwind_begin", - "wpk_fork_unwind_end", - "wpk_fork_rewind_begin", - "wpk_fork_rewind_end", - "wpk_fork_state", -] as const; +const WPK_FORK_EXPORTS = WPK_FORK_REQUIRED_EXPORTS.map(({ name }) => name); function hasCompleteForkInstrumentation( moduleExports: WebAssembly.ModuleExportDescriptor[], @@ -1522,10 +1641,18 @@ export async function centralizedWorkerMain( ); // Fork state — captured by kernel_fork closure let forkResult = 0; - const forkBufAddr = initData.forkBufAddr ?? channelOffset - FORK_BUF_SIZE; + let forkBufAddr = initData.forkBufAddr ?? 0; const dlopenArchiveControlAddr = channelOffset - FORK_BUF_SIZE; if (hasForkInstrumentation) { + const linkedFrameFormat = readLinkedFrameFormat(module); + const forkContinuation = new LinkedForkContinuation( + memory, + linkedFrameFormat, + (size) => continuationMmap(memory, channelOffset, 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; @@ -1538,15 +1665,43 @@ export async function centralizedWorkerMain( 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; return forkResult; } + if (state === 3) { + const errno = forkContinuation.abortErrno(); + (processInstance.exports.wpk_fork_abort_end as () => void)(); + forkContinuation.finishAbortReplayAndRelease(); + writeForkContinuationAnchor(memory, dlopenArchiveControlAddr, ptrWidth, 0); + forkBufAddr = 0; + return -errno; + } // 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. - (processInstance.exports.wpk_fork_unwind_begin as (addr: number) => void)(forkBufAddr); + try { + forkBufAddr = Number(forkContinuation.beginUnwind()); + } catch (error) { + 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 }; @@ -1562,6 +1717,16 @@ export async function centralizedWorkerMain( 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`, + ); + }, ); const importObject = buildImportObject(module, memory, kernelImports, channelOffset, dlopenSupport.imports, () => processInstance ?? undefined, ptrWidth, processLongjmpTag, processCppExceptionTag, @@ -1573,6 +1738,18 @@ export async function centralizedWorkerMain( vmInterruptPtr, 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); processInstance = instance; @@ -1602,7 +1779,6 @@ export async function centralizedWorkerMain( 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; - const rewindBegin = instance.exports.wpk_fork_rewind_begin as (addr: number) => void; // For fork children: start with rewind to resume from fork point let needsRewind = !!initData.isForkChild; @@ -1610,11 +1786,8 @@ export async function centralizedWorkerMain( forkResult = 0; // fork() returns 0 in child } - // Use parent's fork buffer address for child rewind - const rewindAddr = initData.isForkChild && initData.forkBufAddr != null - ? initData.forkBufAddr - : forkBufAddr; 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 @@ -1643,11 +1816,32 @@ export async function centralizedWorkerMain( 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. - rewindBegin(rewindAddr); + invokeForkContinuationBegin( + instance.exports.wpk_fork_rewind_begin, + rewindAddr, + ptrWidth, + `pid=${pid}: linked fork rewind`, + ); // Now that rewind_begin has restored __tls_base, install // __channel_base for this (child) instance. setupChannelBase(instance, module, memory, channelOffset, programBytes as ArrayBuffer, ptrWidth); @@ -1679,31 +1873,7 @@ export async function centralizedWorkerMain( if (forkState === 1) { // Unwind completed (fork) — finalize and send SYS_FORK. unwindEnd(); - - // The unwind writes saved frames into a fixed FORK_BUF_SIZE buffer - // that abuts the syscall channel. If the call stack at fork() was - // too deep/wide, those writes overran into the channel. Fail - // truthfully here rather than sending a fork on a corrupt channel - // and spawning a child whose continuation buffer is already - // clobbered (which otherwise surfaces as an unexplained trap or a - // child worker that never makes progress). The process is torn - // down after this throw, discarding the corrupted channel. - const overrun = forkSaveBufferOverrun( - memory, - forkBufAddr, - ptrWidth, - FORK_BUF_SIZE, - ); - if (overrun > 0) { - throw new Error( - `pid=${pid}: fork() continuation save buffer overflow — the ` + - `call stack at fork() needed ${FORK_BUF_SIZE + overrun} bytes ` + - `but only ${FORK_BUF_SIZE} (FORK_SAVE_BUFFER_SIZE) are ` + - `reserved; the stack is too deep/wide to fork here. This is a ` + - `platform limit of the fork continuation buffer, not a defect ` + - `in the program.`, - ); - } + forkContinuation.finishUnwind(); dlopenSupport.completeSideModuleForkUnwind(); @@ -1711,7 +1881,9 @@ export async function centralizedWorkerMain( // fork save buffer populated (saved_globals + frames). const childPid = sendForkSyscall(memory, channelOffset); if (childPid < 0) { - throw new Error(`Fork failed: errno=${-childPid}`); + forkResult = childPid; + needsRewind = true; + continue; } forkResult = childPid; needsRewind = true; @@ -2481,7 +2653,23 @@ export async function centralizedThreadWorkerMain( const moduleExports = WebAssembly.Module.exports(module); const hasForkInstrumentation = hasCompleteForkInstrumentation(moduleExports, pid); - const forkBufAddr = channelOffset - FORK_BUF_SIZE; + 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( + memory, + channelOffset, + addr, + size, + `pid=${pid} tid=${tid}`, + ), + `pid=${pid} tid=${tid}`, + ) + : null; const processArchiveHeadOffset = ptrWidth === 8 ? DLOPEN_HEAD_OFFSET_WASM64 : DLOPEN_HEAD_OFFSET_WASM32; @@ -2534,11 +2722,26 @@ export async function centralizedThreadWorkerMain( if (state === 2) { try { (threadInstance.exports.wpk_fork_rewind_end as () => void)(); + threadForkContinuation!.finishReplayAndRelease(); + writeForkContinuationAnchor(memory, forkAnchorAddr, ptrWidth, 0); + forkBufAddr = 0; } finally { releasePthreadForkLock(); } return forkResult; } + if (state === 3) { + const errno = threadForkContinuation!.abortErrno(); + try { + (threadInstance.exports.wpk_fork_abort_end as () => void)(); + threadForkContinuation!.finishAbortReplayAndRelease(); + writeForkContinuationAnchor(memory, forkAnchorAddr, ptrWidth, 0); + forkBufAddr = 0; + } 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 @@ -2554,9 +2757,22 @@ export async function centralizedThreadWorkerMain( } try { - (threadInstance.exports.wpk_fork_unwind_begin as (addr: number) => void)(forkBufAddr); + 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`, + ); } catch (error) { releasePthreadForkLock(); + if (error instanceof ContinuationAllocationError) return -error.errno; throw error; } return 0; @@ -2584,6 +2800,18 @@ export async function centralizedThreadWorkerMain( vmInterruptPtr, 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); threadInstance = instance; @@ -2631,12 +2859,17 @@ export async function centralizedThreadWorkerMain( if (hasForkInstrumentation) { const getState = instance.exports.wpk_fork_state as () => number; const unwindEnd = instance.exports.wpk_fork_unwind_end as () => void; - const rewindBegin = instance.exports.wpk_fork_rewind_begin as (addr: number) => void; let needsRewind = false; for (;;) { if (needsRewind) { - rewindBegin(forkBufAddr); + threadForkContinuation!.beginReplay(); + invokeForkContinuationBegin( + instance.exports.wpk_fork_rewind_begin, + forkBufAddr, + ptrWidth, + `pid=${pid} tid=${tid}: linked fork rewind`, + ); needsRewind = false; } @@ -2658,23 +2891,7 @@ export async function centralizedThreadWorkerMain( const forkState = getState(); if (forkState === 1) { unwindEnd(); - // See the main-process fork path: a too-deep/wide stack overruns the - // fixed save buffer into the channel. Detect and fail truthfully. - const overrun = forkSaveBufferOverrun( - memory, - forkBufAddr, - ptrWidth, - FORK_BUF_SIZE, - ); - if (overrun > 0) { - throw new Error( - `pid=${pid} tid=${tid}: fork() continuation save buffer ` + - `overflow — the call stack at fork() needed ` + - `${FORK_BUF_SIZE + overrun} bytes but only ${FORK_BUF_SIZE} ` + - `(FORK_SAVE_BUFFER_SIZE) are reserved; too deep/wide to fork ` + - `from this thread. Platform limit, not a program defect.`, - ); - } + 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. @@ -2685,8 +2902,9 @@ export async function centralizedThreadWorkerMain( } const childPid = sendForkSyscall(memory, channelOffset); if (childPid < 0) { - releasePthreadForkLock(); - throw new Error(`Fork failed: errno=${-childPid}`); + forkResult = childPid; + needsRewind = true; + continue; } forkResult = childPid; needsRewind = true; diff --git a/host/src/worker-protocol.ts b/host/src/worker-protocol.ts index 2b80c6359b..d72da141d1 100644 --- a/host/src/worker-protocol.ts +++ b/host/src/worker-protocol.ts @@ -4,14 +4,8 @@ export type HostToWorkerMessage = | CentralizedWorkerInitMessage | CentralizedThreadInitMessage | WorkerTerminateMessage - | DeliverSignalMessage | ExecReplyMessage; -export interface DeliverSignalMessage { - type: "deliver_signal"; - signal: number; -} - /** * Init message for centralized-mode Workers. * These Workers don't instantiate a kernel — they use channel IPC @@ -20,7 +14,6 @@ export interface DeliverSignalMessage { export interface CentralizedWorkerInitMessage { type: "centralized_init"; pid: number; - ppid: number; /** User program bytes (compiled with channel_syscall.c — no kernel imports) */ programBytes: ArrayBuffer; /** Pre-compiled WebAssembly module (avoids recompilation in web workers) */ diff --git a/host/test/advisory-lock-kernel.test.ts b/host/test/advisory-lock-kernel.test.ts index da88b06158..985fa20339 100644 --- a/host/test/advisory-lock-kernel.test.ts +++ b/host/test/advisory-lock-kernel.test.ts @@ -83,16 +83,15 @@ function makeProcessMemory(): ProcessMemory { function register( worker: CentralizedKernelWorker, - pid: number, -): ProcessMemory { +): number { + const pid = worker.createProcess(CAPTURED_STDIO); const entry = makeProcessMemory(); worker.registerProcess(pid, entry.memory, [entry.channelOffset], { brkBase: entry.layout.brkBase, mmapBase: entry.layout.mmapBase, maxAddr: entry.layout.maxAddr, - stdio: CAPTURED_STDIO, }); - return entry; + return pid; } function issue( @@ -117,6 +116,9 @@ function issue( const handleChannel = (worker as any).kernelInstance.exports .kernel_handle_channel as (offset: number | bigint, pid: number) => number; + 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)), @@ -206,12 +208,9 @@ describe("Rust advisory locks through the real kernel Wasm", () => { new NodeTimeProvider(), ); const worker = await makeWorker(platform); - const firstPid = 690; - const secondPid = 691; - const aliasPid = 692; - register(worker, firstPid); - register(worker, secondPid); - register(worker, aliasPid); + const firstPid = register(worker); + const secondPid = register(worker); + const aliasPid = register(worker); try { const firstFd = openFile(worker, firstPid, "/first/file"); @@ -253,12 +252,9 @@ describe("Rust advisory locks through the real kernel Wasm", () => { linkSync(original, alias); const worker = await makeWorker(); - const ownerPid = 700; - const peerPid = 701; - const recreatedPid = 702; - register(worker, ownerPid); - register(worker, peerPid); - register(worker, recreatedPid); + const ownerPid = register(worker); + const peerPid = register(worker); + const recreatedPid = register(worker); try { const ownerFd = openFile(worker, ownerPid, original); @@ -323,11 +319,8 @@ describe("Rust advisory locks through the real kernel Wasm", () => { const path = join(root, "file"); writeFileSync(path, "data"); const worker = await makeWorker(); - const parentPid = 705; - const childPid = 706; - const peerPid = 707; - register(worker, parentPid); - register(worker, peerPid); + const parentPid = register(worker); + const peerPid = register(worker); try { const parentFd = openFile(worker, parentPid, path); @@ -338,8 +331,9 @@ describe("Rust advisory locks through the real kernel Wasm", () => { }); const forkProcess = (worker as any).kernelInstance.exports - .kernel_fork_process as (parent: number, child: number) => number; - expect(forkProcess(parentPid, childPid)).toBe(0); + .kernel_fork_process as (parent: number, callerTid: number) => number; + const childPid = forkProcess(parentPid, parentPid); + expect(childPid).toBeGreaterThan(0); expect(lock(worker, peerPid, peerFd, 0n, 1n)).toEqual({ value: -1, errno: EAGAIN, @@ -370,11 +364,8 @@ describe("Rust advisory locks through the real kernel Wasm", () => { const path = join(root, "file"); writeFileSync(path, "data"); const worker = await makeWorker(); - const ownerPid = 710; - const childPid = 711; - const peerPid = 712; - register(worker, ownerPid); - register(worker, peerPid); + const ownerPid = register(worker); + const peerPid = register(worker); try { const ownerFd = openFile(worker, ownerPid, path); @@ -388,8 +379,9 @@ describe("Rust advisory locks through the real kernel Wasm", () => { .toEqual({ value: -1, errno: EAGAIN }); const forkProcess = (worker as any).kernelInstance.exports - .kernel_fork_process as (parent: number, child: number) => number; - expect(forkProcess(ownerPid, childPid)).toBe(0); + .kernel_fork_process as (parent: number, callerTid: number) => number; + const childPid = forkProcess(ownerPid, ownerPid); + expect(childPid).toBeGreaterThan(0); closeFile(worker, ownerPid, ownerFd); closeFile(worker, ownerPid, duplicate.value); @@ -418,10 +410,8 @@ describe("Rust advisory locks through the real kernel Wasm", () => { const path = join(root, "file"); writeFileSync(path, "capacity"); const worker = await makeWorker(); - const ownerPid = 720; - const peerPid = 721; - register(worker, ownerPid); - register(worker, peerPid); + const ownerPid = register(worker); + const peerPid = register(worker); try { const fd = openFile(worker, ownerPid, path); diff --git a/host/test/advisory-lock-retry.test.ts b/host/test/advisory-lock-retry.test.ts index 30825781e7..14595a6c6e 100644 --- a/host/test/advisory-lock-retry.test.ts +++ b/host/test/advisory-lock-retry.test.ts @@ -3,6 +3,7 @@ import { ABI_SYSCALLS, CH_ERRNO, CH_RETURN, + PROCESS_STATE_EXITED, PROCESS_STATE_RUNNING, } from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; @@ -123,7 +124,10 @@ describe("Rust-owned advisory-lock retry scheduling", () => { const handleChannel = vi.fn(() => { throw new WebAssembly.RuntimeError("unreachable"); }); - const worker = createWorker({ kernel_handle_channel: handleChannel }); + const worker = createWorker({ + kernel_handle_channel: handleChannel, + 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(); @@ -186,8 +190,8 @@ describe("Rust-owned advisory-lock retry scheduling", () => { worker.drainAndProcessWakeupEvents = vi.fn(); worker.snapshotExecTcpListenerWakeIds = vi.fn(() => new Map()); - expect(worker.kernelExecPrepare(19)).toBe(-5); - expect(worker.kernelExecSetup(19)).toBe(-5); + expect(worker.kernelExecPrepare(19, 19)).toBe(-5); + expect(worker.kernelExecSetup(19, 19)).toBe(-5); expect(worker.drainAndProcessWakeupEvents).toHaveBeenCalledTimes(2); expect(prepare.mock.invocationCallOrder[0]).toBeLessThan( @@ -404,6 +408,7 @@ function createWorker(exports: Record): any { 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, }, }, diff --git a/host/test/audio-integration.test.ts b/host/test/audio-integration.test.ts index 6ca5342206..b226df5f9f 100644 --- a/host/test/audio-integration.test.ts +++ b/host/test/audio-integration.test.ts @@ -59,7 +59,7 @@ describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { const workerAdapter = new NodeWorkerAdapter(); const workers = new Map>(); - const pid = 100; + let pid = 0; let stdout = ""; let resolveExit: (status: number) => void; @@ -96,18 +96,18 @@ describe.skipIf(!existsSync(audiotestBinary))("audio integration", () => { }); 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, stdio: CAPTURED_STDIO }); + kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes, memory, channelOffset, diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index d1d119c707..2bc345461c 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -254,8 +254,8 @@ describe("BrowserKernel", () => { const spawn = worker.lastMessage("spawn"); // The main thread cannot know that a guest fork has reserved pid 101 but - // has not yet finished registering its process worker. Only the kernel - // worker's authoritative PID allocator can assign the following pid safely. + // has not yet finished registering its process worker. Only the Rust + // kernel's authoritative task-ID allocator can assign the next ID safely. expect(spawn).not.toHaveProperty("pid"); worker.simulateMessage({ type: "response", @@ -385,12 +385,12 @@ describe("BrowserKernel", () => { await bootPromise; processEvents.length = 0; - worker.simulateMessage({ type: "proc_event", kind: "spawn", pid: 2, ppid: 100 }); - worker.simulateMessage({ type: "proc_event", kind: "exec", pid: 2 }); + worker.simulateMessage({ type: "proc_event", kind: "spawn", pid: 101, ppid: 100 }); + worker.simulateMessage({ type: "proc_event", kind: "exec", pid: 101 }); expect(processEvents).toEqual([ - { kind: "spawn", pid: 2, ppid: 100 }, - { kind: "exec", pid: 2 }, + { kind: "spawn", pid: 101, ppid: 100 }, + { kind: "exec", pid: 101 }, ]); }); diff --git a/host/test/browser-worker-adapter.test.ts b/host/test/browser-worker-adapter.test.ts index 0d2af487f4..82274c1575 100644 --- a/host/test/browser-worker-adapter.test.ts +++ b/host/test/browser-worker-adapter.test.ts @@ -71,7 +71,7 @@ describe("BrowserWorkerAdapter", () => { describe("createWorker", () => { it("should create a Worker with the entry URL and module type", () => { const adapter = new BrowserWorkerAdapter("worker.js"); - adapter.createWorker({ pid: 1 }); + adapter.createWorker({ pid: 100 }); expect(lastMockWorker).not.toBeNull(); expect(lastMockWorker!.url).toBe("worker.js"); @@ -116,10 +116,10 @@ describe("BrowserWorkerAdapter", () => { const received: unknown[] = []; handle.on("message", (msg) => received.push(msg)); - lastMockWorker!.simulateMessage({ type: "ready", pid: 1 }); + lastMockWorker!.simulateMessage({ type: "ready", pid: 100 }); expect(received).toHaveLength(1); - expect(received[0]).toEqual({ type: "ready", pid: 1 }); + expect(received[0]).toEqual({ type: "ready", pid: 100 }); }); it("should support multiple message handlers", () => { diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index 205d76ba81..587c32c6a4 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -21,6 +21,7 @@ import { type ProcessMemoryLayout, } from "../src/process-memory"; import { NodeKernelHost } from "../src/node-kernel-host"; +import { readForkContinuationAnchor } from "../src/fork-continuation"; import type { HostDiagnostic } from "../src/host-diagnostic"; import type { CentralizedWorkerInitMessage, CentralizedThreadInitMessage, WorkerToHostMessage } from "../src/worker-protocol"; import type { PlatformIO } from "../src/types"; @@ -69,6 +70,7 @@ function createFreshProcessMemory( programBytes: ArrayBuffer, ptrWidth: 4 | 8, reserveSlotStartPage?: () => number, + maximumPages: number = MAX_PAGES, ): { memory: WebAssembly.Memory; layout: ProcessMemoryLayout; @@ -76,7 +78,7 @@ function createFreshProcessMemory( } { const heapBase = extractHeapBase(programBytes); const layout = computeProcessMemoryLayout({ - maxPages: MAX_PAGES, + maxPages: maximumPages, ptrWidth, programBytes, heapBase, @@ -116,6 +118,8 @@ export interface RunProgramOptions { argv?: string[]; /** Timeout in ms (default: 30000) */ timeout?: number; + /** Process memory ceiling for bounded allocation-failure tests. */ + maxPages?: number; /** Custom PlatformIO (defaults to NodePlatformIO). * When provided, forces main-thread mode (PlatformIO can't be serialized). */ io?: PlatformIO; @@ -220,6 +224,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise(); let mainThreadForkCount: bigint | undefined; - const pid = 100; + let pid = 0; const kernelWorker = new CentralizedKernelWorker( { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true, enableSyscallLog: !!process.env.KERNEL_SYSCALL_LOG }, @@ -353,7 +358,6 @@ async function runOnMainThread(options: RunProgramOptions): Promise 0) return 0; kernelWorker.registerProcess(execPid, newMemory, [newChannelOffset], { - skipKernelCreate: true, + preserveProcessState: true, ptrWidth: newPtrWidth, metadataPtrWidth: sourcePtrWidth, brkBase: newLayout.brkBase, @@ -495,7 +507,6 @@ async function runOnMainThread(options: RunProgramOptions): Promise { + onClone: async (attachment) => { + const { + pid: clonePid, + tid, + fnPtr, + argPtr, + stackPtr, + tlsPtr, + ctidPtr, + memory, + } = attachment; const threadAllocator = threadAllocators.get(clonePid); if (!threadAllocator) throw new Error(`Unknown thread allocator for pid ${clonePid}`); const clonePtrWidth = processPtrWidths.get(clonePid) ?? ptrWidth; @@ -544,7 +565,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise { if (exitPid === pid) { @@ -629,6 +649,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise Promise, + kernelTid = KERNEL_TID, + autoAttach = true, +) { + const memory = new WebAssembly.Memory({ + initial: 16, + 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 kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const kernelView = new DataView(kernelMemory.buffer); + 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, + scratchOffset: 0, + 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(() => { + kernelView.setBigInt64(CH_RETURN, BigInt(kernelTid), true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }), + }, + }, + }, + ) as CentralizedKernelWorker; + (worker as any).callbacks = { + onClone: (attachment: unknown) => { + if (autoAttach) { + worker.attachThreadChannel( + attachment as Parameters[0], + 2 * WASM_PAGE_SIZE, + ); + } + return onClone(attachment); + }, + }; + + return { + channel, + completeChannel, + kernelHandleChannel: (worker as any).kernelInstance.exports.kernel_handle_channel, + notifyThreadExit, + worker, + }; +} + +function makeChannelOwnershipHarness() { + const memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const mainChannelOffset = WASM_PAGE_SIZE; + const mainChannel = { + pid: PID, + channelOffset: mainChannelOffset, + memory, + i32View: new Int32Array(memory.buffer, mainChannelOffset), + consecutiveSyscalls: 0, + }; + const validateTask = vi.fn(() => 0); + const retireExactChannelAsyncState = vi.fn(); + const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const kernelView = new DataView(kernelMemory.buffer); + let nextKernelTid = 0; + const worker = Object.assign( + Object.create(CentralizedKernelWorker.prototype), + { + callbacks: {}, + kernel: { + toKernelPtr(value: number | bigint): number { + return Number(value); + }, + }, + kernelMemory, + scratchOffset: 0, + 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(() => { + kernelView.setBigInt64(CH_RETURN, BigInt(nextKernelTid), true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }), + }, + }, + }, + ) as CentralizedKernelWorker; + + return { + mainChannel, + memory, + retireExactChannelAsyncState, + validateTask, + worker, + 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(() => {}); + }, + }; + (worker as any).handleClone( + mainChannel, + [0, 0x0080_0000, 0, 0x0090_0000, 0, 0], + ); + if (!attachment) throw new Error("clone callback did not receive attachment"); + return attachment; + }, + }; +} + +async function flushCloneContinuation(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe("kernel TID authority", () => { + it("rejects invalid parent/child TID pointers before Rust allocates a task", () => { + const onClone = vi.fn(async () => {}); + const first = makeCloneHarness(onClone); + const invalidPtr = first.channel.memory.buffer.byteLength - 2; + const parentArgs = [ + CLONE_ARGS[0] | 0x0010_0000, + CLONE_ARGS[1], + invalidPtr, + CLONE_ARGS[3], + CLONE_ARGS[4], + 0, + ]; + + (first.worker as any).handleClone(first.channel, parentArgs); + expect(first.kernelHandleChannel).not.toHaveBeenCalled(); + expect(first.completeChannel).toHaveBeenCalledWith( + first.channel, + ABI_SYSCALLS.Clone, + parentArgs, + undefined, + -1, + 14, + ); + + const second = makeCloneHarness(onClone); + const childArgs = [...CLONE_ARGS]; + childArgs[4] = invalidPtr; + (second.worker as any).handleClone(second.channel, childArgs); + expect(second.kernelHandleChannel).not.toHaveBeenCalled(); + expect(second.completeChannel).toHaveBeenCalledWith( + second.channel, + ABI_SYSCALLS.Clone, + childArgs, + undefined, + -1, + 14, + ); + expect(onClone).not.toHaveBeenCalled(); + }); + + it("rolls back the exact Rust TID when the clone callback throws synchronously", () => { + const launchError = new Error("synchronous worker construction failed"); + const onClone = vi.fn(() => { + throw launchError; + }); + const { channel, notifyThreadExit, worker } = makeCloneHarness(onClone); + + expect(() => (worker as any).handleClone(channel, CLONE_ARGS)) + .toThrow(launchError); + expect(notifyThreadExit).toHaveBeenCalledOnce(); + expect(notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); + }); + + 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 args = [...CLONE_ARGS]; + args[0] &= ~0x0020_0000; + + (worker as any).handleClone(channel, 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(); + }); + + 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); + + (worker as any).handleClone(channel, CLONE_ARGS); + + expect(onClone).not.toHaveBeenCalled(); + expect(notifyThreadExit).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Clone, + CLONE_ARGS, + undefined, + -1, + 5, + ); + }); + + it("ignores a host callback return value and completes with the Rust-assigned TID", async () => { + // Deliberately emulate a stale callback that still returns a TID. The + // 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); + + (worker as any).handleClone(channel, CLONE_ARGS); + await flushCloneContinuation(); + + expect(onClone).toHaveBeenCalledWith(expect.objectContaining({ + pid: PID, + tid: KERNEL_TID, + fnPtr: 11, + argPtr: 22, + stackPtr: CLONE_ARGS[1], + tlsPtr: CLONE_ARGS[3], + ctidPtr: CLONE_ARGS[4], + memory: channel.memory, + })); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Clone, + CLONE_ARGS, + undefined, + KERNEL_TID, + 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 consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + (worker as any).handleClone(channel, CLONE_ARGS); + await flushCloneContinuation(); + + expect(notifyThreadExit).toHaveBeenCalledOnce(); + expect(notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Clone, + CLONE_ARGS, + undefined, + -1, + 12, + ); + } finally { + consoleError.mockRestore(); + } + }); + + 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 consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + (worker as any).handleClone(channel, CLONE_ARGS); + await flushCloneContinuation(); + + expect(notifyThreadExit).toHaveBeenCalledWith(PID, KERNEL_TID); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Clone, + CLONE_ARGS, + undefined, + -1, + 12, + ); + } finally { + consoleError.mockRestore(); + } + }); +}); + +describe("thread channel ownership", () => { + const firstThreadOffset = 2 * WASM_PAGE_SIZE; + const secondThreadOffset = 3 * WASM_PAGE_SIZE; + const thirdThreadOffset = 4 * WASM_PAGE_SIZE; + + it("rejects a duplicate channel offset instead of remapping its TID", () => { + const { issueThreadAttachment, validateTask, worker } = + makeChannelOwnershipHarness(); + worker.attachThreadChannel( + issueThreadAttachment(KERNEL_TID), + firstThreadOffset, + ); + + expect(() => worker.attachThreadChannel( + issueThreadAttachment(KERNEL_TID + 1), + firstThreadOffset, + )) + .toThrow( + `Channel offset ${firstThreadOffset} for process ${PID} is already registered`, + ); + + expect(validateTask).toHaveBeenCalledTimes(1); + expect((worker as any).processes.get(PID).channels).toHaveLength(2); + expect((worker as any).channelTids.get(`${PID}:${firstThreadOffset}`)) + .toBe(KERNEL_TID); + expect((worker as any).threadForkContexts.get(`${PID}:${firstThreadOffset}`)) + .toEqual({ fnPtr: 11, argPtr: 22 }); + }); + + it("rejects assigning one kernel TID to a second channel", () => { + const { issueThreadAttachment, validateTask, worker } = + makeChannelOwnershipHarness(); + worker.attachThreadChannel( + issueThreadAttachment(KERNEL_TID), + firstThreadOffset, + ); + + expect(() => worker.attachThreadChannel( + issueThreadAttachment(KERNEL_TID), + secondThreadOffset, + )) + .toThrow( + `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); + expect((worker as any).activeChannels).toHaveLength(2); + expect((worker as any).channelTids.has(`${PID}:${secondThreadOffset}`)) + .toBe(false); + }); + + it("rejects a wrong-but-valid sibling TID for another clone channel", () => { + const siblingTid = KERNEL_TID + 1; + const { issueThreadAttachment, validateTask, worker } = + makeChannelOwnershipHarness(); + worker.attachThreadChannel( + issueThreadAttachment(KERNEL_TID), + firstThreadOffset, + ); + worker.attachThreadChannel( + issueThreadAttachment(siblingTid), + secondThreadOffset, + ); + + expect(() => worker.attachThreadChannel( + issueThreadAttachment(siblingTid), + thirdThreadOffset, + )) + .toThrow( + `Kernel TID ${siblingTid} is already attached to channel ${PID}:${secondThreadOffset}`, + ); + + expect(validateTask).toHaveBeenLastCalledWith(PID, siblingTid); + expect(validateTask).toHaveBeenCalledTimes(3); + expect((worker as any).processes.get(PID).channels).toHaveLength(3); + expect((worker as any).channelTids.has(`${PID}:${thirdThreadOffset}`)) + .toBe(false); + }); + + it("keeps concurrent pending TIDs bound to uncopyable one-shot attachments", () => { + 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"); + + 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}`)) + .toBe(KERNEL_TID); + expect((worker as any).channelTids.get(`${PID}:${secondThreadOffset}`)) + .toBe(siblingTid); + expect((worker as any).threadForkContexts.get(`${PID}:${secondThreadOffset}`)) + .toEqual({ fnPtr: 33, argPtr: 44 }); + expect((worker as any).addChannel).toBeUndefined(); + }); + + it("releases channel ownership on removal so a later clone can reuse the slot", () => { + const replacementTid = KERNEL_TID + 1; + const { issueThreadAttachment, retireExactChannelAsyncState, worker } = + makeChannelOwnershipHarness(); + worker.attachThreadChannel( + 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), + firstThreadOffset, + ); + expect((worker as any).channelTids.get(`${PID}:${firstThreadOffset}`)) + .toBe(replacementTid); + expect((worker as any).threadForkContexts.get(`${PID}:${firstThreadOffset}`)) + .toEqual({ fnPtr: 33, argPtr: 44 }); + }); +}); diff --git a/host/test/closed-lazy-assets.test.ts b/host/test/closed-lazy-assets.test.ts index ec2d3f8d6e..d816046aa0 100644 --- a/host/test/closed-lazy-assets.test.ts +++ b/host/test/closed-lazy-assets.test.ts @@ -1,9 +1,13 @@ import { createHash } from "node:crypto"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createClosedLazyAssetFetcher, + loadClosedLazyAssetSources, + MAX_CLOSED_LAZY_ASSET_BYTES, + MAX_CLOSED_LAZY_ASSETS, snapshotClosedLazyAssets, type ClosedLazyAsset, + type ClosedLazyAssetSource, } from "../src/vfs/closed-lazy-assets"; const URL_A = "https://github.com/example/project/releases/download/v1/a.tar.gz"; @@ -21,7 +25,539 @@ function asset( }; } +function sourceBinding( + url = URL_B, + sourceUrl = "/assets/package-tree.zip", + bytes = new Uint8Array([4, 5, 6]), +): ClosedLazyAssetSource { + return { + url, + sourceUrl, + sha256: createHash("sha256").update(bytes).digest("hex"), + size: bytes.byteLength, + }; +} + describe("closed lazy assets", () => { + it("loads a verified transport source under its canonical deferred-tree URL", async () => { + const source = new Uint8Array([4, 5, 6, 7]); + const fetchImpl = vi.fn(async () => new Response(source, { + headers: { "content-length": String(source.byteLength) }, + })); + const loaded = await loadClosedLazyAssetSources([ + sourceBinding(URL_B, "/assets/package-tree.zip", source), + ], { fetchImpl }); + + expect(fetchImpl).toHaveBeenCalledWith("/assets/package-tree.zip", { + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + redirect: "error", + signal: expect.any(AbortSignal), + }); + expect(loaded).toEqual([asset(URL_B, source)]); + + const fetcher = createClosedLazyAssetFetcher([ + asset(URL_A, new Uint8Array([1, 2, 3])), + ...loaded, + ]); + expect(new Uint8Array(await (await fetcher(URL_B)).arrayBuffer())).toEqual(source); + await expect(fetcher("https://example.test/unbound.zip")).rejects.toThrow( + "do not bind URL", + ); + }); + + it("rejects missing, truncated, oversized, and changed transport sources", async () => { + const source = new Uint8Array([4, 5, 6]); + const identity = sourceBinding(URL_B, "/assets/package-tree.zip", source); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(null, { status: 404 }), + })).rejects.toThrow("returned HTTP 404"); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(source.slice(0, 2)), + })).rejects.toThrow("has 2 bytes, expected 3"); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(new Uint8Array([4, 5, 6, 7])), + })).rejects.toThrow("exceeds 3 bytes"); + await expect(loadClosedLazyAssetSources([{ + ...identity, + sha256: "0".repeat(64), + }], { + fetchImpl: async () => new Response(source), + })).rejects.toThrow("changed SHA-256"); + }); + + it("trusts the decoded stream length instead of transport Content-Length", async () => { + const identity = sourceBinding(); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => + new Response(new Uint8Array([4, 5, 6]), { + headers: { "content-length": "03" }, + }), + })).resolves.toEqual([asset(URL_B, new Uint8Array([4, 5, 6]))]); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => + new Response(new Uint8Array([4, 5, 6]), { + headers: { + "content-encoding": "gzip", + "content-length": "1", + }, + }), + })).resolves.toEqual([asset(URL_B, new Uint8Array([4, 5, 6]))]); + }); + + it("rejects a successful response without a body", async () => { + const identity = sourceBinding(); + await expect(loadClosedLazyAssetSources([identity], { + fetchImpl: async () => new Response(null, { status: 200 }), + })).rejects.toThrow("has no response body"); + }); + + it("validates transport-source identities before fetching", async () => { + const source = new Uint8Array([4, 5, 6]); + const identity = sourceBinding(URL_B, "/assets/package-tree.zip", source); + const fetchImpl = vi.fn(async () => new Response(source)); + await expect(loadClosedLazyAssetSources([ + identity, + { ...identity, sourceUrl: "/assets/duplicate.zip" }, + ], { fetchImpl })).rejects.toThrow("duplicate URL"); + await expect(loadClosedLazyAssetSources([{ + ...identity, + sourceUrl: "data:text/plain,not-http", + }], { fetchImpl })).rejects.toThrow("loopback HTTP"); + await expect(loadClosedLazyAssetSources([{ + ...identity, + url: "http://example.test/not-https", + }], { fetchImpl })).rejects.toThrow("canonical credential-free HTTPS"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it.each([ + "/assets/package-tree.zip?", + "/assets/package-tree.zip?channel=closed", + "http://127.0.0.1:4173/package-tree.zip", + "http://localhost:4173/package-tree.zip?", + "http://[::1]:4173/package-tree.zip", + "https://assets.example.test/package-tree.zip", + "https://assets.example.test/package-tree.zip?channel=closed", + ])("accepts canonical transport source URL %s", async (sourceUrl) => { + const source = new Uint8Array([4, 5, 6]); + const fetchImpl = vi.fn(async () => new Response(source)); + await expect(loadClosedLazyAssetSources([ + sourceBinding(URL_B, sourceUrl, source), + ], { fetchImpl })).resolves.toEqual([asset(URL_B, source)]); + expect(fetchImpl).toHaveBeenCalledWith(sourceUrl, { + cache: "no-store", + credentials: "omit", + referrerPolicy: "no-referrer", + redirect: "error", + signal: expect.any(AbortSignal), + }); + }); + + it.each([ + ["credentials", "https://user:secret@assets.example.test/package.zip"], + ["fragment", "https://assets.example.test/package.zip#fragment"], + ["empty fragment", "https://assets.example.test/package.zip#"], + ["relative empty fragment", "/assets/package.zip#"], + ["uppercase host", "https://ASSETS.example.test/package.zip"], + ["dot-segment normalization", "https://assets.example.test/a/../package.zip"], + ["relative dot-segment normalization", "/assets/a/../package.zip"], + ["non-root-relative path", "assets/package.zip"], + ["network-path reference", "//assets.example.test/package.zip"], + ["non-loopback cleartext HTTP", "http://assets.example.test/package.zip"], + ["non-loopback numeric HTTP", "http://192.0.2.1/package.zip"], + ])("rejects a transport source URL with %s", async (_name, sourceUrl) => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + await expect(loadClosedLazyAssetSources([ + sourceBinding(URL_B, sourceUrl), + ], { fetchImpl })).rejects.toThrow("loopback HTTP"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it.each([0, 17, 1.5, Number.NaN, Number.POSITIVE_INFINITY])( + "rejects invalid maxConcurrency %s before fetching", + async (maxConcurrency) => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + await expect(loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + maxConcurrency, + })).rejects.toThrow("concurrency must be an integer from 1 to 16"); + expect(fetchImpl).not.toHaveBeenCalled(); + }, + ); + + it("rejects empty, sparse, and coercible source manifests before fetching", async () => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + await expect(loadClosedLazyAssetSources([], { fetchImpl })).rejects.toThrow( + "at least one binding", + ); + + const sparse = new Array(2); + sparse[0] = sourceBinding(); + await expect(loadClosedLazyAssetSources(sparse, { fetchImpl })).rejects.toThrow( + "source 1 is missing", + ); + + const coercibleSha = { + toString: () => sourceBinding().sha256, + } as unknown as string; + await expect(loadClosedLazyAssetSources([{ + ...sourceBinding(), + sha256: coercibleSha, + }], { fetchImpl })).rejects.toThrow("invalid fields"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("snapshots every source field before starting transport I/O", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const original = sourceBinding(URL_B, "/assets/original.zip", bytes); + const mutable = { ...original }; + const manifest = [mutable]; + let resolveFetch!: (response: Response) => void; + const fetchImpl = vi.fn(() => new Promise((resolve) => { + resolveFetch = resolve; + })); + + const loading = loadClosedLazyAssetSources(manifest, { fetchImpl }); + mutable.url = URL_A; + mutable.sourceUrl = "/assets/mutated.zip"; + mutable.sha256 = "0".repeat(64); + mutable.size = 1; + manifest.push(sourceBinding()); + resolveFetch(new Response(bytes)); + + await expect(loading).resolves.toEqual([asset(original.url, bytes)]); + expect(fetchImpl.mock.calls[0]![0]).toBe(original.sourceUrl); + }); + + it("hashes the owned response buffer without an aggregate-sized copy", async () => { + const bytes = new Uint8Array([4, 5, 6]); + const digest = crypto.subtle.digest.bind(crypto.subtle); + const digestInputs: BufferSource[] = []; + const digestSpy = vi.spyOn(crypto.subtle, "digest").mockImplementation( + async (algorithm, input) => { + digestInputs.push(input); + return digest(algorithm, input); + }, + ); + try { + const loaded = await loadClosedLazyAssetSources([ + sourceBinding(URL_B, "/assets/package.zip", bytes), + ], { + fetchImpl: async () => new Response(bytes), + }); + expect(digestInputs).toHaveLength(1); + expect(digestInputs[0]).toBe(loaded[0]!.bytes.buffer); + } finally { + digestSpy.mockRestore(); + } + }); + + it("redacts source queries and cancels an unused HTTP-error body", async () => { + const cancellationError = new Error("secondary cancellation failure"); + let cancellationReason: unknown; + const response = new Response(new ReadableStream({ + cancel(reason) { + cancellationReason = reason; + return Promise.reject(cancellationError); + }, + }), { status: 503 }); + const loading = loadClosedLazyAssetSources([ + sourceBinding(URL_B, "/assets/package.zip?token=private-value"), + ], { fetchImpl: async () => response }); + + const failure = await loading.then( + () => undefined, + (reason: unknown) => reason, + ); + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain( + "/assets/package.zip? returned HTTP 503", + ); + expect((failure as Error).message).not.toContain("private-value"); + expect(cancellationReason).toBe(failure); + }); + + it("rejects an injected redirected response and cancels its body", async () => { + let cancellationReason: unknown; + const response = new Response(new ReadableStream({ + cancel(reason) { + cancellationReason = reason; + }, + })); + Object.defineProperty(response, "redirected", { value: true }); + const failure = await loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl: async () => response, + }).then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain("followed a redirect"); + expect(cancellationReason).toBe(failure); + }); + + it("preserves a pre-aborted caller reason without starting I/O", async () => { + const controller = new AbortController(); + const reason = new Error("caller stopped before loading"); + controller.abort(reason); + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([4, 5, 6]))); + + await expect(loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + signal: controller.signal, + })).rejects.toBe(reason); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("relays a caller abort to an active fetch and removes its listener", async () => { + const controller = new AbortController(); + const addListener = vi.spyOn(controller.signal, "addEventListener"); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + let internalSignal!: AbortSignal; + let started!: () => void; + const didStart = new Promise((resolve) => { + started = resolve; + }); + const fetchImpl = vi.fn((_input: string | URL, init?: RequestInit) => { + internalSignal = init!.signal as AbortSignal; + started(); + return new Promise((_resolve, reject) => { + internalSignal.addEventListener( + "abort", + () => reject(internalSignal.reason), + { once: true }, + ); + }); + }); + const loading = loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + signal: controller.signal, + }); + await didStart; + const reason = new Error("caller stopped active loading"); + controller.abort(reason); + + await expect(loading).rejects.toBe(reason); + expect(internalSignal).not.toBe(controller.signal); + expect(internalSignal.aborted).toBe(true); + expect(internalSignal.reason).toBe(reason); + const listener = addListener.mock.calls[0]![1]; + expect(removeListener).toHaveBeenCalledWith("abort", listener); + }); + + it("removes the caller abort listener after success and transport failure", async () => { + const scenarios = [ + async () => new Response(new Uint8Array([4, 5, 6])), + async () => { + throw new Error("transport failed"); + }, + ]; + for (const fetchImpl of scenarios) { + const controller = new AbortController(); + const addListener = vi.spyOn(controller.signal, "addEventListener"); + const removeListener = vi.spyOn(controller.signal, "removeEventListener"); + await loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl, + signal: controller.signal, + }).catch(() => undefined); + + const listener = addListener.mock.calls[0]![1]; + expect(removeListener).toHaveBeenCalledWith("abort", listener); + } + }); + + it("keeps the first worker failure, stops dequeuing, and waits for peer cleanup", async () => { + const inputs = [0, 1, 2].map((index) => { + const bytes = new Uint8Array([10 + index]); + return sourceBinding( + `https://example.test/releases/${index}.zip`, + `/assets/${index}.zip`, + bytes, + ); + }); + const firstFailure = new Error("first source failed"); + let peerCancelReason: unknown; + let releasePeerCancel!: () => void; + const peerCancelGate = new Promise((resolve) => { + releasePeerCancel = resolve; + }); + const peerResponse = new Response(new ReadableStream({ + cancel(reason) { + peerCancelReason = reason; + return peerCancelGate; + }, + })); + const started: string[] = []; + const fetchImpl = vi.fn(async (input: string | URL) => { + const url = String(input); + started.push(url); + if (url === inputs[0]!.sourceUrl) throw firstFailure; + if (url === inputs[1]!.sourceUrl) return peerResponse; + return new Response(new Uint8Array([12])); + }); + + const loading = loadClosedLazyAssetSources(inputs, { + fetchImpl, + maxConcurrency: 2, + }); + let settled = false; + void loading.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(peerCancelReason).toBe(firstFailure)); + expect(started).toEqual([inputs[0]!.sourceUrl, inputs[1]!.sourceUrl]); + expect(settled).toBe(false); + releasePeerCancel(); + + await expect(loading).rejects.toBe(firstFailure); + expect(started).not.toContain(inputs[2]!.sourceUrl); + }); + + it("returns the exact overflow error only after stream cancellation finishes", async () => { + let cancellationReason: unknown; + let releaseCancellation!: () => void; + const cancellationGate = new Promise((resolve) => { + releaseCancellation = resolve; + }); + const response = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([4, 5, 6, 7])); + }, + cancel(reason) { + cancellationReason = reason; + return cancellationGate; + }, + })); + const loading = loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl: async () => response, + }); + let settled = false; + void loading.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.waitFor(() => expect(cancellationReason).toBeInstanceOf(Error)); + expect((cancellationReason as Error).message).toContain("exceeds 3 bytes"); + expect(settled).toBe(false); + releaseCancellation(); + + await expect(loading).rejects.toBe(cancellationReason); + }); + + it("preserves an exact stream read failure", async () => { + const streamFailure = new Error("transport stream failed"); + const response = new Response(new ReadableStream({ + start(controller) { + controller.error(streamFailure); + }, + })); + + await expect(loadClosedLazyAssetSources([sourceBinding()], { + fetchImpl: async () => response, + })).rejects.toBe(streamFailure); + }); + + it("limits concurrent fetches while preserving source order", async () => { + const inputs = [0, 1, 2].map((index) => { + const bytes = new Uint8Array([10 + index]); + return { + bytes, + binding: sourceBinding( + `https://example.test/releases/${index}.zip`, + `https://assets.example.test/releases/${index}.zip`, + bytes, + ), + }; + }); + const started: string[] = []; + const pending = new Map void>(); + let active = 0; + let peakActive = 0; + const fetchImpl = vi.fn((input: string | URL) => { + const url = String(input); + started.push(url); + active += 1; + peakActive = Math.max(peakActive, active); + return new Promise((resolve) => { + pending.set(url, (response) => { + active -= 1; + resolve(response); + }); + }); + }); + + const loading = loadClosedLazyAssetSources( + inputs.map(({ binding }) => binding), + { fetchImpl, maxConcurrency: 2 }, + ); + expect(started).toEqual([ + inputs[0]!.binding.sourceUrl, + inputs[1]!.binding.sourceUrl, + ]); + + pending.get(inputs[1]!.binding.sourceUrl)!(new Response(inputs[1]!.bytes)); + await vi.waitFor(() => { + expect(started).toEqual([ + inputs[0]!.binding.sourceUrl, + inputs[1]!.binding.sourceUrl, + inputs[2]!.binding.sourceUrl, + ]); + }); + pending.get(inputs[2]!.binding.sourceUrl)!(new Response(inputs[2]!.bytes)); + pending.get(inputs[0]!.binding.sourceUrl)!(new Response(inputs[0]!.bytes)); + + const loaded = await loading; + expect(peakActive).toBe(2); + expect(loaded.map(({ url }) => url)).toEqual( + inputs.map(({ binding }) => binding.url), + ); + expect(loaded.map(({ bytes }) => Array.from(bytes))).toEqual([[10], [11], [12]]); + }); + + it("bounds transport source count and declared total bytes before fetching", async () => { + const fetchImpl = vi.fn(async () => new Response(new Uint8Array([1]))); + const tooMany = Array.from( + { length: MAX_CLOSED_LAZY_ASSETS + 1 }, + (_, index) => ({ + ...sourceBinding( + `https://example.test/releases/${index}.zip`, + `/assets/${index}.zip`, + new Uint8Array([index & 0xff]), + ), + }), + ); + await expect(loadClosedLazyAssetSources(tooMany, { fetchImpl })).rejects.toThrow( + `exceed ${MAX_CLOSED_LAZY_ASSETS} bindings`, + ); + + const oversized = [ + { + ...sourceBinding(URL_A, "/assets/a.zip", new Uint8Array([1])), + size: MAX_CLOSED_LAZY_ASSET_BYTES, + }, + { + ...sourceBinding(URL_B, "/assets/b.zip", new Uint8Array([2])), + size: 1, + }, + ]; + await expect(loadClosedLazyAssetSources(oversized, { fetchImpl })).rejects.toThrow( + `exceed ${MAX_CLOSED_LAZY_ASSET_BYTES} bytes`, + ); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + it("serves exact snapshotted bytes and content length", async () => { const source = new Uint8Array([1, 2, 3]); const fetcher = createClosedLazyAssetFetcher([asset(URL_A, source)]); @@ -69,6 +605,11 @@ describe("closed lazy assets", () => { [asset("https://example.test/a#fragment")], "canonical credential-free HTTPS", ], + [ + "empty-fragment URL", + [asset("https://example.test/a#")], + "canonical credential-free HTTPS", + ], [ "noncanonical URL", [asset("https://EXAMPLE.test/a")], @@ -88,6 +629,20 @@ describe("closed lazy assets", () => { expect(() => snapshotClosedLazyAssets(assets)).toThrow(message); }); + it("rejects sparse assets and coercible digests before copying bytes", () => { + const sparse = new Array(2); + sparse[0] = asset(); + expect(() => snapshotClosedLazyAssets(sparse)).toThrow("asset 1 is missing"); + + const coercibleSha = { + toString: () => asset().sha256, + } as unknown as string; + expect(() => snapshotClosedLazyAssets([{ + ...asset(), + sha256: coercibleSha, + }])).toThrow("invalid fields"); + }); + it("returns defensive byte copies", () => { const source = new Uint8Array([4, 5, 6]); const snapshot = snapshotClosedLazyAssets([asset(URL_A, source)]); diff --git a/host/test/deferred-worker-start.test.ts b/host/test/deferred-worker-start.test.ts index 1b0d3bbf91..044d3ab384 100644 --- a/host/test/deferred-worker-start.test.ts +++ b/host/test/deferred-worker-start.test.ts @@ -342,6 +342,7 @@ function createWorkerHarness( 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] }]]), diff --git a/host/test/dlopen-host-imports.test.ts b/host/test/dlopen-host-imports.test.ts index 9d5604c423..4d640bd780 100644 --- a/host/test/dlopen-host-imports.test.ts +++ b/host/test/dlopen-host-imports.test.ts @@ -99,6 +99,25 @@ describe("dlopen host import pointer widths", () => { expect(dlopen(pointer(0), 0, pointer(0), 0)).toBe(1); }); + it("requires the BigInt representation supplied by memory64 imports", () => { + const { pointer, dlopen } = createImports(8); + + expect(() => dlopen(0, 1, pointer(0), 0)) + .toThrow("__wasm_dlopen bytes: expected an exact memory64 pointer"); + expect(dlopen(pointer(0), 0, pointer(0), 0)).toBe(1); + }); + + it("unsigned-normalizes a signed memory32 high-bit pointer", () => { + const { pointer, dlopen } = createImports(4); + + expect(() => dlopen(-1, 1, pointer(0), 0)) + .toThrow( + "__wasm_dlopen bytes: memory range [4294967295, 4294967296) " + + "exceeds 65536 bytes", + ); + expect(dlopen(pointer(0), 0, pointer(0), 0)).toBe(1); + }); + it("rejects a memory32 range that crosses the end of linear memory", () => { const { memory, pointer, dlopen } = createImports(4); diff --git a/host/test/dri-cube-pyramid.test.ts b/host/test/dri-cube-pyramid.test.ts index d05f8e0f51..fdd6a5c966 100644 --- a/host/test/dri-cube-pyramid.test.ts +++ b/host/test/dri-cube-pyramid.test.ts @@ -12,6 +12,7 @@ import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; import { NodeWorkerAdapter } from "../src/worker-adapter"; import { detectPtrWidth, extractHeapBase } from "../src/constants"; import { tryResolveBinary } from "../src/binary-resolver"; +import { readForkContinuationAnchor } from "../src/fork-continuation"; import { GlMuxer } from "../src/webgl/muxer"; import type { GlBinding } from "../src/webgl/registry"; import type { @@ -87,7 +88,7 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( const workerAdapter = new NodeWorkerAdapter(); const workers = new Map>(); - const parentPid = 100; + let parentPid = 0; let stdout = ""; let stderr = ""; let resolveExit: (s: number) => void; @@ -118,20 +119,23 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( new Uint8Array(childMemory.buffer, childChannelOffset, CH_TOTAL_SIZE).fill(0); kernel.registerProcess(childPid, childMemory, [childChannelOffset], { - skipKernelCreate: true, 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 = childChannelOffset - FORK_SAVE_BUFFER_SIZE; + const forkBufAddr = readForkContinuationAnchor( + parentMemory, + childChannelOffset - FORK_SAVE_BUFFER_SIZE, + ptrWidth, + ); const childInit: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: parentForkPid, programBytes, memory: childMemory, channelOffset: childChannelOffset, @@ -181,13 +185,14 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( }); await kernel.init(kernelWasmBytes); + parentPid = 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(parentPid, memory, [channelOffset], { ptrWidth, stdio: CAPTURED_STDIO }); + kernel.registerProcess(parentPid, memory, [channelOffset], { ptrWidth }); const heapBase = extractHeapBase(programBytes); if (heapBase !== null) kernel.setBrkBase(parentPid, heapBase); @@ -198,7 +203,6 @@ describe.skipIf(!existsSync(programBinary) || !existsSync(kernelBinary))( const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: parentPid, - ppid: 0, programBytes, memory, channelOffset, diff --git a/host/test/dri-smoke.test.ts b/host/test/dri-smoke.test.ts index 75581df44f..2dc8eda021 100644 --- a/host/test/dri-smoke.test.ts +++ b/host/test/dri-smoke.test.ts @@ -59,7 +59,7 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { const workerAdapter = new NodeWorkerAdapter(); const workers = new Map>(); - const pid = 100; + let pid = 0; let stdout = ""; let stderr = ""; @@ -105,18 +105,18 @@ describe.skipIf(!existsSync(driSmokeBinary))("dri-smoke integration", () => { }); 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, stdio: CAPTURED_STDIO }); + kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes, memory, channelOffset, diff --git a/host/test/dylink.test.ts b/host/test/dylink.test.ts index 2ee4ec03cf..4de05a8e5a 100644 --- a/host/test/dylink.test.ts +++ b/host/test/dylink.test.ts @@ -24,7 +24,7 @@ import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; +import { LINKED_FRAME_FORMAT_SECTION } from "../src/fork-continuation"; function hasCompiler(compiler = "wasm32posix-cc"): boolean { try { @@ -84,7 +84,14 @@ function buildDylinkWat( mkdirSync(dir, { recursive: true }); const watPath = join(dir, `${name}.wat`); const wasmPath = join(dir, `${name}.wasm`); - writeFileSync(watPath, wat); + const 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; + writeFileSync(watPath, linkedWat); execFileSync("wat2wasm", ["--enable-threads", ...wat2wasmFlags, watPath, "-o", wasmPath], { stdio: "pipe", }); @@ -113,13 +120,26 @@ function buildDylinkWat( out.set(module.subarray(0, 8), 0); out.set(section, 8); out.set(module.subarray(8), 8 + section.length); - return forkCapabilities === undefined - ? out - : appendCustomSection( + if (forkCapabilities === undefined) return out; + let marked = appendCustomSection( out, FORK_CAPABILITIES_SECTION, new Uint8Array([FORK_CAPABILITIES_VERSION, forkCapabilities]), ); + if ((forkCapabilities & FORK_CAP_SIDE_ENTRY) !== 0) { + marked = appendCustomSection( + marked, + LINKED_FRAME_FORMAT_SECTION, + new Uint8Array([ + 0x4b, 0x4c, 0x43, 0x46, + 1, 0, 24, 0, 4, 8, 3, 0, + 32, 0, 0, 0, + 24, 0, 0, 0, + 8, 0, 0, 0, + ]), + ); + } + return marked; } describe.skipIf(typeof WebAssembly.Tag !== "function")("longjmp tag identity", () => { @@ -616,11 +636,22 @@ 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: new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }), + memory, 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(), @@ -640,6 +671,7 @@ describe("side-module fork contract", () => { setActiveFork: () => {}, clearActiveFork: () => {}, invokeMainFork: () => 0, + beginMainAbort: () => {}, }; expect(() => loadSharedLibrarySync("libbadfork.so", wasmBytes, options)) @@ -655,6 +687,8 @@ describe("side-module fork contract", () => { (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"); @@ -663,6 +697,7 @@ describe("side-module fork contract", () => { setActiveFork: () => {}, clearActiveFork: () => {}, invokeMainFork: () => 0, + beginMainAbort: () => {}, }; const load = () => loadSharedLibrarySync("liblegacyfork.so", wasmBytes, options); @@ -705,6 +740,8 @@ describe("side-module fork contract", () => { (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); @@ -713,6 +750,7 @@ describe("side-module fork contract", () => { setActiveFork: () => {}, clearActiveFork: () => {}, invokeMainFork: () => 0, + beginMainAbort: () => {}, }; expect(() => loadSharedLibrarySync("libwrongmarker.so", wasmBytes, options)) @@ -755,6 +793,8 @@ describe("side-module fork contract", () => { (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); @@ -766,6 +806,34 @@ describe("side-module fork contract", () => { .toThrow(/main module lacks the versioned dlopen-main fork capability; rebuild it/); }); + it("rejects a fork-capable side module without process-mapping storage", () => { + 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-without-continuation-mapping", FORK_CAP_SIDE_ENTRY); + const options = createSideForkLoadOptions(); + options.allocateContinuation = undefined; + options.deallocateContinuation = undefined; + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + beginMainAbort: () => {}, + }; + + expect(() => loadSharedLibrarySync("libunmappedfork.so", wasmBytes, options)) + .toThrow(/require process-mapping allocation and cleanup/); + }); + it("drives repeated instrumented side-module forks through exact states", () => { const wasmBytes = buildDylinkWat(` (module @@ -789,6 +857,14 @@ describe("side-module fork contract", () => { (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) @@ -809,6 +885,7 @@ describe("side-module fork contract", () => { active = null; }, invokeMainFork: () => forkResult, + beginMainAbort: () => {}, }; const lib = loadSharedLibrarySync("libsidefork.so", wasmBytes, options); @@ -817,12 +894,21 @@ describe("side-module fork contract", () => { 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?.forkBufSize).toBe(FORK_SAVE_BUFFER_SIZE); + expect(active?.continuation).toBe(lib.forkContinuation); unwindEnd(); forkResult = expectedForkResult; @@ -839,6 +925,7 @@ describe("side-module fork contract", () => { setActiveFork: () => {}, clearActiveFork: () => {}, invokeMainFork: () => 0, + beginMainAbort: () => {}, }; const provider = buildDylinkWat(` (module @@ -858,6 +945,9 @@ describe("side-module fork contract", () => { (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); diff --git a/host/test/exec-brk-base.test.ts b/host/test/exec-brk-base.test.ts index 8af0119e29..bf5b8dc3da 100644 --- a/host/test/exec-brk-base.test.ts +++ b/host/test/exec-brk-base.test.ts @@ -185,7 +185,7 @@ describe.skipIf(!compatible)("brk-base regression: mariadbd bootstrap via dash-e }, 30_000); // Bug case 2: dash forks /bin/sh which forks mariadbd. The dinit-shape - // chain (PID 1 → fork sh → fork mariadbd) — this is the original + // chain (first user process → fork sh → fork mariadbd) — this is the original // mariadbd-bootstrap-hangs-in-wasm-port-during-kernel reproducer. it("dash → fork /bin/sh → fork mariadbd: boots InnoDB", async () => { const r = await runDashCommand( diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts index f79de7f816..ac59588136 100644 --- a/host/test/exec-state-tracking.test.ts +++ b/host/test/exec-state-tracking.test.ts @@ -7,6 +7,7 @@ import { ABI_SYSCALLS, CH_ARG_SIZE, CH_ARGS, + CH_DATA, CH_DATA_SIZE, CH_RETURN, HOST_INTERCEPTED_SYSCALLS, @@ -102,13 +103,17 @@ describe("exec host-state transition", () => { ]), }); const notify = vi.spyOn(Atomics, "notify"); + const pendingAttachment = issueThreadAttachment(worker, mainChannel, 11); 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.addChannel(7, 512)).toThrow(/replacing its image/); + expect(() => worker.attachThreadChannel( + pendingAttachment, + 512, + )).toThrow(/replacing its image/); expect(worker.processes.has(8)).toBe(true); expect(worker.activeChannels).toEqual([otherChannel]); expect(worker.waitingForChild).toEqual([ @@ -148,11 +153,18 @@ describe("exec host-state transition", () => { it("rejects an old-memory clone after replacement registration", () => { const oldMemory = new WebAssembly.Memory({ initial: 1 }); const newMemory = new WebAssembly.Memory({ initial: 1 }); + const oldChannel = createChannel(7, oldMemory, 0); const worker = createWorker({ - processes: new Map([[7, { channels: [], memory: newMemory }]]), + processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), + activeChannels: [oldChannel], }); + const pendingAttachment = issueThreadAttachment(worker, oldChannel, 11, 1, 2); + worker.processes.set(7, { channels: [], memory: newMemory }); - expect(() => worker.addChannel(7, 512, 11, 1, 2, oldMemory)) + expect(() => worker.attachThreadChannel( + pendingAttachment, + 512, + )) .toThrow(/changed memory generation/); expect(worker.processes.get(7).channels).toEqual([]); }); @@ -366,6 +378,7 @@ describe("exec host-state transition", () => { channel, [0, 0, 0, 40, 0, 0], 7, + 7, 0, new Uint8Array(40), 40, @@ -425,6 +438,7 @@ describe("exec host-state transition", () => { channel, [0, 0, 0, 40, 0, 0], 7, + 7, 0, new Uint8Array(40), 40, @@ -797,10 +811,9 @@ describe("exec host-state transition", () => { toKernelPtr: (value: number) => value, kernelInstance: { exports: { - kernel_set_current_pid: vi.fn(), kernel_ipc_shm_read_chunk: readChunk, kernel_ipc_shm_write_chunk: writeChunk, - kernel_ipc_shmdt: detach, + kernel_ipc_shmdt_for_process: detach, }, }, }); @@ -811,7 +824,7 @@ describe("exec host-state transition", () => { expect(worker.shmMappings.has(7)).toBe(true); expect(worker.finalizeAddressSpaceForExec(7)).toBe(0); - expect(detach).toHaveBeenCalledWith(3); + expect(detach).toHaveBeenCalledWith(7, 3); expect(worker.shmMappings.has(7)).toBe(false); }); @@ -832,7 +845,6 @@ describe("exec host-state transition", () => { ambientPid = worker.currentHandlePid; return 0; }, - kernel_exec_setup: () => 0, kernel_fd_is_open: (_pid: number, fd: number) => openFds.has(fd) ? 1 : 0, }, }, @@ -858,6 +870,28 @@ 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({ + 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) }, + }, + }); + + 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", + ); + }); + it("remaps a TCP listener mirror to its surviving fd alias", () => { let committed = false; const close = vi.fn(); @@ -875,7 +909,6 @@ describe("exec host-state transition", () => { committed = true; return 0; }, - kernel_exec_setup: () => 0, kernel_fd_is_open: (_pid: number, fd: number) => committed && fd === 2048 ? 1 : 0, kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => { if (fd === 2048) return 41; @@ -909,7 +942,6 @@ describe("exec host-state transition", () => { kernelInstance: { exports: { kernel_exec_setup_for_thread: () => 0, - kernel_exec_setup: () => 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, @@ -1119,6 +1151,46 @@ function createWorker(overrides: Record): any { return worker; } +function issueThreadAttachment( + worker: CentralizedKernelWorker, + channel: ReturnType, + tid: number, + fnPtr = 1, + argPtr = 2, +) { + const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const kernelView = new DataView(kernelMemory.buffer); + 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) }, + 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; + }); + (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 resolvedProgram() { const programBytes = Uint8Array.from([ 0x00, 0x61, 0x73, 0x6d, diff --git a/host/test/file-shared-memory.test.ts b/host/test/file-shared-memory.test.ts index cdc6a3467e..c622bb42db 100644 --- a/host/test/file-shared-memory.test.ts +++ b/host/test/file-shared-memory.test.ts @@ -124,6 +124,7 @@ function createFileHarness() { io, kernel, processes, + channelTids: new Map(), sharedMappings: new Map(), anonymousSharedBackings: new Map(), sharedMmapBackings: new Map(), @@ -823,7 +824,6 @@ describe("file/POSIX MAP_SHARED page cache", () => { Object.assign(h.kw as any, { callbacks: { onFork: vi.fn() }, kernelInstance: { exports: { kernel_fork_process: kernelForkProcess } }, - nextChildPid: 100, }); expect(() => (h.kw as any).handleFork(channel, [])).toThrow( diff --git a/host/test/fork-abort-unwind.test.ts b/host/test/fork-abort-unwind.test.ts new file mode 100644 index 0000000000..99a9f86d6e --- /dev/null +++ b/host/test/fork-abort-unwind.test.ts @@ -0,0 +1,148 @@ +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 { describe, expect, it } from "vitest"; +import { + ContinuationAllocationError, + LinkedForkContinuation, + readLinkedFrameFormat, +} from "../src/fork-continuation"; + +describe("instrumented ABORT_UNWINDING", () => { + it("reconstructs committed inner frames and permits a later successful fork", () => { + const dir = mkdtempSync(join(tmpdir(), "kandelo-fork-abort-")); + try { + const rawPath = join(dir, "abort.wasm"); + const instrumentedPath = join(dir, "abort.instrumented.wasm"); + // The outer function has a roughly 72 KiB scalar payload. Its caller + // (run) first commits a small frame into the root chunk, then outer's + // reservation requires a second mapping where failure is injected. + const outerLocalCount = 9_000; + const outerLocalInit = Array.from( + { length: outerLocalCount }, + (_, index) => `i64.const ${index} local.set ${index}`, + ).join("\n"); + const wat = `(module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "memory" (memory 8)) + (func $leaf (result i32) call $fork) + (func $outer (result i32) (local ${"i64 ".repeat(outerLocalCount)}) + ${outerLocalInit} + call $leaf) + (func (export "run") (result i32) (local $saved i32) + i32.const 7 + local.set $saved + call $outer + local.get $saved + i32.add))`; + const watPath = join(dir, "abort.wat"); + writeFileSync(watPath, wat); + execFileSync("wat2wasm", [watPath, "-o", rawPath]); + execFileSync(fileURLToPath(new URL( + "../../tools/bin/wasm-fork-instrument", + import.meta.url, + )), [ + rawPath, + "-o", + instrumentedPath, + ]); + + const bytes = readFileSync(instrumentedPath); + 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; + const released: Array<{ addr: number; size: number }> = []; + const continuation = new LinkedForkContinuation( + memory, + readLinkedFrameFormat(module), + (size) => { + if (failGrowth && nextAddress !== 65_536) { + throw new ContinuationAllocationError(12, size, "injected ENOMEM"); + } + const addr = nextAddress; + nextAddress += size; + return addr; + }, + (addr, size) => released.push({ addr, size }), + "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), + }, + 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 forkResult; + } + if (state === 3) { + const errno = continuation.abortErrno(); + (instance.exports.wpk_fork_abort_end as () => void)(); + continuation.finishAbortReplayAndRelease(); + return -errno; + } + moduleBuffer = Number(continuation.beginUnwind()); + (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)(moduleBuffer); + return 0; + }, + }, + }; + instance = new WebAssembly.Instance(module, imports); + const run = instance.exports.run as () => number; + const state = instance.exports.wpk_fork_state as () => number; + + expect(run()).toBe(-5); // raw -ENOMEM plus the preserved caller local 7 + expect(state()).toBe(0); + expect(continuation.hasActiveContinuation()).toBe(false); + expect(released).toEqual([{ addr: 65_536, size: 65_536 }]); + + // Reuse the released root and allow the large second chunk. A negative + // 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 + expect(state()).toBe(1); + (instance.exports.wpk_fork_unwind_end as () => void)(); + continuation.finishUnwind(); + forkResult = -11; + continuation.beginReplay(); + (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)(moduleBuffer); + 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(); + forkResult = 123; + continuation.beginReplay(); + (instance.exports.wpk_fork_rewind_begin as (addr: number) => void)(moduleBuffer); + expect(run()).toBe(130); + expect(state()).toBe(0); + expect(continuation.hasActiveContinuation()).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/fork-continuation.test.ts b/host/test/fork-continuation.test.ts new file mode 100644 index 0000000000..c0d54765ae --- /dev/null +++ b/host/test/fork-continuation.test.ts @@ -0,0 +1,914 @@ +import { describe, expect, it } from "vitest"; +import { + ContinuationAllocationError, + invokeForkContinuationBegin, + LinkedForkContinuation, + readLinkedFrameFormat, + type LinkedFrameFormatDescriptor, +} from "../src/fork-continuation"; +import { + WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, + 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, +} from "../src/generated/abi"; + +function addressBeginExport(pointerType: 0x7f | 0x7e): WebAssembly.ExportValue { + const module = new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x01, 0x05, 0x01, 0x60, 0x01, pointerType, 0x00, + 0x03, 0x02, 0x01, 0x00, + 0x07, 0x09, 0x01, 0x05, 0x62, 0x65, 0x67, 0x69, 0x6e, 0x00, 0x00, + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, + ])); + return new WebAssembly.Instance(module).exports.begin!; +} + +describe("invokeForkContinuationBegin", () => { + it("calls wasm32 i32 and wasm64 i64 exports with their native JS types", () => { + const wasm32Begin = addressBeginExport(0x7f); + const wasm64Begin = addressBeginExport(0x7e); + + expect(() => invokeForkContinuationBegin(wasm32Begin, 4096, 4, "wasm32")) + .not.toThrow(); + expect(() => invokeForkContinuationBegin(wasm64Begin, 4096, 8, "wasm64")) + .not.toThrow(); + + // Prove this reaches V8's real i64 boundary rather than a mock function. + expect(() => (wasm64Begin as (value: number) => void)(4096)).toThrow(TypeError); + }); + + it("rejects missing exports and invalid continuation addresses", () => { + const wasm32Begin = addressBeginExport(0x7f); + expect(() => invokeForkContinuationBegin(undefined, 4096, 4, "missing")) + .toThrow("continuation begin export is not callable"); + expect(() => invokeForkContinuationBegin(wasm32Begin, 0, 4, "zero")) + .toThrow("invalid continuation address"); + expect(() => invokeForkContinuationBegin( + wasm32Begin, + Number.MAX_SAFE_INTEGER + 1, + 4, + "imprecise", + )).toThrow("invalid continuation address"); + }); +}); + +const PAGE_SIZE = 65_536; + +function formatFor(ptrWidth: 4 | 8): LinkedFrameFormatDescriptor { + const pointerFormat = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS.find( + ({ bytes }) => bytes === ptrWidth, + )!; + return { + version: WPK_FORK_LINKED_FRAME_FORMAT_VERSION, + ptrWidth, + alignment: WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT, + flags: WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, + chunkHeaderSize: pointerFormat.chunkHeaderSize, + nodeHeaderSize: pointerFormat.nodeHeaderSize, + fixedPrefixSize: 128, + }; +} + +const FORMAT = formatFor(4); + +function uleb128(value: number): number[] { + const out: number[] = []; + do { + let byte = value & 0x7f; + value >>>= 7; + if (value !== 0) byte |= 0x80; + out.push(byte); + } while (value !== 0); + return out; +} + +function moduleWithLinkedDescriptor(data: number[]): WebAssembly.Module { + const name = [...new TextEncoder().encode(WPK_FORK_LINKED_FRAME_FORMAT_SECTION)]; + const payload = [...uleb128(name.length), ...name, ...data]; + return new WebAssembly.Module(new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, + 0x00, ...uleb128(payload.length), ...payload, + ])); +} + +function linkedDescriptorBytes(pointerWidth: 4 | 8 = 4): number[] { + const pointerFormat = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS.find( + ({ bytes }) => bytes === pointerWidth, + )!; + const bytes = new Uint8Array(WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE); + const view = new DataView(bytes.buffer); + bytes.set(WPK_FORK_LINKED_FRAME_FORMAT_MAGIC); + view.setUint16(4, WPK_FORK_LINKED_FRAME_FORMAT_VERSION, true); + view.setUint16(6, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, true); + view.setUint8(8, pointerWidth); + view.setUint8(9, WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT); + view.setUint16(10, WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, true); + view.setUint32(12, pointerFormat.chunkHeaderSize, true); + view.setUint32(16, pointerFormat.nodeHeaderSize, true); + view.setUint32(20, 128, true); + return [...bytes]; +} + +describe("readLinkedFrameFormat", () => { + it("accepts exact generated wasm32 and wasm64 descriptors", () => { + expect(readLinkedFrameFormat(moduleWithLinkedDescriptor(linkedDescriptorBytes()))) + .toEqual(FORMAT); + const wasm64Format = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS.find(({ bytes }) => bytes === 8)!; + expect(readLinkedFrameFormat(moduleWithLinkedDescriptor(linkedDescriptorBytes(8)))) + .toEqual({ + ...FORMAT, + ptrWidth: 8, + chunkHeaderSize: wasm64Format.chunkHeaderSize, + nodeHeaderSize: wasm64Format.nodeHeaderSize, + }); + }); + + it("rejects unknown flags before instantiation", () => { + const bytes = linkedDescriptorBytes(); + bytes[10] = 7; + expect(() => readLinkedFrameFormat(moduleWithLinkedDescriptor(bytes))) + .toThrow("unsupported linked continuation flags"); + }); +}); + +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 guestPointer(value: number, ptrWidth: 4 | 8): number | bigint { + return ptrWidth === 8 ? BigInt(value) : value; +} + +function writePointer( + memory: WebAssembly.Memory, + ptrWidth: 4 | 8, + addr: number, + value: number, +): void { + const view = new DataView(memory.buffer); + if (ptrWidth === 8) view.setBigUint64(addr, BigInt(value), true); + else view.setUint32(addr, value, true); +} + +describe("LinkedForkContinuation", () => { + it("reserves transactionally and replays frames outer-to-inner", () => { + const parentMemory = new WebAssembly.Memory({ initial: 8 }); + const parentAllocator = allocator(parentMemory); + const parent = new LinkedForkContinuation( + parentMemory, + FORMAT, + parentAllocator.allocate, + parentAllocator.deallocate, + "parent", + ); + const moduleBuffer = Number(parent.beginUnwind()); + const inner = Number(parent.reserveFrame(16)); + new Uint8Array(parentMemory.buffer, inner, 16).fill(0x11); + parent.commitFrame(inner); + const outer = Number(parent.reserveFrame(24)); + new Uint8Array(parentMemory.buffer, outer, 24).fill(0x22); + parent.commitFrame(outer); + parent.finishUnwind(); + + const childMemory = cloneMemory(parentMemory); + const childAllocator = allocator(childMemory); + const child = new LinkedForkContinuation( + childMemory, + FORMAT, + childAllocator.allocate, + childAllocator.deallocate, + "child", + ); + child.attachForReplay(moduleBuffer); + const replayOuter = Number(child.nextFrame(24)); + const replayInner = Number(child.nextFrame(16)); + expect(new Uint8Array(childMemory.buffer, replayOuter, 24)).toEqual( + new Uint8Array(24).fill(0x22), + ); + expect(new Uint8Array(childMemory.buffer, replayInner, 16)).toEqual( + new Uint8Array(16).fill(0x11), + ); + child.finishReplayAndRelease(); + expect(childAllocator.releases).toEqual(parentAllocator.allocations); + }); + + it.each([4, 8] as const)( + "replays a deep multi-chunk wasm%s chain in exact reverse frame order", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const parentMemory = new WebAssembly.Memory({ initial: 4 }); + const parentAllocator = allocator(parentMemory); + const parent = new LinkedForkContinuation( + parentMemory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `deep-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + const frames = Array.from({ length: 257 }, (_, index) => ({ + size: 4_096 + index % 5 * format.alignment, + byte: (index * 37) & 0xff, + })); + for (const frame of frames) { + const payload = parent.reserveFrame(guestPointer(frame.size, ptrWidth)); + new Uint8Array(parentMemory.buffer, Number(payload), frame.size).fill(frame.byte); + parent.commitFrame(payload); + } + parent.finishUnwind(); + expect(parentAllocator.allocations.length).toBeGreaterThan(10); + + // Clone before the parent consumes its nodes, exactly as fork gives the + // child an independent copy of continuation memory. + const childMemory = cloneMemory(parentMemory); + parent.beginReplay(); + for (const frame of [...frames].reverse()) { + const payload = parent.nextFrame(guestPointer(frame.size, ptrWidth)); + const bytes = new Uint8Array(parentMemory.buffer, Number(payload), frame.size); + expect(bytes[0]).toBe(frame.byte); + expect(bytes[bytes.length - 1]).toBe(frame.byte); + } + parent.finishReplayAndRelease(); + expect(parentAllocator.releases).toEqual( + [...parentAllocator.allocations].reverse(), + ); + + const childAllocator = allocator(childMemory); + const child = new LinkedForkContinuation( + childMemory, + format, + childAllocator.allocate, + childAllocator.deallocate, + `deep-child-wasm${ptrWidth * 8}`, + ); + child.attachForReplay(moduleBuffer); + for (const frame of [...frames].reverse()) { + const payload = child.nextFrame(guestPointer(frame.size, ptrWidth)); + const bytes = new Uint8Array(childMemory.buffer, Number(payload), frame.size); + expect(bytes[0]).toBe(frame.byte); + expect(bytes[bytes.length - 1]).toBe(frame.byte); + } + child.finishReplayAndRelease(); + expect(childAllocator.releases).toEqual( + [...parentAllocator.allocations].reverse(), + ); + }, + ); + + it.each([4, 8] as const)( + "rejects a two-node wasm%s chunk cycle at its first repeated address", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const parentMemory = new WebAssembly.Memory({ initial: 5 }); + const parentAllocator = allocator(parentMemory); + const parent = new LinkedForkContinuation( + parentMemory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `cycle-parent-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + const payload = parent.reserveFrame(guestPointer(PAGE_SIZE, ptrWidth)); + parent.commitFrame(payload); + parent.finishUnwind(); + expect(parentAllocator.allocations).toHaveLength(2); + const [root, second] = parentAllocator.allocations; + writePointer( + parentMemory, + ptrWidth, + second!.addr + 8 + 2 * ptrWidth, + root!.addr, + ); + + const childMemory = cloneMemory(parentMemory); + const child = new LinkedForkContinuation( + childMemory, + format, + () => { throw new Error("replay must not allocate"); }, + () => { throw new Error("failed attachment must not release"); }, + `cycle-child-wasm${ptrWidth * 8}`, + ); + expect(() => child.attachForReplay(moduleBuffer)).toThrow( + "linked continuation chunk cycle", + ); + expect(child.hasActiveContinuation()).toBe(false); + }, + ); + + it.each([4, 8] as const)( + "rejects distinct wasm%s chunk headers whose declared ranges overlap", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const parentMemory = new WebAssembly.Memory({ initial: 8 }); + const parentAllocator = allocator(parentMemory); + const parent = new LinkedForkContinuation( + parentMemory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `overlap-parent-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + const payload = parent.reserveFrame(guestPointer(PAGE_SIZE, ptrWidth)); + parent.commitFrame(payload); + parent.finishUnwind(); + expect(parentAllocator.allocations).toHaveLength(2); + const root = parentAllocator.allocations[0]!; + const second = parentAllocator.allocations[1]!; + expect(second.size).toBe(2 * PAGE_SIZE); + + const overlapping = second.addr + PAGE_SIZE; + const copiedHeader = new Uint8Array( + parentMemory.buffer, + second.addr, + format.chunkHeaderSize, + ).slice(); + new Uint8Array( + parentMemory.buffer, + overlapping, + format.chunkHeaderSize, + ).set(copiedHeader); + writePointer( + parentMemory, + ptrWidth, + second.addr + 8 + 2 * ptrWidth, + overlapping, + ); + writePointer( + parentMemory, + ptrWidth, + overlapping + 8 + ptrWidth, + second.addr, + ); + writePointer(parentMemory, ptrWidth, overlapping + 8 + 2 * ptrWidth, 0); + writePointer( + parentMemory, + ptrWidth, + overlapping + 8 + 3 * ptrWidth, + PAGE_SIZE, + ); + writePointer( + parentMemory, + ptrWidth, + overlapping + 8 + 4 * ptrWidth, + format.chunkHeaderSize + format.nodeHeaderSize, + ); + const actualNode = Number(payload) - format.nodeHeaderSize; + const forgedNode = overlapping + format.chunkHeaderSize; + const copiedNodeHeader = new Uint8Array( + parentMemory.buffer, + actualNode, + format.nodeHeaderSize, + ).slice(); + new Uint8Array( + parentMemory.buffer, + forgedNode, + format.nodeHeaderSize, + ).set(copiedNodeHeader); + writePointer(parentMemory, ptrWidth, forgedNode + 8, actualNode); + writePointer(parentMemory, ptrWidth, forgedNode + 8 + ptrWidth, 0); + writePointer( + parentMemory, + ptrWidth, + forgedNode + 8 + 2 * ptrWidth, + format.nodeHeaderSize, + ); + writePointer( + parentMemory, + ptrWidth, + root.addr + 8 + 5 * ptrWidth, + forgedNode, + ); + + const releases: Array<{ addr: number; size: number }> = []; + const child = new LinkedForkContinuation( + cloneMemory(parentMemory), + format, + () => { throw new Error("replay must not allocate"); }, + (addr, size) => releases.push({ addr, size }), + `overlap-child-wasm${ptrWidth * 8}`, + ); + expect(() => child.attachForReplay(moduleBuffer)).toThrow( + "linked continuation chunk ranges overlap", + ); + expect(child.hasActiveContinuation()).toBe(false); + expect(releases).toEqual([]); + }, + ); + + it.each([4, 8] as const)( + "rejects a zero wasm%s replay tail when committed frame bytes exist", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const parentMemory = new WebAssembly.Memory({ initial: 4 }); + const parentAllocator = allocator(parentMemory); + const parent = new LinkedForkContinuation( + parentMemory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `missing-tail-parent-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + const payload = parent.reserveFrame(guestPointer(32, ptrWidth)); + parent.commitFrame(payload); + parent.finishUnwind(); + const root = parentAllocator.allocations[0]!; + writePointer(parentMemory, ptrWidth, root.addr + 8 + 5 * ptrWidth, 0); + + const releases: Array<{ addr: number; size: number }> = []; + const child = new LinkedForkContinuation( + cloneMemory(parentMemory), + format, + () => { throw new Error("replay must not allocate"); }, + (addr, size) => releases.push({ addr, size }), + `missing-tail-child-wasm${ptrWidth * 8}`, + ); + expect(() => child.attachForReplay(moduleBuffer)).toThrow( + "nonempty linked continuation has no replay tail", + ); + expect(child.hasActiveContinuation()).toBe(false); + expect(releases).toEqual([]); + }, + ); + + it.each([4, 8] as const)( + "rejects a nonzero wasm%s replay tail when no frame bytes exist", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const parentMemory = new WebAssembly.Memory({ initial: 4 }); + const parentAllocator = allocator(parentMemory); + const parent = new LinkedForkContinuation( + parentMemory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `unexpected-tail-parent-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + parent.finishUnwind(); + const root = parentAllocator.allocations[0]!; + const nodeStart = root.addr + Math.ceil( + (format.chunkHeaderSize + format.fixedPrefixSize) / format.alignment, + ) * format.alignment; + writePointer( + parentMemory, + ptrWidth, + root.addr + 8 + 5 * ptrWidth, + nodeStart, + ); + + const releases: Array<{ addr: number; size: number }> = []; + const child = new LinkedForkContinuation( + cloneMemory(parentMemory), + format, + () => { throw new Error("replay must not allocate"); }, + (addr, size) => releases.push({ addr, size }), + `unexpected-tail-child-wasm${ptrWidth * 8}`, + ); + expect(() => child.attachForReplay(moduleBuffer)).toThrow( + "empty linked continuation has a replay tail", + ); + expect(child.hasActiveContinuation()).toBe(false); + expect(releases).toEqual([]); + }, + ); + + it.each([4, 8] as const)( + "revalidates the wasm%s tail before local normal and abort replay", + (ptrWidth) => { + const format = formatFor(ptrWidth); + for (const replayKind of ["normal", "abort"] as const) { + const memory = new WebAssembly.Memory({ initial: 4 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + format, + arenaAllocator.allocate, + arenaAllocator.deallocate, + `local-${replayKind}-wasm${ptrWidth * 8}`, + ); + arena.beginUnwind(); + const payload = arena.reserveFrame(guestPointer(32, ptrWidth)); + arena.commitFrame(payload); + arena.finishUnwind(); + const root = arenaAllocator.allocations[0]!; + writePointer(memory, ptrWidth, root.addr + 8 + 5 * ptrWidth, 0); + + const beginReplay = replayKind === "normal" + ? () => arena.beginReplay() + : () => arena.beginAbortReplay(12); + expect(beginReplay).toThrow( + "nonempty linked continuation has no replay tail", + ); + expect(arena.hasActiveContinuation()).toBe(true); + expect(arenaAllocator.releases).toEqual([]); + arena.cancelUnwindAndRelease(); + expect(arenaAllocator.releases).toEqual([root]); + } + }, + ); + + it.each([4, 8] as const)( + "allows a wasm%s allocation abort before any frame was committed", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const memory = new WebAssembly.Memory({ initial: 4 }); + const releases: Array<{ addr: number; size: number }> = []; + let allocations = 0; + const arena = new LinkedForkContinuation( + memory, + format, + (size) => { + if (allocations++ > 0) { + throw new ContinuationAllocationError(12, size, "synthetic ENOMEM"); + } + return PAGE_SIZE; + }, + (addr, size) => releases.push({ addr, size }), + `empty-abort-wasm${ptrWidth * 8}`, + ); + arena.beginUnwind(); + expect(arena.reserveFrame(guestPointer(PAGE_SIZE, ptrWidth))).toBe( + guestPointer(0, ptrWidth), + ); + expect(arena.abortErrno()).toBe(12); + arena.finishAbortReplayAndRelease(); + expect(releases).toEqual([{ addr: PAGE_SIZE, size: PAGE_SIZE }]); + }, + ); + + it("unsigned-normalizes a signed wasm32 high-bit frame size", () => { + const memory = new WebAssembly.Memory({ initial: 4 }); + const releases: Array<{ addr: number; size: number }> = []; + let allocations = 0; + let requestedGrowth = 0; + const arena = new LinkedForkContinuation( + memory, + FORMAT, + (size) => { + if (allocations++ > 0) { + requestedGrowth = size; + throw new ContinuationAllocationError(12, size, "synthetic ENOMEM"); + } + return PAGE_SIZE; + }, + (addr, size) => releases.push({ addr, size }), + "signed-memory32-size", + ); + arena.beginUnwind(); + expect(arena.reserveFrame(-0x8000_0000)).toBe(0); + expect(requestedGrowth).toBe(0x8001_0000); + arena.finishAbortReplayAndRelease(); + expect(releases).toEqual([{ addr: PAGE_SIZE, size: PAGE_SIZE }]); + }); + + it("requires BigInt for a wasm64 guest frame size", () => { + const memory = new WebAssembly.Memory({ initial: 4 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + formatFor(8), + arenaAllocator.allocate, + arenaAllocator.deallocate, + "strict-memory64-size", + ); + arena.beginUnwind(); + expect(() => arena.reserveFrame(32)).toThrow( + "strict-memory64-size: linked continuation: expected an exact memory64 pointer", + ); + arena.cancelUnwindAndRelease(); + }); + + it("bounds an attached chunk chain by the pages available in memory", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "memory-bound-parent", + ); + const moduleBuffer = arena.beginUnwind(); + for (let index = 0; index < 2; index += 1) { + const payload = arena.reserveFrame(40_000); + arena.commitFrame(payload); + } + arena.finishUnwind(); + expect(arenaAllocator.allocations).toHaveLength(2); + const second = arenaAllocator.allocations[1]!; + writePointer(memory, 4, second.addr + 8 + 2 * FORMAT.ptrWidth, 3 * PAGE_SIZE); + + const child = new LinkedForkContinuation( + cloneMemory(memory), + FORMAT, + () => { throw new Error("replay must not allocate"); }, + () => { throw new Error("failed attachment must not release"); }, + "memory-bound-child", + ); + expect(() => child.attachForReplay(moduleBuffer)).toThrow( + "chunk chain exceeds memory", + ); + expect(child.hasActiveContinuation()).toBe(false); + }); + + it("rejects a reverse link at the exclusive end of the prior chunk", () => { + const memory = new WebAssembly.Memory({ initial: 5 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "boundary-parent", + ); + const moduleBuffer = arena.beginUnwind(); + const payloads = []; + for (let index = 0; index < 3; index += 1) { + const payload = arena.reserveFrame(40_000); + payloads.push(Number(payload)); + arena.commitFrame(payload); + } + arena.finishUnwind(); + expect(arenaAllocator.allocations).toHaveLength(3); + const prior = arenaAllocator.allocations[1]!; + const tailNode = payloads[2]! - FORMAT.nodeHeaderSize; + writePointer(memory, 4, tailNode + 8, prior.addr + prior.size); + + const child = new LinkedForkContinuation( + cloneMemory(memory), + FORMAT, + () => { throw new Error("replay must not allocate"); }, + () => {}, + "boundary-child", + ); + child.attachForReplay(moduleBuffer); + expect(() => child.nextFrame(40_000)).toThrow( + "frame pointer is outside the expected continuation chunk", + ); + }); + + it("rejects a reverse link that skips a frame in the active chunk", () => { + const memory = new WebAssembly.Memory({ initial: 5 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "ordering-parent", + ); + const moduleBuffer = arena.beginUnwind(); + const payloads = []; + for (let index = 0; index < 4; index += 1) { + const payload = arena.reserveFrame(30_000); + payloads.push(Number(payload)); + arena.commitFrame(payload); + } + arena.finishUnwind(); + expect(arenaAllocator.allocations).toHaveLength(2); + const tailNode = payloads[3]! - FORMAT.nodeHeaderSize; + const priorChunkNode = payloads[1]! - FORMAT.nodeHeaderSize; + writePointer(memory, 4, tailNode + 8, priorChunkNode); + + const child = new LinkedForkContinuation( + cloneMemory(memory), + FORMAT, + () => { throw new Error("replay must not allocate"); }, + () => {}, + "ordering-child", + ); + child.attachForReplay(moduleBuffer); + expect(() => child.nextFrame(30_000)).toThrow( + "linked continuation replay skipped a frame", + ); + }); + + it.each([4, 8] as const)( + "rejects a same-chunk wasm%s skip before returning the current frame", + (ptrWidth) => { + const format = formatFor(ptrWidth); + const parentMemory = new WebAssembly.Memory({ initial: 5 }); + const parentAllocator = allocator(parentMemory); + const parent = new LinkedForkContinuation( + parentMemory, + format, + parentAllocator.allocate, + parentAllocator.deallocate, + `same-chunk-skip-parent-wasm${ptrWidth * 8}`, + ); + const moduleBuffer = parent.beginUnwind(); + const payloads = Array.from({ length: 3 }, (_, index) => { + const payload = parent.reserveFrame(guestPointer(32 + index * 8, ptrWidth)); + parent.commitFrame(payload); + return Number(payload); + }); + parent.finishUnwind(); + expect(parentAllocator.allocations).toHaveLength(1); + + const tailNode = payloads[2]! - format.nodeHeaderSize; + const middleNode = payloads[1]! - format.nodeHeaderSize; + const firstNode = payloads[0]! - format.nodeHeaderSize; + writePointer(parentMemory, ptrWidth, tailNode + 8, firstNode); + + const childMemory = cloneMemory(parentMemory); + const childAllocator = allocator(childMemory); + const child = new LinkedForkContinuation( + childMemory, + format, + childAllocator.allocate, + childAllocator.deallocate, + `same-chunk-skip-child-wasm${ptrWidth * 8}`, + ); + child.attachForReplay(moduleBuffer); + expect(() => child.nextFrame(guestPointer(48, ptrWidth))).toThrow( + "linked continuation replay skipped a frame", + ); + + // A rejected adjacency proof must leave the current node committed and + // retryable rather than exposing or consuming its payload. + writePointer(childMemory, ptrWidth, tailNode + 8, middleNode); + expect(Number(child.nextFrame(guestPointer(48, ptrWidth)))).toBe(payloads[2]); + expect(Number(child.nextFrame(guestPointer(40, ptrWidth)))).toBe(payloads[1]); + expect(Number(child.nextFrame(guestPointer(32, ptrWidth)))).toBe(payloads[0]); + child.finishReplayAndRelease(); + }, + ); + + it("allocates a multi-page chunk for one frame larger than a Wasm page", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "large-frame", + ); + arena.beginUnwind(); + const payload = arena.reserveFrame(65536 + 29000); + arena.commitFrame(payload); + arena.finishUnwind(); + + expect(arenaAllocator.allocations).toEqual([ + { addr: 65536, size: 65536 }, + { addr: 131072, size: 131072 }, + ]); + }); + + it("does not expose an uncommitted reservation to replay", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "uncommitted", + ); + arena.beginUnwind(); + arena.reserveFrame(32); + expect(() => arena.finishUnwind()).toThrow("uncommitted frame"); + }); + + it("rejects replay when the generated frame size disagrees", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const arenaAllocator = allocator(memory); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + arenaAllocator.allocate, + arenaAllocator.deallocate, + "size-mismatch", + ); + const moduleBuffer = arena.beginUnwind(); + const payload = arena.reserveFrame(48); + arena.commitFrame(payload); + arena.finishUnwind(); + + const childMemory = cloneMemory(memory); + const childAllocator = allocator(childMemory); + const child = new LinkedForkContinuation( + childMemory, + FORMAT, + childAllocator.allocate, + childAllocator.deallocate, + "size-mismatch-child", + ); + child.attachForReplay(moduleBuffer); + expect(() => child.nextFrame(47)).toThrow("does not match"); + }); + + it("releases a partial chain and reports committed progress on allocation failure", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const releases: Array<{ addr: number; size: number }> = []; + let allocations = 0; + const arena = new LinkedForkContinuation( + memory, + FORMAT, + (size) => { + if (allocations++ > 0) throw new Error("synthetic ENOMEM"); + return 65536; + }, + (addr, size) => releases.push({ addr, size }), + "allocation-failure", + ); + arena.beginUnwind(); + const committed = arena.reserveFrame(32); + arena.commitFrame(committed); + + expect(() => arena.reserveFrame(65536)).toThrow( + /committed_frames=1 committed_bytes=32 requested_next_frame=65536.*synthetic ENOMEM/, + ); + expect(releases).toEqual([{ addr: 65536, size: 65536 }]); + }); + + it("retains committed frames for recoverable abort replay", () => { + const memory = new WebAssembly.Memory({ initial: 8 }); + const releases: Array<{ addr: number; size: number }> = []; + let allocations = 0; + const arena = new LinkedForkContinuation( + memory, + FORMAT, + (size) => { + if (allocations++ > 0) { + throw new ContinuationAllocationError(12, size, "synthetic ENOMEM"); + } + return 65536; + }, + (addr, size) => releases.push({ addr, size }), + "recoverable-allocation-failure", + ); + arena.beginUnwind(); + const committed = arena.reserveFrame(32); + arena.commitFrame(committed); + + expect(arena.reserveFrame(65536)).toBe(0); + expect(arena.abortErrno()).toBe(12); + expect(releases).toEqual([]); + expect(Number(arena.nextFrame(32))).toBe(Number(committed)); + arena.finishAbortReplayAndRelease(); + expect(releases).toEqual([{ addr: 65536, size: 65536 }]); + }); + + it("propagates typed root allocation failure without activating unwind", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + (size) => { throw new ContinuationAllocationError(12, size, "root ENOMEM"); }, + () => { throw new Error("nothing was allocated"); }, + "recoverable-root-failure", + ); + + expect(() => arena.beginUnwind()).toThrow(ContinuationAllocationError); + expect(arena.hasActiveContinuation()).toBe(false); + }); + + it("reports an initial allocation failure before any frame write", () => { + const memory = new WebAssembly.Memory({ initial: 2 }); + const arena = new LinkedForkContinuation( + memory, + FORMAT, + () => { throw new Error("synthetic initial ENOMEM"); }, + () => { throw new Error("nothing was allocated"); }, + "initial-allocation-failure", + ); + + expect(() => arena.beginUnwind()).toThrow( + /committed_frames=0 committed_bytes=0.*synthetic initial ENOMEM/, + ); + }); +}); diff --git a/host/test/fork-instrument-coverage.test.ts b/host/test/fork-instrument-coverage.test.ts index 2fe823daaa..a1e0a24006 100644 --- a/host/test/fork-instrument-coverage.test.ts +++ b/host/test/fork-instrument-coverage.test.ts @@ -13,8 +13,9 @@ * non-nullable funcref, throw-from-outside. * K-* (4) callback-registration fork roots — sigaction, signal, * pthread_cleanup_push, qsort comparator. - * P-* (5) process / threading patterns — main thread, blocked - * cond, held mutex, popen, posix_spawn. + * 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. * * Pre-refactor expected behaviour is encoded with vitest modifiers: @@ -50,6 +51,10 @@ interface Expected { argv?: string[]; /** Optional virtual-path → wasm binary map for exec/spawn targets. */ execPrograms?: Map; + /** Opt out when the fixture does not access the filesystem. */ + useDefaultRootfs?: boolean; + /** Process memory ceiling for bounded allocation-failure fixtures. */ + maxPages?: number; } async function runFixture(relPath: string, expected: Expected) { @@ -66,6 +71,8 @@ async function runFixture(relPath: string, expected: Expected) { argv: expected.argv ?? [relPath], timeout: expected.timeout ?? 10_000, execPrograms: expected.execPrograms, + useDefaultRootfs: expected.useDefaultRootfs, + maxPages: expected.maxPages, }); expect( result.exitCode, @@ -470,6 +477,40 @@ describe("fork_instrument_coverage / P-* process & threading", () => { execPrograms: echoExecMap, }); }); + + // 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. + 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"], + timeout: 10_000, + useDefaultRootfs: false, + }); + }); + + // P-11 first exhausts the address space completely so the root continuation + // mmap fails before unwind, then frees one page so a deep fork fails on its + // second chunk after committing frames. Both real guest paths must leave no + // child and preserve a usable parent before a later fork succeeds. + it("P-11 root and later continuation allocation failures preserve the parent", async () => { + await runFixture("programs/p_11_fork_continuation_enomem.wasm", { + contains: [ + "ROOT_CONTINUATION_ENOMEM: ok", + "ROOT_NO_PHANTOM_CHILD: ok", + "ROOT_PARENT_USABLE: ok", + "CONTINUATION_ENOMEM: ok", + "NO_PHANTOM_CHILD: ok", + "CONTINUATION_PAGE_REUSED: ok", + "RECOVERY_CHILD: ok", + "RECOVERY_PARENT: child=", + "PASS: P-11", + ], + timeout: 10_000, + useDefaultRootfs: false, + maxPages: 384, + }); + }); }); // --------------------------------------------------------------------------- diff --git a/host/test/fork-save-buffer-overrun.test.ts b/host/test/fork-save-buffer-overrun.test.ts index 9a295723ab..7b51742bb8 100644 --- a/host/test/fork-save-buffer-overrun.test.ts +++ b/host/test/fork-save-buffer-overrun.test.ts @@ -13,9 +13,10 @@ * * End-to-end behavior was measured against the Homebrew dispatcher, its * /usr/bin/brew alias launcher, the GTK/GLib desktop path, and the exact - * candidate bootstrap's Bash child. ABI 41's 60 KiB reserve must fit every - * measured continuation while retaining truthful detection for larger ones. - * These tests pin the arithmetic that the fork paths use. + * candidate bootstrap's Bash child. Those measurements preserve the ABI 41 + * regression boundary; ABI 42 no longer treats 60 KiB as continuation + * capacity. These tests keep the retired contiguous-buffer detector truthful + * without implying that current linked continuations have the old ceiling. */ import { describe, it, expect } from "vitest"; import type { SideModuleForkState } from "../src/dylink"; @@ -24,6 +25,7 @@ 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 @@ -42,6 +44,7 @@ function writeCurrentPos( function createSideForkState( name: string, forkBufAddr: number, + finishUnwind: () => void = () => {}, ): { state: SideModuleForkState; runtimeState: () => number } { let value = 1; // UNWINDING const instance = { @@ -57,7 +60,7 @@ function createSideForkState( name, instance, forkBufAddr, - forkBufSize: FORK_SAVE_BUFFER_SIZE, + continuation: { finishUnwind } as unknown as LinkedForkContinuation, }, runtimeState: () => value, }; @@ -129,37 +132,31 @@ describe("forkSaveBufferOverrun", () => { ).toBe(1); }); - it("accepts an independently allocated side-module save that fits", () => { + it("finalizes the side-module linked continuation", () => { const memory = new WebAssembly.Memory({ initial: 3 }); - const side = createSideForkState("libintl.so", SIDE_FORK_BUF_ADDR); - writeCurrentPos( - memory, + let finalized = false; + const side = createSideForkState( + "libintl.so", SIDE_FORK_BUF_ADDR, - SIDE_FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE, - 4, + () => { finalized = true; }, ); expect(() => finalizeSideModuleForkUnwind(memory, side.state, 4)) .not.toThrow(); + expect(finalized).toBe(true); expect(side.runtimeState()).toBe(0); }); - it("rejects an overflowing side-module save before fork dispatch", () => { + it("propagates linked continuation validation before fork dispatch", () => { const memory = new WebAssembly.Memory({ initial: 3 }); - const side = createSideForkState("libintl.so", SIDE_FORK_BUF_ADDR); - const overrun = 73; - writeCurrentPos( - memory, + const side = createSideForkState( + "libintl.so", SIDE_FORK_BUF_ADDR, - SIDE_FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE + overrun, - 4, + () => { throw new Error("uncommitted linked frame"); }, ); - expect(() => finalizeSideModuleForkUnwind(memory, side.state, 4)).toThrow( - `libintl.so: side-module fork() continuation save buffer overflow — ` + - `the call stack at fork() needed ${FORK_SAVE_BUFFER_SIZE + overrun} ` + - `bytes but only ${FORK_SAVE_BUFFER_SIZE}`, - ); + expect(() => finalizeSideModuleForkUnwind(memory, side.state, 4)) + .toThrow("uncommitted linked frame"); expect(side.runtimeState()).toBe(0); }); }); diff --git a/host/test/framebuffer-integration.test.ts b/host/test/framebuffer-integration.test.ts index 1dfe335c73..487f1a3f4f 100644 --- a/host/test/framebuffer-integration.test.ts +++ b/host/test/framebuffer-integration.test.ts @@ -57,7 +57,7 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { const workerAdapter = new NodeWorkerAdapter(); const workers = new Map>(); - const pid = 100; + let pid = 0; let stdout = ""; let stdoutResolved = false; @@ -100,6 +100,7 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { }); await kernel.init(kernelWasmBytes); + pid = kernel.createProcess(CAPTURED_STDIO); const memory = createProcessMemory(17); const channelOffset = (MAX_PAGES - 2) * 65536; @@ -107,12 +108,11 @@ describe.skipIf(!existsSync(fbtestBinary))("framebuffer integration", () => { memory.grow(MAX_PAGES - 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth, stdio: CAPTURED_STDIO }); + kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes, memory, channelOffset, diff --git a/host/test/generated-abi.test.ts b/host/test/generated-abi.test.ts index 58a975ccc7..2f0a7e46c0 100644 --- a/host/test/generated-abi.test.ts +++ b/host/test/generated-abi.test.ts @@ -1,5 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import { WPK_FORK_EXPORTS } from "../src/constants"; +import { SIDE_MODULE_FORK_EXPORTS } from "../src/dylink"; import { ABI_CUSTOM_SECTION, ABI_KERNEL_EXPORT, @@ -59,6 +61,15 @@ import { STRUCT_SIZE_WASM_STATFS, STRUCT_SIZE_WASM_TIMESPEC, SYSCALL_ARGS, + WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, + 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, } from "../src/generated/abi"; const snapshot = JSON.parse( @@ -99,6 +110,43 @@ 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 descriptor = fork.linked_frame_descriptor; + 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); + 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_LINKED_FRAME_POINTER_WIDTHS).toEqual( + descriptor.pointer_widths.map( + (format: { + bytes: number; + chunk_header_size: number; + node_header_size: number; + }) => ({ + bytes: format.bytes, + chunkHeaderSize: format.chunk_header_size, + nodeHeaderSize: format.node_header_size, + }), + ), + ); + expect(WPK_FORK_REQUIRED_IMPORTS).toEqual( + fork.required_imports.map(({ kind: _kind, ...requirement }: { kind: string }) => + requirement + ), + ); + expect(WPK_FORK_REQUIRED_EXPORTS).toEqual( + fork.required_exports.map(({ kind: _kind, ...requirement }: { kind: string }) => + requirement + ), + ); + const generatedExportNames = WPK_FORK_REQUIRED_EXPORTS.map(({ name }) => name); + expect(WPK_FORK_EXPORTS).toEqual(generatedExportNames); + expect(SIDE_MODULE_FORK_EXPORTS).toEqual(generatedExportNames); + }); + it("match the ABI version and channel layout snapshot", () => { expect(ABI_VERSION).toBe(snapshot.abi_version); expect(snapshot.custom_sections).toContain(ABI_CUSTOM_SECTION); diff --git a/host/test/homebrew-vfs-builder.test.ts b/host/test/homebrew-vfs-builder.test.ts index fb7bdf76ac..a03e54b33a 100644 --- a/host/test/homebrew-vfs-builder.test.ts +++ b/host/test/homebrew-vfs-builder.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; -import { gzipSync } from "fflate"; +import { gzipSync, zipSync, type Zippable } from "fflate"; import { ABI_VERSION } from "../src/generated/abi"; import { buildHomebrewVfs, @@ -61,6 +61,11 @@ import { type HomebrewVfsPlan, } from "../src/homebrew-vfs-planner"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { + derivePackageDeferredZipTree, + registerPackageDeferredZipTree, + type PackageDeferredZipTreeSpec, +} from "../src/vfs/package-deferred-tree"; import { VFS_DEFERRED_TREE_COLLECTION_LIMITS, VFS_DEFERRED_TREE_LIMITS, @@ -3518,6 +3523,60 @@ describe("Homebrew VFS builder", () => { }, }); + const packageTreeArchive = zipSync({ + "bin/": [new Uint8Array(), { + os: 3, + attrs: ((0o040755 << 16) >>> 0), + }], + "bin/tool": [utf8("package tree\n"), { + os: 3, + attrs: ((0o100755 << 16) >>> 0), + }], + } satisfies Zippable); + const packageTreeSpec = { + schema: 1, + kind: "kandelo-package-deferred-zip-tree", + id: "shell/package-bootstrap", + content_role: "source-tree", + package: { name: "shell", output: "package-bootstrap.zip" }, + archive: { + url: "package-bootstrap.zip", + mode_policy: "portable-posix-v1", + }, + mount_prefix: "/opt/package-bootstrap", + owner: { uid: 0, gid: 0 }, + activation: { + mode: "first-use", + capabilities: ["package:bootstrap"], + roots: ["/opt/package-bootstrap/bin/tool"], + }, + } as const satisfies PackageDeferredZipTreeSpec; + const withPackageTree = MemoryFileSystem.fromImage(await fs.saveImage()); + registerPackageDeferredZipTree( + withPackageTree, + derivePackageDeferredZipTree(packageTreeSpec, packageTreeArchive), + ); + expect(() => assertHomebrewVfsMaterialization( + withPackageTree, + result.evidence, + )).not.toThrow(); + + const withUnexpectedBottle = MemoryFileSystem.fromImage(await fs.saveImage()); + registerPackageDeferredZipTree( + withUnexpectedBottle, + derivePackageDeferredZipTree({ + ...packageTreeSpec, + activation: { + ...packageTreeSpec.activation, + capabilities: ["homebrew-bottle:unexpected"], + }, + }, packageTreeArchive), + ); + expect(() => assertHomebrewVfsMaterialization( + withUnexpectedBottle, + result.evidence, + )).toThrow(/pending deferred trees differ/); + expect(result.selection.embeddedPackages.map((pkg) => pkg.fullName)).toEqual( policy.embedded_package_order, ); diff --git a/host/test/homebrew-vfs-formula-layer.test.ts b/host/test/homebrew-vfs-formula-layer.test.ts new file mode 100644 index 0000000000..b9a21b9a0c --- /dev/null +++ b/host/test/homebrew-vfs-formula-layer.test.ts @@ -0,0 +1,682 @@ +import { describe, expect, it } from "vitest"; + +import { + HOMEBREW_VFS_FORMULA_LAYER_KIND, + HOMEBREW_VFS_FORMULA_MANIFEST_RELATIVE_PATH, + HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH, + parseHomebrewVfsFormulaLayerManifest, + preflightHomebrewVfsFormulaLayers, + projectHomebrewVfsFormulaLayer, + type HomebrewVfsFormulaLayerManifest, + type HomebrewVfsFormulaLayerProjection, +} from "../src/homebrew-vfs-formula-layer"; +import { HOMEBREW_RUNTIME_LAYER_LIMITS } from "../src/homebrew-runtime-layer-limits"; +import type { + HomebrewDependency, + HomebrewFederatedVfsPlan, + HomebrewVfsPackagePlan, +} from "../src/homebrew-vfs-planner"; +import type { TarEntry } from "../src/vfs/tar"; + +const CORE_TAP = "kandelo-dev/tap-core"; +const EXTERNAL_TAP = "example/homebrew-apps"; +const PREFIX = "/home/linuxbrew/.linuxbrew"; + +function pkg( + tapName: string, + name: string, + dependencies: HomebrewDependency[] = [], +): HomebrewVfsPackagePlan { + const version = "1.0"; + const fullName = `${tapName}/${name}`; + const tapRepository = `${tapName}-repository`; + const tapCommit = "1".repeat(40); + const kandeloRepository = "Automattic/kandelo"; + const kandeloCommit = "2".repeat(40); + const sha256 = "3".repeat(64); + const cacheKeySha = "4".repeat(64); + const keg = `${PREFIX}/Cellar/${name}/${version}`; + const payloadRoot = `${name}/${version}`; + const url = `https://example.invalid/${tapName}/${name}.tar.gz`; + return { + name, + fullName, + tapRepository, + tapName, + tapCommit, + kandeloRepository, + kandeloCommit, + version, + formulaRevision: 0, + bottleRebuild: 0, + arch: "wasm32", + kandeloAbi: 41, + metadataStatus: "success", + sourceStatus: "success", + url, + sha256, + bytes: 1024, + cacheKeySha, + dependencies, + runtimeSupport: ["node", "browser"], + browserCompatible: true, + prefix: PREFIX, + cellar: "any", + keg, + payloadRoot, + linkManifestPath: `metadata/link-manifests/${name}.json`, + linkManifest: { + schema: 1, + package: fullName, + version, + arch: "wasm32", + kandelo_abi: 41, + prefix: PREFIX, + cellar: "any", + keg, + bottle: { + url, + sha256, + bytes: 1024, + cache_key_sha: cacheKeySha, + payload_root: payloadRoot, + }, + links: [], + receipts: [], + env: {}, + }, + }; +} + +function plan( + root: HomebrewVfsPackagePlan, + dependencies: HomebrewVfsPackagePlan[] = [], +): HomebrewFederatedVfsPlan { + const [owner, tap] = root.tapName.split("/"); + return { + schema: 1, + tapRepository: `${owner}/${tap}`, + tapName: root.tapName, + tapCommit: "1".repeat(40), + kandeloRepository: "Automattic/kandelo", + kandeloCommit: "2".repeat(40), + kandeloAbi: 41, + releaseTag: "bottles-abi-v41", + requestedPackages: [root.name], + requestedFullNames: [root.fullName], + taps: [], + packages: [...dependencies, root], + }; +} + +function manifest( + packageName: string, + roots = ["/etc/dinit.d"], +): HomebrewVfsFormulaLayerManifest { + return { + schema: 1, + kind: HOMEBREW_VFS_FORMULA_LAYER_KIND, + package: packageName, + payload: { + root: HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH, + mount_prefix: "/", + }, + activation: { + mode: "first-use", + capabilities: [`service:${packageName.split("/")[2]}`], + roots, + }, + }; +} + +function bottleEntries( + root: HomebrewVfsPackagePlan, + payload: TarEntry[] = [ + { path: "etc", type: "directory", mode: 0o755 }, + { path: "etc/dinit.d", type: "directory", mode: 0o755 }, + { + path: "etc/dinit.d/service", + type: "file", + mode: 0o644, + data: new TextEncoder().encode("type = process\n"), + }, + ], + manifestValue: unknown = manifest(root.fullName), +): TarEntry[] { + const payloadSource = `${root.payloadRoot}/${HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH}`; + return [ + { + path: `${root.payloadRoot}/${HOMEBREW_VFS_FORMULA_MANIFEST_RELATIVE_PATH}`, + type: "file", + mode: 0o644, + data: new TextEncoder().encode(`${JSON.stringify(manifestValue)}\n`), + }, + { path: payloadSource, type: "directory", mode: 0o755 }, + ...payload.map((entry) => ({ + ...entry, + path: `${payloadSource}/${entry.path}`, + })), + ]; +} + +function projection( + tapName: string, + name: string, + payload: TarEntry[], + dependencies: HomebrewVfsPackagePlan[] = [], +): HomebrewVfsFormulaLayerProjection { + const rootDependencies = dependencies.map((dependency) => ({ + name: dependency.name, + full_name: dependency.fullName, + })); + const root = pkg(tapName, name, rootDependencies); + return projectHomebrewVfsFormulaLayer( + plan(root, dependencies), + root.fullName, + bottleEntries( + root, + payload, + manifest(root.fullName, [`/${payload[0]!.path.split("/")[0]}`]), + ), + ); +} + +describe("Homebrew VFS Formula layer manifest", () => { + it("parses the fixed URL-free keg contract", () => { + const value = manifest(`${EXTERNAL_TAP}/blog-vfs`); + + expect(parseHomebrewVfsFormulaLayerManifest(value)).toEqual(value); + expect(JSON.stringify(value)).not.toMatch(/https:|sha256|release/); + }); + + it("rejects open-ended fields, alternate payload locations, and unordered policy", () => { + const value = manifest(`${EXTERNAL_TAP}/blog-vfs`); + expect(() => + parseHomebrewVfsFormulaLayerManifest({ + ...value, + release_url: "https://example.invalid/layer", + }), + ).toThrow("unexpected or missing fields"); + expect(() => + parseHomebrewVfsFormulaLayerManifest({ + ...value, + payload: { ...value.payload, root: "share/rootfs" }, + }), + ).toThrow("conventional keg root"); + expect(() => + parseHomebrewVfsFormulaLayerManifest({ + ...value, + activation: { + ...value.activation, + roots: ["/var/lib/blog", "/etc/dinit.d"], + }, + }), + ).toThrow("not in canonical order"); + expect(() => + parseHomebrewVfsFormulaLayerManifest({ + ...value, + package: `${"a".repeat(HOMEBREW_RUNTIME_LAYER_LIMITS.maxRepositoryBytes)}/tap/formula`, + }), + ).toThrow("canonical owner/tap/formula name"); + }); +}); + +describe("Homebrew VFS Formula bottle projection", () => { + it("uses ordinary cross-tap dependencies and maps owned config from the fixed payload", () => { + const dinit = pkg(CORE_TAP, "dinit"); + const root = pkg(EXTERNAL_TAP, "blog-vfs", [ + { + name: dinit.name, + full_name: dinit.fullName, + }, + ]); + const projected = projectHomebrewVfsFormulaLayer( + plan(root, [dinit]), + root.fullName, + bottleEntries(root), + ); + + expect( + projected.dependencies.map((dependency) => dependency.fullName), + ).toEqual([`${CORE_TAP}/dinit`]); + expect(projected.packages.map((entry) => entry.fullName)).toEqual([ + `${CORE_TAP}/dinit`, + `${EXTERNAL_TAP}/blog-vfs`, + ]); + expect(projected.entries).toEqual([ + { + path: "/etc", + source_path: "blog-vfs/1.0/libexec/kandelo-vfs-layer/rootfs/etc", + type: "directory", + mode: 0o755, + size: 0, + }, + { + path: "/etc/dinit.d", + source_path: + "blog-vfs/1.0/libexec/kandelo-vfs-layer/rootfs/etc/dinit.d", + type: "directory", + mode: 0o755, + size: 0, + }, + { + path: "/etc/dinit.d/service", + source_path: + "blog-vfs/1.0/libexec/kandelo-vfs-layer/rootfs/etc/dinit.d/service", + type: "file", + mode: 0o644, + size: 15, + }, + ]); + }); + + it("requires a complete single-root dependency-first closure", () => { + const dependency = pkg(CORE_TAP, "dinit"); + const root = pkg(EXTERNAL_TAP, "blog-vfs", [ + { + name: dependency.name, + full_name: dependency.fullName, + }, + ]); + const entries = bottleEntries(root); + + expect(() => + projectHomebrewVfsFormulaLayer(plan(root), root.fullName, entries), + ).toThrow(`depends on missing ${dependency.fullName}`); + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root, [root, dependency]), + root.fullName, + entries, + ), + ).toThrow("duplicates package"); + + const unrelated = pkg(CORE_TAP, "redis"); + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root, [dependency, unrelated]), + root.fullName, + entries, + ), + ).toThrow("outside its root dependency closure"); + }); + + it("binds both fixed files and the manifest package identity", () => { + const root = pkg(EXTERNAL_TAP, "blog-vfs"); + const entries = bottleEntries(root); + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + entries.slice(1), + ), + ).toThrow("is missing"); + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + entries.filter( + (entry) => + !entry.path.endsWith(HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH), + ), + ), + ).toThrow("payload root must be a directory"); + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + bottleEntries(root, undefined, manifest(`${CORE_TAP}/other-vfs`)), + ), + ).toThrow(`expected ${root.fullName}`); + }); + + it("rejects incomplete directory ownership and unsafe links", () => { + const root = pkg(EXTERNAL_TAP, "blog-vfs"); + const withoutEtc = bottleEntries(root).filter( + (entry) => !entry.path.endsWith("/rootfs/etc"), + ); + expect(() => + projectHomebrewVfsFormulaLayer(plan(root), root.fullName, withoutEtc), + ).toThrow("payload omits directory /etc"); + + const unsafeLink: TarEntry[] = [ + { path: "usr", type: "directory", mode: 0o755 }, + { path: "usr/bin", type: "directory", mode: 0o755 }, + { + path: "usr/bin/tool", + type: "symlink", + mode: 0o777, + linkName: "../../../outside", + }, + ]; + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + bottleEntries(root, unsafeLink, manifest(root.fullName, ["/usr/bin"])), + ), + ).toThrow("escapes /"); + }); + + it("resolves payload hard links to one in-payload regular target", () => { + const root = pkg(EXTERNAL_TAP, "blog-vfs"); + const payloadSource = `${root.payloadRoot}/${HOMEBREW_VFS_FORMULA_PAYLOAD_RELATIVE_PATH}`; + const payload: TarEntry[] = [ + { path: "usr", type: "directory", mode: 0o755 }, + { path: "usr/share", type: "directory", mode: 0o755 }, + { + path: "usr/share/data", + type: "file", + mode: 0o644, + data: new Uint8Array([1, 2, 3]), + }, + { + path: "usr/share/data-alias", + type: "hardlink", + mode: 0o644, + linkName: `${payloadSource}/usr/share/data`, + }, + ]; + const projected = projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + bottleEntries(root, payload, manifest(root.fullName, ["/usr/share"])), + ); + + expect( + projected.entries.find((entry) => entry.path.endsWith("data-alias")), + ).toMatchObject({ + type: "hardlink", + target: "/usr/share/data", + size: 3, + }); + + (payload[3] as Extract).linkName = + "another-package/1.0/data"; + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + bottleEntries(root, payload, manifest(root.fullName, ["/usr/share"])), + ), + ).toThrow("targets outside its payload"); + }); + + it("preserves absolute and parent-relative dependency symlinks", () => { + const root = pkg(EXTERNAL_TAP, "blog-vfs"); + const payload: TarEntry[] = [ + { path: "usr", type: "directory", mode: 0o755 }, + { path: "usr/bin", type: "directory", mode: 0o755 }, + { + path: "usr/bin/absolute-tool", + type: "symlink", + mode: 0o777, + linkName: "/home/linuxbrew/.linuxbrew/opt/dependency/bin/tool", + }, + { + path: "usr/bin/relative-tool", + type: "symlink", + mode: 0o777, + linkName: "../../home/linuxbrew/.linuxbrew/opt/dependency/bin/tool", + }, + ]; + + const projected = projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + bottleEntries(root, payload, manifest(root.fullName, ["/usr/bin"])), + ); + + expect(projected.entries.find((entry) => + entry.path === "/usr/bin/absolute-tool" + )).toMatchObject({ + type: "symlink", + target: "/home/linuxbrew/.linuxbrew/opt/dependency/bin/tool", + }); + expect(projected.entries.find((entry) => + entry.path === "/usr/bin/relative-tool" + )).toMatchObject({ + type: "symlink", + target: "../../home/linuxbrew/.linuxbrew/opt/dependency/bin/tool", + }); + }); + + it("requires first-use roots to cover every non-directory payload entry", () => { + const root = pkg(EXTERNAL_TAP, "blog-vfs"); + const payload: TarEntry[] = [ + { path: "etc", type: "directory", mode: 0o755 }, + { + path: "etc/config", + type: "file", + mode: 0o644, + data: new Uint8Array([1]), + }, + { path: "var", type: "directory", mode: 0o755 }, + { + path: "var/data", + type: "file", + mode: 0o644, + data: new Uint8Array([2]), + }, + ]; + expect(() => + projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + bottleEntries(root, payload, manifest(root.fullName, ["/etc"])), + ), + ).toThrow("/var/data has no activation root"); + }); + + it("allows shared directory scaffolding outside first-use roots", () => { + const root = pkg(EXTERNAL_TAP, "blog-vfs"); + const projected = projectHomebrewVfsFormulaLayer( + plan(root), + root.fullName, + bottleEntries( + root, + [ + { path: "usr", type: "directory", mode: 0o755 }, + { path: "usr/bin", type: "directory", mode: 0o755 }, + { + path: "usr/bin/tool", + type: "file", + mode: 0o755, + data: new Uint8Array([1]), + }, + { path: "var", type: "directory", mode: 0o755 }, + { path: "var/lib", type: "directory", mode: 0o755 }, + ], + manifest(root.fullName, ["/usr/bin"]), + ), + ); + + expect(projected.entries.map((entry) => entry.path)).toContain("/var/lib"); + }); +}); + +describe("Homebrew VFS Formula layer composition preflight", () => { + it("composes independent layers deterministically in either selection order", () => { + const dinit = pkg(CORE_TAP, "dinit"); + const blog = projection( + EXTERNAL_TAP, + "blog-vfs", + [ + { path: "etc", type: "directory", mode: 0o755 }, + { path: "etc/blog", type: "directory", mode: 0o755 }, + { + path: "etc/blog/config", + type: "file", + mode: 0o644, + data: new Uint8Array([1]), + }, + ], + [dinit], + ); + const metrics = projection(CORE_TAP, "metrics-vfs", [ + { path: "var", type: "directory", mode: 0o755 }, + { path: "var/lib", type: "directory", mode: 0o755 }, + { + path: "var/lib/metrics", + type: "file", + mode: 0o600, + data: new Uint8Array([2]), + }, + ]); + + const forward = preflightHomebrewVfsFormulaLayers([blog, metrics]); + const reverse = preflightHomebrewVfsFormulaLayers([metrics, blog]); + + expect(reverse).toEqual(forward); + expect(forward.layers.map((layer) => layer.rootPackage.fullName)).toEqual([ + `${EXTERNAL_TAP}/blog-vfs`, + `${CORE_TAP}/metrics-vfs`, + ]); + expect(forward.packageOrder).toEqual([ + `${CORE_TAP}/dinit`, + `${EXTERNAL_TAP}/blog-vfs`, + `${CORE_TAP}/metrics-vfs`, + ]); + }); + + it("rejects target conflicts before changing either projection", () => { + const first = projection(CORE_TAP, "first-vfs", [ + { path: "etc", type: "directory", mode: 0o755 }, + { + path: "etc/shared.conf", + type: "file", + mode: 0o644, + data: new Uint8Array([1]), + }, + ]); + const second = projection(EXTERNAL_TAP, "second-vfs", [ + { path: "etc", type: "directory", mode: 0o755 }, + { + path: "etc/shared.conf", + type: "file", + mode: 0o644, + data: new Uint8Array([2]), + }, + ]); + const before = structuredClone([first.entries, second.entries]); + + expect(() => preflightHomebrewVfsFormulaLayers([second, first])).toThrow( + `layers ${EXTERNAL_TAP}/second-vfs and ${CORE_TAP}/first-vfs conflict ` + + "at /etc/shared.conf", + ); + expect([first.entries, second.entries]).toEqual(before); + }); + + it("merges equal directories and identical dependencies but rejects conflicts", () => { + const first = projection(CORE_TAP, "first-vfs", [ + { path: "etc", type: "directory", mode: 0o755 }, + { + path: "etc/first", + type: "file", + mode: 0o644, + data: new Uint8Array([1]), + }, + ]); + const second = projection(EXTERNAL_TAP, "second-vfs", [ + { path: "etc", type: "directory", mode: 0o755 }, + { + path: "etc/second", + type: "file", + mode: 0o644, + data: new Uint8Array([2]), + }, + ]); + expect( + preflightHomebrewVfsFormulaLayers([first, second]).entries, + ).toHaveLength(3); + + second.entries[0]!.mode = 0o700; + expect(() => preflightHomebrewVfsFormulaLayers([first, second])).toThrow( + "conflict at /etc", + ); + + const shared = pkg(CORE_TAP, "shared"); + const withSharedA = projection( + CORE_TAP, + "a-vfs", + [{ path: "a", type: "file", mode: 0o644, data: new Uint8Array([1]) }], + [shared], + ); + const withSharedB = projection( + EXTERNAL_TAP, + "b-vfs", + [{ path: "b", type: "file", mode: 0o644, data: new Uint8Array([2]) }], + [shared], + ); + expect( + preflightHomebrewVfsFormulaLayers([withSharedB, withSharedA]) + .packageOrder, + ).toEqual([shared.fullName, `${EXTERNAL_TAP}/b-vfs`, `${CORE_TAP}/a-vfs`]); + + const incompatibleShared = structuredClone(shared); + incompatibleShared.sha256 = "5".repeat(64); + incompatibleShared.linkManifest.bottle.sha256 = incompatibleShared.sha256; + const withIncompatibleShared = projection( + EXTERNAL_TAP, + "c-vfs", + [{ path: "c", type: "file", mode: 0o644, data: new Uint8Array([3]) }], + [incompatibleShared], + ); + expect(() => + preflightHomebrewVfsFormulaLayers([withSharedA, withIncompatibleShared]), + ).toThrow(`${shared.fullName} to different immutable package identities`); + }); + + it("enforces image-wide package, entry, and payload budgets after deduplication", () => { + const first = projection(CORE_TAP, "first-vfs", [ + { path: "first", type: "file", mode: 0o644, data: new Uint8Array([1]) }, + ]); + const second = projection(EXTERNAL_TAP, "second-vfs", [ + { path: "second", type: "file", mode: 0o644, data: new Uint8Array([2]) }, + ]); + + const packageHeavy = structuredClone(first); + packageHeavy.packages = [ + ...Array.from( + { length: HOMEBREW_RUNTIME_LAYER_LIMITS.maxPackages - 1 }, + (_, index) => pkg(CORE_TAP, `dependency-${index}`), + ), + packageHeavy.rootPackage, + ]; + expect(() => + preflightHomebrewVfsFormulaLayers([packageHeavy, second]), + ).toThrow( + `packages; maximum is ${HOMEBREW_RUNTIME_LAYER_LIMITS.maxPackages}`, + ); + + const entryHeavy = structuredClone(first); + entryHeavy.entries = Array.from( + { length: HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionEntries }, + (_, index) => ({ + path: `/entry-${index}`, + source_path: `${first.rootPackage.payloadRoot}/entry-${index}`, + type: "file" as const, + mode: 0o644, + size: 1, + }), + ); + expect(() => + preflightHomebrewVfsFormulaLayers([entryHeavy, second]), + ).toThrow( + `entries; maximum is ${HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionEntries}`, + ); + + const payloadHeavy = structuredClone(first); + const halfPayloadBudget = + Math.floor(HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionPayloadBytes / 2) + + 1; + payloadHeavy.entries[0]!.size = halfPayloadBudget; + const secondPayloadHeavy = structuredClone(second); + secondPayloadHeavy.entries[0]!.size = halfPayloadBudget; + expect(() => + preflightHomebrewVfsFormulaLayers([payloadHeavy, secondPayloadHeavy]), + ).toThrow( + `${HOMEBREW_RUNTIME_LAYER_LIMITS.maxCollectionPayloadBytes}-byte cap`, + ); + }); +}); diff --git a/host/test/homebrew-vfs-image-save.test.ts b/host/test/homebrew-vfs-image-save.test.ts new file mode 100644 index 0000000000..e159a2701c --- /dev/null +++ b/host/test/homebrew-vfs-image-save.test.ts @@ -0,0 +1,76 @@ +import { + existsSync, + mkdtempSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + saveVerifiedHomebrewVfsImage, +} from "../../images/vfs/scripts/build-homebrew-vfs-image"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const MiB = 1024 * 1024; + +describe("Homebrew VFS image publication boundary", () => { + it("writes an image whose encoded ceiling matches its consumer contract", async () => { + const maxByteLength = 8 * MiB; + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(1 * MiB, { maxByteLength }), + maxByteLength, + ); + const dir = mkdtempSync(join(tmpdir(), "homebrew-vfs-capacity-")); + const outFile = join(dir, "homebrew.vfs.zst"); + try { + const image = await saveVerifiedHomebrewVfsImage( + fs, + outFile, + { skipWasmArtifactCheck: true }, + maxByteLength, + ); + + expect( + MemoryFileSystem.readImageCapacity(image).maxByteLength, + ).toBe(maxByteLength); + expect(existsSync(outFile)).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("rejects a masked encoded ceiling before creating an output artifact", async () => { + const encodedMaxByteLength = 8 * MiB; + const consumerMaxByteLength = 4 * MiB; + const source = MemoryFileSystem.create( + new SharedArrayBuffer(1 * MiB, { + maxByteLength: encodedMaxByteLength, + }), + encodedMaxByteLength, + ); + const restored = MemoryFileSystem.fromImage(await source.saveImage(), { + maxByteLength: consumerMaxByteLength, + }); + expect(restored.statfs("/").blocks * restored.statfs("/").bsize).toBe( + consumerMaxByteLength, + ); + + const dir = mkdtempSync(join(tmpdir(), "homebrew-vfs-capacity-drift-")); + const outFile = join(dir, "homebrew.vfs.zst"); + try { + await expect( + saveVerifiedHomebrewVfsImage( + restored, + outFile, + { skipWasmArtifactCheck: true }, + consumerMaxByteLength, + ), + ).rejects.toThrow( + /has a 8388608-byte VFS capacity; 4194304 bytes are required/, + ); + expect(existsSync(outFile)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/kernel-worker-copyback.test.ts b/host/test/kernel-worker-copyback.test.ts index c8eb166852..c873488f73 100644 --- a/host/test/kernel-worker-copyback.test.ts +++ b/host/test/kernel-worker-copyback.test.ts @@ -32,7 +32,7 @@ interface CopybackHarnessWorker { } function makeCopybackHarness() { - const pid = 1; + const pid = 100; const kernelMemory = new WebAssembly.Memory({ initial: 2 }); const processMemory = new WebAssembly.Memory({ initial: 2, diff --git a/host/test/kernel.test.ts b/host/test/kernel.test.ts index 7b3f37a6c5..6f9c5d9ef3 100644 --- a/host/test/kernel.test.ts +++ b/host/test/kernel.test.ts @@ -50,10 +50,10 @@ describe("CentralizedKernelWorker", () => { const channelOffset = (256 - 2) * 65536; memory.grow(256 - 17); - // PID 1 is reserved for the virtual init process; use PIDs >= 100. - kernelWorker.registerProcess(100, memory, [channelOffset], { stdio: CAPTURED_STDIO }); + const pid = kernelWorker.createProcess(CAPTURED_STDIO); + kernelWorker.registerProcess(pid, memory, [channelOffset]); // Unregister to clean up - kernelWorker.unregisterProcess(100); + kernelWorker.unregisterProcess(pid); }); }); diff --git a/host/test/lazy-tree.test.ts b/host/test/lazy-tree.test.ts index 0a566c5515..6eeb57fca6 100644 --- a/host/test/lazy-tree.test.ts +++ b/host/test/lazy-tree.test.ts @@ -319,7 +319,7 @@ describe("format-neutral deferred trees", () => { const mirror = "https://mirror.example.invalid/runtime.tar.gz"; const fetcher = vi.fn(async (url: string) => url === primary - ? new Response(null, { status: 503 }) + ? new Response(null, { status: 404 }) : new Response(fixture.payload) ); fs.setLazyFetcher(fetcher); @@ -333,6 +333,410 @@ describe("format-neutral deferred trees", () => { expect(readText(fs, "/runtime/tool")).toBe("payload"); }); + it.each([408, 429, 500, 502, 599])( + "retries transient HTTP %s responses on the same transport", + async (status) => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const url = fixture.content.transports[0]!; + const fetcher = vi.fn(async () => + fetcher.mock.calls.length === 1 + ? new Response(null, { + status, + headers: { "retry-after": "0" }, + }) + : new Response(fixture.payload) + ); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + + await expect(fs.preparePath("/runtime/tool")).resolves.toBe(true); + expect(fetcher).toHaveBeenCalledTimes(2); + expect(fetcher.mock.calls.map(([requested]) => requested)).toEqual([ + url, + url, + ]); + }, + ); + + it.each([400, 401, 403, 404, 409, 499])( + "does not retry permanent HTTP %s responses", + async (status) => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const fetcher = vi.fn(async () => new Response(null, { status })); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + + await expect(fs.preparePath("/runtime/tool")).rejects.toThrow( + `HTTP ${status}`, + ); + expect(fetcher).toHaveBeenCalledOnce(); + }, + ); + + it("bounds one transient transport to three total attempts", async () => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const fetcher = vi.fn(async () => + new Response(null, { + status: 503, + headers: { "retry-after": "0" }, + }) + ); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + + await expect(fs.preparePath("/runtime/tool")).rejects.toThrow("HTTP 503"); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("exhausts one transient transport before advancing to its mirror", async () => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const primary = "https://primary.example.invalid/transient.tar.gz"; + const mirror = "https://mirror.example.invalid/transient.tar.gz"; + const fetcher = vi.fn(async (url: string) => + url === primary + ? new Response(null, { + status: 503, + headers: { "retry-after": "0" }, + }) + : new Response(fixture.payload) + ); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree({ + ...fixture.content, + transports: [primary, mirror], + }, fixture.inventory, "/", fixture.activation); + + await expect(fs.preparePath("/runtime/tool")).resolves.toBe(true); + expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ + primary, + primary, + primary, + mirror, + ]); + }); + + it("uses bounded backoff and honors Retry-After without sleeping in tests", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-23T12:00:00.000Z")); + try { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const fetcher = vi.fn(async () => + fetcher.mock.calls.length === 1 + ? new Response(null, { status: 502 }) + : fetcher.mock.calls.length === 2 + ? new Response(null, { + status: 429, + headers: { + "retry-after": new Date(Date.now() + 60_000).toUTCString(), + }, + }) + : new Response(fixture.payload) + ); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + + const materialized = fs.preparePath("/runtime/tool"); + await vi.advanceTimersByTimeAsync(249); + expect(fetcher).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(1); + expect(fetcher).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(4_999); + expect(fetcher).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + await expect(materialized).resolves.toBe(true); + expect(fetcher).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + + it("retries fetch and response-stream network interruptions", async () => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const fetcher = vi.fn(async () => { + if (fetcher.mock.calls.length === 1) { + throw new TypeError("fetch failed"); + } + if (fetcher.mock.calls.length === 2) { + return new Response(new ReadableStream({ + start(controller) { + controller.error(new TypeError("connection reset")); + }, + }), { + headers: { "content-length": String(fixture.payload.byteLength) }, + }); + } + return new Response(fixture.payload); + }); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + + vi.useFakeTimers(); + try { + const materialized = fs.preparePath("/runtime/tool"); + await vi.runAllTimersAsync(); + await expect(materialized).resolves.toBe(true); + } finally { + vi.useRealTimers(); + } + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("preserves an oversize violation when stream cancellation rejects", async () => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const oversized = new Uint8Array(fixture.payload.byteLength + 1); + oversized.set(fixture.payload); + const cancel = vi.fn(async () => { + throw new TypeError("stream cancellation failed"); + }); + const fetcher = vi.fn(async () => + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(oversized); + }, + cancel, + })) + ); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + + await expect(fs.preparePath("/runtime/tool")).rejects.toThrow( + `exceeded expected byte count ${fixture.payload.byteLength}`, + ); + expect(fetcher).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("stops a multi-mirror tree on a standard Fetch abort", async () => { + const fixture = tarTreeFixture("first-use"); + const aborted = createFs(); + const reason = new DOMException("caller stopped", "AbortError"); + const abortFetch = vi.fn(async () => { + throw reason; + }); + aborted.setLazyFetcher(abortFetch); + aborted.registerLazyTree({ + ...fixture.content, + transports: [ + "https://primary.example.invalid/abort.tar.gz", + "https://mirror.example.invalid/abort.tar.gz", + ], + }, fixture.inventory, "/", fixture.activation); + + await expect(aborted.preparePath("/runtime/tool")).rejects.toBe(reason); + expect(abortFetch).toHaveBeenCalledOnce(); + }); + + it("invokes an existing one-argument fetcher with exactly one argument", async () => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const argumentCounts: number[] = []; + const fetcher = vi.fn(async function (url: string) { + argumentCounts.push(arguments.length); + expect(url).toBe(fixture.content.transports[0]); + return new Response(fixture.payload); + }); + fs.setLazyFetcher(fetcher); + fs.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + + await expect(fs.preparePath("/runtime/tool")).resolves.toBe(true); + expect(argumentCounts).toEqual([1]); + }); + + it("rethrows a pre-aborted registered signal before starting any mirror", async () => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const controller = new AbortController(); + const reason = new Error("cancel before fetch"); + controller.abort(reason); + const fetcher = vi.fn(async () => new Response(fixture.payload)); + fs.setLazyFetcher(fetcher, { signal: controller.signal }); + fs.registerLazyTree({ + ...fixture.content, + transports: [ + "https://primary.example.invalid/pre-abort.tar.gz", + "https://mirror.example.invalid/pre-abort.tar.gz", + ], + }, fixture.inventory, "/", fixture.activation); + + await expect(fs.preparePath("/runtime/tool")).rejects.toBe(reason); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it.each([ + ["Error", () => new Error("custom cancellation")], + ["TypeError", () => new TypeError("custom cancellation")], + ["string", () => "primitive cancellation"], + ] as const)( + "preserves a registered signal's custom %s reason across mirrors", + async (_label, createReason) => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const controller = new AbortController(); + const reason = createReason(); + const fetcher = vi.fn(async ( + _url: string, + init?: { signal?: AbortSignal }, + ) => { + expect(init?.signal).toBe(controller.signal); + controller.abort(reason); + return new Response(fixture.payload); + }); + fs.setLazyFetcher(fetcher, { signal: controller.signal }); + fs.registerLazyTree({ + ...fixture.content, + transports: [ + "https://primary.example.invalid/custom-abort.tar.gz", + "https://mirror.example.invalid/custom-abort.tar.gz", + ], + }, fixture.inventory, "/", fixture.activation); + + await expect(fs.preparePath("/runtime/tool")).rejects.toBe(reason); + expect(fetcher).toHaveBeenCalledOnce(); + expect(fs.isPathDeferred("/runtime/tool")).toBe(true); + }, + ); + + it("preserves AbortSignal.timeout provenance across mirrors", async () => { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const signal = AbortSignal.timeout(10); + const fetcher = vi.fn(( + _url: string, + init?: { signal?: AbortSignal }, + ) => + new Promise((_resolve, reject) => { + const registered = init?.signal; + if (registered === undefined) { + reject(new Error("lazy fetch signal was not forwarded")); + return; + } + const onAbort = (): void => reject(registered.reason); + if (registered.aborted) onAbort(); + else registered.addEventListener("abort", onAbort, { once: true }); + }) + ); + fs.setLazyFetcher(fetcher, { signal }); + fs.registerLazyTree({ + ...fixture.content, + transports: [ + "https://primary.example.invalid/timeout.tar.gz", + "https://mirror.example.invalid/timeout.tar.gz", + ], + }, fixture.inventory, "/", fixture.activation); + + let caught: unknown; + try { + await fs.preparePath("/runtime/tool"); + } catch (error) { + caught = error; + } + expect(signal.aborted).toBe(true); + expect(caught).toBe(signal.reason); + expect(fetcher).toHaveBeenCalledOnce(); + }); + + it("interrupts a transient retry wait with the exact registered reason", async () => { + vi.useFakeTimers(); + try { + const fixture = tarTreeFixture("first-use"); + const fs = createFs(); + const controller = new AbortController(); + const reason = new Error("stop retry wait"); + const fetcher = vi.fn(async () => new Response(null, { status: 502 })); + fs.setLazyFetcher(fetcher, { signal: controller.signal }); + fs.registerLazyTree({ + ...fixture.content, + transports: [ + "https://primary.example.invalid/wait.tar.gz", + "https://mirror.example.invalid/wait.tar.gz", + ], + }, fixture.inventory, "/", fixture.activation); + + const materialized = fs.preparePath("/runtime/tool"); + await vi.advanceTimersByTimeAsync(0); + expect(fetcher).toHaveBeenCalledOnce(); + controller.abort(reason); + await expect(materialized).rejects.toBe(reason); + expect(fetcher).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it("does not retry integrity or decoder failures", async () => { + const fixture = tarTreeFixture("first-use"); + + const changed = fixture.payload.slice(); + changed[0] ^= 0xff; + const integrity = createFs(); + const integrityFetch = vi.fn(async () => new Response(changed)); + integrity.setLazyFetcher(integrityFetch); + integrity.registerLazyTree( + fixture.content, + fixture.inventory, + "/", + fixture.activation, + ); + await expect(integrity.preparePath("/runtime/tool")).rejects.toThrow( + /SHA-256/, + ); + expect(integrityFetch).toHaveBeenCalledOnce(); + + const undecodable = encoder.encode("not a gzip archive"); + const decoder = createFs(); + const decoderFetch = vi.fn(async () => new Response(undecodable)); + decoder.setLazyFetcher(decoderFetch); + decoder.registerLazyTree({ + ...fixture.content, + sha256: createHash("sha256").update(undecodable).digest("hex"), + bytes: undecodable.byteLength, + }, fixture.inventory, "/", fixture.activation); + await expect(decoder.preparePath("/runtime/tool")).rejects.toThrow(); + expect(decoderFetch).toHaveBeenCalledOnce(); + }); + it("accepts every exact public activation and transport boundary", () => { const fixture = tarTreeFixture("first-use"); const capabilities = Array.from( diff --git a/host/test/lazy-url.test.ts b/host/test/lazy-url.test.ts new file mode 100644 index 0000000000..7dbb8e7f04 --- /dev/null +++ b/host/test/lazy-url.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { resolveLazyUrl } from "../src/vfs/lazy-url"; + +describe("lazy asset URL resolution", () => { + it("resolves relative assets and preserves absolute transports", () => { + expect(resolveLazyUrl("https://cdn.example.test/release", "tree.zip")).toBe( + "https://cdn.example.test/release/tree.zip", + ); + expect(resolveLazyUrl("https://cdn.example.test/release/", "nested/tree.zip")).toBe( + "https://cdn.example.test/release/nested/tree.zip", + ); + expect(resolveLazyUrl("https://ignored.example/", "https://cdn.example/tree.zip")).toBe( + "https://cdn.example/tree.zip", + ); + expect(resolveLazyUrl("https://ignored.example/", "/assets/tree.zip")).toBe( + "/assets/tree.zip", + ); + }); +}); diff --git a/host/test/mariadb-test-source-copy.test.ts b/host/test/mariadb-test-source-copy.test.ts new file mode 100644 index 0000000000..17c4def54c --- /dev/null +++ b/host/test/mariadb-test-source-copy.test.ts @@ -0,0 +1,165 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + copyMariaDbTestSources, +} from "../../images/vfs/scripts/mariadb-test-source-copy"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const O_RDONLY = 0; + +function createFs(): MemoryFileSystem { + return MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); +} + +function readVfsText(fs: MemoryFileSystem, path: string): string { + const bytes = new Uint8Array(fs.stat(path).size); + const fd = fs.open(path, O_RDONLY, 0); + try { + const count = fs.read(fd, bytes, null, bytes.byteLength); + if (count !== bytes.byteLength) { + throw new Error(`short test read for ${path}`); + } + } finally { + fs.close(fd); + } + return new TextDecoder().decode(bytes); +} + +function withMariaDbSource(run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "mariadb-test-source-")); + try { + mkdirSync(join(root, "main")); + mkdirSync(join(root, "include")); + mkdirSync(join(root, "std_data")); + writeFileSync(join(root, "main", "selected.test"), "selected"); + writeFileSync(join(root, "main", "other.test"), "other"); + writeFileSync(join(root, "main", "README"), "not a test"); + writeFileSync(join(root, "include", "helper.inc"), "include"); + writeFileSync(join(root, "std_data", "fixture.dat"), "fixture"); + run(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("MariaDB test source closure", () => { + it("keeps the artifact and browser runner on one curated selection", () => { + const repoRoot = resolve(import.meta.dirname, "../.."); + const builder = readFileSync( + join(repoRoot, "images/vfs/scripts/build-mariadb-test-vfs-image.ts"), + "utf8", + ); + const runner = readFileSync( + join(repoRoot, "scripts/run-browser-mariadb-tests.sh"), + "utf8", + ); + const builderBody = builder.match( + /const CURATED_TESTS = \[([\s\S]*?)\];/, + )?.[1]; + const runnerBody = runner.match( + /CURATED_TESTS=\(([\s\S]*?)\n\)/, + )?.[1]; + expect(builderBody, "builder curated test list").toBeDefined(); + expect(runnerBody, "runner curated test list").toBeDefined(); + + const builderTests = Array.from( + builderBody!.matchAll(/"([^"]+)"/g), + (match) => match[1], + ); + const runnerTests = runnerBody!.trim().split(/\s+/); + expect(builderTests).toEqual(runnerTests); + expect(new Set(builderTests).size).toBe(builderTests.length); + }); + + it("copies every curated test and both required fixture trees", () => { + withMariaDbSource((root) => { + const fs = createFs(); + + expect(copyMariaDbTestSources(fs, root, { + includeAll: false, + curatedTests: ["selected"], + })).toBe(1); + + expect(readVfsText(fs, "/mysql-test/main/selected.test")).toBe("selected"); + expect(() => fs.stat("/mysql-test/main/other.test")).toThrow(); + expect(readVfsText(fs, "/mysql-test/include/helper.inc")).toBe("include"); + expect(readVfsText(fs, "/mysql-test/std_data/fixture.dat")).toBe("fixture"); + }); + }); + + it("copies every .test entry in all-tests mode and ignores unrelated files", () => { + withMariaDbSource((root) => { + const fs = createFs(); + + expect(copyMariaDbTestSources(fs, root, { + includeAll: true, + curatedTests: [], + })).toBe(2); + + expect(readVfsText(fs, "/mysql-test/main/selected.test")).toBe("selected"); + expect(readVfsText(fs, "/mysql-test/main/other.test")).toBe("other"); + expect(() => fs.stat("/mysql-test/main/README")).toThrow(); + }); + }); + + it("rejects a missing declared curated test", () => { + withMariaDbSource((root) => { + unlinkSync(join(root, "main", "selected.test")); + + expect(() => copyMariaDbTestSources(createFs(), root, { + includeAll: false, + curatedTests: ["selected"], + })).toThrow(/selected\.test/); + }); + }); + + it("rejects a non-regular .test source", () => { + withMariaDbSource((root) => { + unlinkSync(join(root, "main", "selected.test")); + mkdirSync(join(root, "main", "selected.test")); + + expect(() => copyMariaDbTestSources(createFs(), root, { + includeAll: false, + curatedTests: ["selected"], + })).toThrow(/MariaDB test source entry is not a regular file/); + }); + }); + + it.each(["include", "std_data"] as const)( + "rejects a missing required %s fixture tree", + (fixtureName) => { + withMariaDbSource((root) => { + rmSync(join(root, fixtureName), { recursive: true }); + + expect(() => copyMariaDbTestSources(createFs(), root, { + includeAll: false, + curatedTests: ["selected"], + })).toThrow(); + }); + }, + ); + + it.each(["include", "std_data"] as const)( + "rejects an empty required %s fixture tree", + (fixtureName) => { + withMariaDbSource((root) => { + rmSync(join(root, fixtureName), { recursive: true }); + mkdirSync(join(root, fixtureName)); + + expect(() => copyMariaDbTestSources(createFs(), root, { + includeAll: false, + curatedTests: ["selected"], + })).toThrow(/Required MariaDB test fixture tree is empty/); + }); + }, + ); +}); diff --git a/host/test/mouse-integration.test.ts b/host/test/mouse-integration.test.ts index df4fa95de1..c12e2b6e20 100644 --- a/host/test/mouse-integration.test.ts +++ b/host/test/mouse-integration.test.ts @@ -71,7 +71,7 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { const workerAdapter = new NodeWorkerAdapter(); const workers = new Map>(); - const pid = 100; + let pid = 0; let stdout = ""; let resolveReady: () => void; @@ -114,18 +114,18 @@ describe.skipIf(!existsSync(mousetestBinary))("mouse integration", () => { }); 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, stdio: CAPTURED_STDIO }); + kernel.registerProcess(pid, memory, [channelOffset], { ptrWidth }); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes, memory, channelOffset, diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index a114606bfd..591a56c96e 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -1,7 +1,6 @@ // host/test/multi-worker.test.ts // -// Tests CentralizedKernelWorker process management: register/unregister, -// setNextChildPid, and fork flow. +// Tests CentralizedKernelWorker process management and fork flow. import { describe, it, expect, vi } from "vitest"; import { readFileSync } from "node:fs"; import { join } from "node:path"; @@ -27,6 +26,7 @@ import { CH_RETURN, CH_SYSCALL, HOST_INTERCEPTED_SYSCALLS, + PROCESS_STATE_EXITED, } from "../src/generated/abi"; const MAX_PAGES = 1024; // 64 MiB: enough to prove initial < maximum. @@ -53,7 +53,7 @@ function createProcessMemory(): { return { memory, channelOffset, layout }; } -function registerProcess( +function attachProcess( kw: CentralizedKernelWorker, pid: number, entry: ReturnType, @@ -62,10 +62,59 @@ function registerProcess( brkBase: entry.layout.brkBase, mmapBase: entry.layout.mmapBase, maxAddr: entry.layout.maxAddr, - stdio: CAPTURED_STDIO, }); } +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: 1, maximum: 1 }); + const kernelView = new DataView(kernelMemory.buffer); + 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; + (worker as any).scratchOffset = 0; + (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(() => { + 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, +): number { + const pid = kw.createProcess(CAPTURED_STDIO); + attachProcess(kw, pid, entry); + return pid; +} + describe("CentralizedKernelWorker Process Management", () => { it("does not deliver SIGEV_NONE as a signal-zero wakeup", () => { expect(shouldDeliverPosixTimerSignal(0)).toBe(false); @@ -73,19 +122,17 @@ describe("CentralizedKernelWorker Process Management", () => { expect(shouldDeliverPosixTimerSignal(65)).toBe(false); }); - it("retries fork allocation when the kernel still owns a zombie pid", async () => { + 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 }; - const kernelForkProcess = vi.fn((_parent: number, child: number) => - child === 100 ? -17 : 0, - ); + 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), { callbacks: { onFork }, - nextChildPid: 100, processes: new Map([[parentPid, { channels: [channel] }]]), + channelTids: new Map(), threadForkContexts: new Map(), sharedMappings: new Map(), tcpListenerTargets: new Map(), @@ -95,7 +142,6 @@ describe("CentralizedKernelWorker Process Management", () => { exports: { kernel_fork_process: kernelForkProcess, kernel_clear_fork_child: vi.fn(() => 0), - kernel_reset_signal_mask: vi.fn(() => 0), kernel_get_process_exit_signal: vi.fn(() => -1), }, }, @@ -104,8 +150,8 @@ describe("CentralizedKernelWorker Process Management", () => { (kw as any).handleFork(channel, [0]); await Promise.resolve(); - expect(kernelForkProcess).toHaveBeenNthCalledWith(1, parentPid, 100); - expect(kernelForkProcess).toHaveBeenNthCalledWith(2, parentPid, 101); + expect(kernelForkProcess).toHaveBeenCalledOnce(); + expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid); expect(onFork).toHaveBeenCalledWith(parentPid, 101, memory, undefined); expect(completeChannel).toHaveBeenCalledWith( channel, @@ -140,8 +186,8 @@ describe("CentralizedKernelWorker Process Management", () => { }; const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { callbacks: { onFork: vi.fn(() => forkLaunch) }, - nextChildPid: 100, - processes: new Map([[parentPid, { channels: [replacementChannel] }]]), + processes: new Map([[parentPid, { channels: [oldChannel] }]]), + channelTids: new Map(), threadForkContexts: new Map(), sharedMappings: new Map(), tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), @@ -157,15 +203,15 @@ describe("CentralizedKernelWorker Process Management", () => { completeChannel, kernelInstance: { exports: { - kernel_fork_process: vi.fn(() => 0), + kernel_fork_process: vi.fn(() => 100), kernel_clear_fork_child: vi.fn(() => 0), - kernel_reset_signal_mask: vi.fn(() => 0), kernel_get_process_exit_signal: vi.fn(() => -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(); @@ -189,8 +235,8 @@ describe("CentralizedKernelWorker Process Management", () => { const removeProcess = vi.fn(() => 0); const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { callbacks: { onFork: vi.fn(() => Promise.reject(new Error("launch failed"))) }, - nextChildPid: 100, processes: new Map([[parentPid, { channels: [channel] }]]), + channelTids: new Map(), threadForkContexts: new Map(), tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), epollInterests: new Map(), @@ -198,9 +244,8 @@ describe("CentralizedKernelWorker Process Management", () => { deactivateProcess, kernelInstance: { exports: { - kernel_fork_process: vi.fn(() => 0), + kernel_fork_process: vi.fn(() => 100), kernel_clear_fork_child: vi.fn(() => 0), - kernel_reset_signal_mask: vi.fn(() => 0), kernel_remove_process: removeProcess, kernel_get_process_exit_signal: vi.fn(() => -1), }, @@ -223,6 +268,60 @@ describe("CentralizedKernelWorker Process Management", () => { ); }); + 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 }; + const completeChannel = vi.fn(); + const deactivateProcess = vi.fn(); + const removeProcess = vi.fn(() => -5); + const notifyHostProcessCrashed = vi.fn(); + const onExit = vi.fn(); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + 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), + }, + }, + }) 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(childPid); + expect(removeProcess).toHaveBeenCalledWith(childPid); + expect(notifyHostProcessCrashed).toHaveBeenCalledWith(parentPid, 11); + expect(onExit).toHaveBeenCalledWith(parentPid, 139); + expect(channel.handling).toBe(true); + expect(completeChannel).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + "[handleSyscall] FATAL could not roll back fork child 100: " + + "Kernel could not remove process 100: errno 5", + ); + } finally { + error.mockRestore(); + } + }); + it("completes pthread SYS_EXIT channels (clearing the exiting guest's atomic-wait waiter) even when the host terminates the worker", () => { // Regression guard for the reused-slot notify-steal deadlock. On thread // exit the kernel must flip the channel status word off CH_PENDING @@ -265,6 +364,7 @@ describe("CentralizedKernelWorker Process Management", () => { [`${pid}:${threadChannelOffset}`, { fnPtr: 1, argPtr: 2 }], ]), threadCtidPtrs: new Map(), + activeChannels: [channel], notifyThreadExit: vi.fn(), removeChannel: vi.fn(), completeChannelRaw, @@ -307,6 +407,7 @@ describe("CentralizedKernelWorker Process Management", () => { channelTids: new Map([[`${pid}:${threadChannelOffset}`, tid]]), threadForkContexts: new Map(), threadCtidPtrs: new Map(), + activeChannels: [channel], notifyThreadExit: vi.fn(), removeChannel: vi.fn(), completeChannelRaw, @@ -320,6 +421,47 @@ describe("CentralizedKernelWorker Process Management", () => { expect(channel.handling).toBe(false); }); + it("rejects pthread exit when the channel lost its kernel-allocated TID", () => { + const pid = 124; + 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 expected = + `No kernel-validated TID for non-main channel ${threadChannel.channelOffset} ` + + `of process ${pid}`; + + expect(() => (kw as any).handleExit( + threadChannel, + ABI_SYSCALLS.Exit, + [0], + )).toThrow(expected); + expect(finalizeThreadExit).not.toHaveBeenCalled(); + expect(completeChannelRaw).not.toHaveBeenCalled(); + }); + it("clears pthread child TID when forced thread cleanup skips guest SYS_EXIT", () => { const pid = 125; const mainChannelOffset = WASM_PAGE_SIZE; @@ -388,16 +530,18 @@ describe("CentralizedKernelWorker Process Management", () => { }); const kernelView = new DataView(kernelMemory.buffer); const threadCtidPtrs = new Map(); - let resolveClone!: (value: number) => void; - const onClone = vi.fn(() => { + let resolveClone!: () => void; + let kw!: CentralizedKernelWorker; + const onClone = vi.fn((attachment) => { expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(ctidPtr); - return new Promise((resolve) => { + kw.attachThreadChannel(attachment, 2 * WASM_PAGE_SIZE); + return new Promise((resolve) => { resolveClone = resolve; }); }); const channel = { pid, channelOffset: mainChannelOffset, memory }; - const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { callbacks: { onClone }, kernel: { toKernelPtr(value: number | bigint): number { @@ -407,14 +551,22 @@ describe("CentralizedKernelWorker Process Management", () => { kernelMemory, scratchOffset: 0, currentHandlePid: 0, + activeChannels: [channel], + channelTids: new Map(), + execHandoffPids: new Set(), + hostReaped: new Set(), processes: new Map([ - [pid, { channels: [channel] }], + [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(() => { kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); kernelView.setUint32(CH_ERRNO, 0, true); @@ -426,12 +578,12 @@ describe("CentralizedKernelWorker Process Management", () => { (kw as any).handleClone( channel, - [0, stackPtr, 0, tlsPtr, ctidPtr, 0], + [0x00200000, stackPtr, 0, tlsPtr, ctidPtr, 0], ); expect(onClone).toHaveBeenCalledTimes(1); expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(ctidPtr); - resolveClone(tid); + resolveClone(); await Promise.resolve(); expect((kw as any).completeChannel).toHaveBeenCalled(); }); @@ -458,8 +610,8 @@ describe("CentralizedKernelWorker Process Management", () => { const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); const kernelView = new DataView(kernelMemory.buffer); const threadCtidPtrs = new Map(); - let resolveClone!: (value: number) => void; - const onClone = vi.fn(() => new Promise((resolve) => { + let resolveClone!: () => void; + const onClone = vi.fn(() => new Promise((resolve) => { resolveClone = resolve; })); const completeChannel = vi.fn(); @@ -490,7 +642,7 @@ describe("CentralizedKernelWorker Process Management", () => { ); (kw as any).processes.set(pid, { channels: [newChannel] }); threadCtidPtrs.set(`${pid}:${tid}`, 0x00050000); - resolveClone(tid); + resolveClone(); await Promise.resolve(); expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(0x00050000); @@ -514,10 +666,11 @@ describe("CentralizedKernelWorker Process Management", () => { }, kernelInstance: { exports: { - kernel_create_process_with_stdio: vi.fn(() => 0), + kernel_get_process_state: vi.fn(() => 0), kernel_set_brk_base: vi.fn(() => 0), kernel_set_mmap_base: vi.fn(() => 0), kernel_set_max_addr: setMaxAddr, + kernel_validate_task: vi.fn(() => 0), }, }, }) as CentralizedKernelWorker; @@ -533,15 +686,41 @@ describe("CentralizedKernelWorker Process Management", () => { brkBase: 4 * WASM_PAGE_SIZE, mmapBase: 4 * WASM_PAGE_SIZE, maxAddr, - stdio: CAPTURED_STDIO, }); - kw.addChannel(321, highThreadChannelOffset, 7); + kw.attachThreadChannel( + issueThreadAttachment(kw, 321, 7), + highThreadChannelOffset, + ); expect(setMaxAddr).toHaveBeenCalledTimes(1); expect(setMaxAddr).toHaveBeenCalledWith(321, maxAddr); }); - it("requires explicit stdio when creating a kernel process", () => { + 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 memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + + expect(() => kw.registerProcess(900, memory, [4 * WASM_PAGE_SIZE])).toThrow( + "Cannot register unknown kernel process 900", + ); + }); + + it("rejects attaching host state to an exited kernel process", () => { const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { initialized: true, hostReaped: new Set(), @@ -550,7 +729,7 @@ describe("CentralizedKernelWorker Process Management", () => { usePolling: true, kernelInstance: { exports: { - kernel_create_process_with_stdio: vi.fn(() => 0), + kernel_get_process_state: vi.fn(() => PROCESS_STATE_EXITED), }, }, }) as CentralizedKernelWorker; @@ -561,10 +740,124 @@ describe("CentralizedKernelWorker Process Management", () => { }); expect(() => kw.registerProcess(900, memory, [4 * WASM_PAGE_SIZE])).toThrow( - "registerProcess requires explicit stdio", + "Cannot register inactive kernel process 900", + ); + }); + + 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 memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + + expect(() => kw.registerProcess(1, memory, [4 * WASM_PAGE_SIZE])).toThrow( + "Cannot register the kernel-reserved init process", ); }); + it("rejects a thread channel whose TID is not owned by the kernel process", () => { + const pid = 321; + const mainChannelOffset = 4 * WASM_PAGE_SIZE; + const threadChannelOffset = 8 * WASM_PAGE_SIZE; + const validateTask = vi.fn(() => -3); + const memory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const mainChannel = { + 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; + + expect(() => kw.attachThreadChannel( + issueThreadAttachment(kw, pid, 999), + threadChannelOffset, + )).toThrow( + "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); + }); + + it("rejects non-canonical or leader identities before attaching a thread channel", () => { + 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", + ); + } + 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 }, @@ -577,19 +870,35 @@ describe("CentralizedKernelWorker Process Management", () => { expect(proc1.memory.buffer.byteLength).toBeLessThan(MAX_PAGES * WASM_PAGE_SIZE); expect(proc2.memory.buffer.byteLength).toBeLessThan(MAX_PAGES * WASM_PAGE_SIZE); - // Register two processes - // PID 1 is reserved for the virtual init process; use PIDs >= 100. - registerProcess(kw, 100, proc1); - registerProcess(kw, 101, proc2); + const firstPid = createAndRegisterProcess(kw, proc1); + const secondPid = createAndRegisterProcess(kw, proc2); + expect(firstPid).not.toBe(secondPid); + + // 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); // Unregister both without error - kw.unregisterProcess(100); - kw.unregisterProcess(101); - expect((kw as any).processes.has(100)).toBe(false); - expect((kw as any).processes.has(101)).toBe(false); + 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`]); + 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 === 100 || ch.pid === 101), + (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); // Unregistering non-existent pid should not throw kw.unregisterProcess(999); @@ -605,9 +914,8 @@ describe("CentralizedKernelWorker Process Management", () => { ); await kw.init(loadKernelWasm()); - const pid = 150; const procMemory = createProcessMemory(); - registerProcess(kw, pid, procMemory); + const pid = createAndRegisterProcess(kw, procMemory); // Issue open(2) directly through the real kernel export so the Rust // Process owns the exact host handle that unregisterProcess must release. @@ -626,6 +934,9 @@ describe("CentralizedKernelWorker Process Management", () => { 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); @@ -651,9 +962,8 @@ describe("CentralizedKernelWorker Process Management", () => { ); await kw.init(loadKernelWasm()); - const pid = 151; const procMemory = createProcessMemory(); - registerProcess(kw, pid, procMemory); + 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; @@ -669,6 +979,9 @@ describe("CentralizedKernelWorker Process Management", () => { 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); @@ -729,31 +1042,33 @@ describe("CentralizedKernelWorker Process Management", () => { ); await kw.init(loadKernelWasm()); - for (let pid = 200; pid < 240; pid++) { + const pids: number[] = []; + for (let launch = 0; launch < 40; launch++) { const proc = createProcessMemory(); expect(proc.memory.buffer.byteLength).toBeLessThan(MAX_PAGES * WASM_PAGE_SIZE); - registerProcess(kw, pid, proc); + const pid = createAndRegisterProcess(kw, proc); + pids.push(pid); kw.unregisterProcess(pid); } expect((kw as any).activeChannels.length).toBe(0); - for (let pid = 200; pid < 240; pid++) { + for (const pid of pids) { expect((kw as any).processes.has(pid)).toBe(false); } }); - it("should set next child PID for fork", async () => { + it("keeps process allocation monotonic after host unregister", async () => { const kw = new CentralizedKernelWorker( { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, new NodePlatformIO(), ); await kw.init(loadKernelWasm()); - kw.setNextChildPid(42); - - const proc = createProcessMemory(); - registerProcess(kw, 100, proc); - kw.unregisterProcess(100); + const firstPid = createAndRegisterProcess(kw, createProcessMemory()); + kw.unregisterProcess(firstPid); + const secondPid = createAndRegisterProcess(kw, createProcessMemory()); + expect(secondPid).toBeGreaterThan(firstPid); + kw.unregisterProcess(secondPid); }); it("should throw when registering duplicate PID", async () => { @@ -766,10 +1081,12 @@ describe("CentralizedKernelWorker Process Management", () => { const proc1 = createProcessMemory(); const proc2 = createProcessMemory(); - registerProcess(kw, 100, proc1); - expect(() => registerProcess(kw, 100, proc2)).toThrow(); + const pid = createAndRegisterProcess(kw, proc1); + expect(() => attachProcess(kw, pid, proc2)).toThrow( + `Process ${pid} is already registered with the host`, + ); - kw.unregisterProcess(100); + kw.unregisterProcess(pid); }); it("should throw when registering before init", () => { @@ -779,7 +1096,7 @@ describe("CentralizedKernelWorker Process Management", () => { ); const proc = createProcessMemory(); - expect(() => registerProcess(kw, 100, proc)).toThrow( + expect(() => attachProcess(kw, 100, proc)).toThrow( "Kernel not initialized", ); }); diff --git a/host/test/node-lazy-archive-runtime.test.ts b/host/test/node-lazy-archive-runtime.test.ts index fd15dc2901..6414fd978e 100644 --- a/host/test/node-lazy-archive-runtime.test.ts +++ b/host/test/node-lazy-archive-runtime.test.ts @@ -64,6 +64,9 @@ describe.skipIf(!available)("Node lazy archive runtime paths", () => { const unboundArchive = gzipSync(unboundTar); const boundUrl = "https://github.com/example/project/releases/download/v1/bound.tar.gz"; + const boundRelativeUrl = "bound.tar.gz"; + const boundUrlBase = + "https://github.com/example/project/releases/download/v1"; const unboundUrl = "https://github.com/example/project/releases/download/v1/unbound.tar.gz"; const fs = MemoryFileSystem.create(new SharedArrayBuffer(32 * 1024 * 1024)); @@ -73,7 +76,7 @@ describe.skipIf(!available)("Node lazy archive runtime paths", () => { ...integrity(boundArchive), expandedBytes: boundTar.byteLength, sourceEntryCount: 1, - transports: [boundUrl], + transports: [boundRelativeUrl], }, [{ vfsPath: "/etc/closed-bound", sourcePath: "etc/closed-bound", @@ -102,6 +105,7 @@ describe.skipIf(!available)("Node lazy archive runtime paths", () => { let stdout = ""; const host = new NodeKernelHost({ rootfsImage: await fs.saveImage(), + rootfsLazyUrlBase: boundUrlBase, rootfsLazyAssets: [{ url: boundUrl, sha256: integrity(boundArchive).sha256, diff --git a/host/test/node-process-teardown-ordering.test.ts b/host/test/node-process-teardown-ordering.test.ts new file mode 100644 index 0000000000..fc9df89f4f --- /dev/null +++ b/host/test/node-process-teardown-ordering.test.ts @@ -0,0 +1,37 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const nodeEntry = readFileSync( + join(repoRoot, "host/src/node-kernel-worker-entry.ts"), + "utf8", +); + +function functionSource(name: string, nextName: string): string { + const start = nodeEntry.indexOf(`async function ${name}(`); + const end = nodeEntry.indexOf(`\nfunction ${nextName}(`, start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return nodeEntry.slice(start, end); +} + +describe("Node process Worker teardown ordering", () => { + it("does not let a trailing Worker event overtake an in-flight kernel exit teardown", () => { + const finalize = functionSource( + "finalizeProcessWorker", + "processWorkerErrorDisposition", + ); + const inFlightGuard = finalize.indexOf("processTeardowns.has(worker)"); + const crashNotification = finalize.indexOf("kernelWorker.notifyHostProcessCrashed"); + const deactivation = finalize.indexOf("kernelWorker.deactivateProcess"); + + expect(inFlightGuard).toBeGreaterThanOrEqual(0); + expect(finalize).toMatch( + /if \(processTeardowns\.has\(worker\)\) \{\s*reportProcessExit\(pid, exitStatus\);\s*return;\s*\}/s, + ); + expect(inFlightGuard).toBeLessThan(crashNotification); + expect(inFlightGuard).toBeLessThan(deactivation); + }); +}); diff --git a/host/test/package-deferred-tree.test.ts b/host/test/package-deferred-tree.test.ts new file mode 100644 index 0000000000..4ab475dec9 --- /dev/null +++ b/host/test/package-deferred-tree.test.ts @@ -0,0 +1,367 @@ +import { zipSync, type Zippable } from "fflate"; +import { describe, expect, it, vi } from "vitest"; + +import { + assertPackageDeferredZipTreeState, + derivePackageDeferredZipTree, + materializePackageDeferredZipTree, + parsePackageDeferredZipTreeSpec, + registerPackageDeferredZipTree, + type PackageDeferredZipTreeSpec, +} from "../src/vfs/package-deferred-tree"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { EIO, SFSError } from "../src/vfs/sharedfs-vendor"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +const SPEC = { + schema: 1, + kind: "kandelo-package-deferred-zip-tree", + id: "shell/homebrew-bootstrap", + content_role: "source-tree", + package: { + name: "shell", + output: "homebrew-bootstrap.zip", + }, + archive: { + url: "homebrew-bootstrap.zip", + mode_policy: "portable-posix-v1", + }, + mount_prefix: "/home/linuxbrew/.linuxbrew", + owner: { + uid: 1000, + gid: 1000, + }, + activation: { + mode: "first-use", + capabilities: ["homebrew:bootstrap"], + roots: ["/home/linuxbrew/.linuxbrew/bin/brew"], + }, +} as const satisfies PackageDeferredZipTreeSpec; + +describe("package deferred ZIP trees", () => { + it("derives one canonical descriptor from the exact package output", () => { + const archive = packageArchive(); + const first = derivePackageDeferredZipTree(SPEC, archive); + const second = derivePackageDeferredZipTree( + structuredClone(SPEC), + new Uint8Array(archive), + ); + + expect(second.descriptor).toEqual(first.descriptor); + expect(second.descriptorBytes).toEqual(first.descriptorBytes); + expect(second.descriptorSha256).toBe(first.descriptorSha256); + expect(first.descriptor.archive).toMatchObject({ + decoder: "zip-v1", + media_type: "application/zip", + bytes: archive.byteLength, + source_entry_count: 6, + }); + expect(first.content.modePolicy).toBe("portable-posix-v1"); + expect(first.descriptor.inventory).toEqual([ + expect.objectContaining({ + vfs_path: "/home/linuxbrew/.linuxbrew/bin", + type: "directory", + mode: 0o755, + }), + expect.objectContaining({ + vfs_path: "/home/linuxbrew/.linuxbrew/bin/brew", + type: "file", + mode: 0o755, + size: 12, + }), + expect.objectContaining({ + vfs_path: "/home/linuxbrew/.linuxbrew/bin/brew-link", + type: "symlink", + mode: 0o777, + target: "brew", + }), + expect.objectContaining({ + vfs_path: "/home/linuxbrew/.linuxbrew/Library", + type: "directory", + }), + expect.objectContaining({ + vfs_path: "/home/linuxbrew/.linuxbrew/Library/Homebrew", + type: "directory", + }), + expect.objectContaining({ + vfs_path: "/home/linuxbrew/.linuxbrew/Library/Homebrew/global.rb", + type: "file", + mode: 0o644, + }), + ]); + expect(decoder.decode(first.descriptorBytes).endsWith("\n")).toBe(true); + }); + + it("fetches one whole group on first use and never refetches it", async () => { + const archive = packageArchive(); + const derived = derivePackageDeferredZipTree(SPEC, archive); + const fs = packageFs(); + registerPackageDeferredZipTree(fs, derived); + assertPackageDeferredZipTreeState(fs, derived, "deferred"); + for (const entry of derived.entries) { + expect(fs.lstat(entry.vfsPath)).toMatchObject({ uid: 1000, gid: 1000 }); + } + const fetcher = vi.fn(async (url: string) => { + expect(url).toBe("homebrew-bootstrap.zip"); + return new Response(archive, { + headers: { "content-length": String(archive.byteLength) }, + }); + }); + fs.setLazyFetcher(fetcher); + + expect(fs.lstat(`${SPEC.mount_prefix}/bin/brew`)).toMatchObject({ + mode: expect.any(Number), + uid: 1000, + gid: 1000, + size: 12, + }); + expect(fs.stat(`${SPEC.mount_prefix}/bin/brew`).size).toBe(12); + expect(fs.isPathDeferred(`${SPEC.mount_prefix}/bin/brew`)).toBe(true); + const directory = fs.opendir(`${SPEC.mount_prefix}/Library/Homebrew`); + try { + expect(fs.readdir(directory)).toBeTruthy(); + } finally { + fs.closedir(directory); + } + expect(fetcher).not.toHaveBeenCalled(); + + await expect(fs.preparePath(`${SPEC.mount_prefix}/bin/brew`)).resolves.toBe(true); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(readFile(fs, `${SPEC.mount_prefix}/bin/brew`)).toBe("#!/bin/brew\n"); + expect(readFile(fs, `${SPEC.mount_prefix}/Library/Homebrew/global.rb`)).toBe( + "GLOBAL = true\n", + ); + expect(fs.readlink(`${SPEC.mount_prefix}/bin/brew-link`)).toBe("brew"); + expect(fs.isPathDeferred(`${SPEC.mount_prefix}/bin/brew`)).toBe(false); + assertPackageDeferredZipTreeState(fs, derived, "materialized"); + + await expect( + fs.preparePath(`${SPEC.mount_prefix}/Library/Homebrew/global.rb`), + ).resolves.toBe(false); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + + it("preserves guest-external symlink text without following it", () => { + const archive = packageArchive("../../../../usr/bin/env"); + const derived = derivePackageDeferredZipTree(SPEC, archive); + const fs = packageFs(); + fs.mkdir("/usr", 0o755); + fs.mkdir("/usr/bin", 0o755); + fs.createFileWithOwner( + "/usr/bin/env", + 0o755, + 0, + 0, + encoder.encode("base image\n"), + ); + + registerPackageDeferredZipTree(fs, derived); + + expect(fs.readlink(`${SPEC.mount_prefix}/bin/brew-link`)).toBe( + "../../../../usr/bin/env", + ); + expect(readFile(fs, `${SPEC.mount_prefix}/bin/brew-link`)).toBe( + "base image\n", + ); + expect(readFile(fs, "/usr/bin/env")).toBe("base image\n"); + expect(fs.lstat("/usr/bin/env")).toMatchObject({ uid: 0, gid: 0 }); + }); + + it("keeps every member deferred after a failed fetch and coalesces the retry", async () => { + const archive = packageArchive(); + const derived = derivePackageDeferredZipTree(SPEC, archive); + const fs = packageFs(); + registerPackageDeferredZipTree(fs, derived); + const wrong = new Uint8Array(archive); + wrong[0] ^= 1; + let served = wrong; + const fetcher = vi.fn(async () => new Response(served, { + headers: { "content-length": String(served.byteLength) }, + })); + fs.setLazyFetcher(fetcher); + + await expect(Promise.all([ + fs.preparePath(`${SPEC.mount_prefix}/bin/brew`), + fs.preparePath(`${SPEC.mount_prefix}/Library/Homebrew/global.rb`), + ])).rejects.toThrow(/SHA-256/); + expect(fetcher).toHaveBeenCalledTimes(1); + assertPackageDeferredZipTreeState(fs, derived, "deferred"); + expect(fs.isPathDeferred(`${SPEC.mount_prefix}/bin/brew`)).toBe(true); + expect(fs.isPathDeferred( + `${SPEC.mount_prefix}/Library/Homebrew/global.rb`, + )).toBe(true); + + served = archive; + await expect(Promise.all([ + fs.preparePath(`${SPEC.mount_prefix}/bin/brew`), + fs.preparePath(`${SPEC.mount_prefix}/Library/Homebrew/global.rb`), + ])).resolves.toEqual([true, true]); + expect(fetcher).toHaveBeenCalledTimes(2); + assertPackageDeferredZipTreeState(fs, derived, "materialized"); + }); + + it("pre-materializes the identical descriptor without using transport", async () => { + const archive = packageArchive(); + const lazy = derivePackageDeferredZipTree(SPEC, archive); + const eager = derivePackageDeferredZipTree(SPEC, archive); + const fs = packageFs(); + const registered = registerPackageDeferredZipTree(fs, eager); + await materializePackageDeferredZipTree(fs, registered, archive); + const fetcher = vi.fn(async () => { + throw new Error("eager package tree must not fetch"); + }); + fs.setLazyFetcher(fetcher); + + expect(eager.descriptorSha256).toBe(lazy.descriptorSha256); + expect(eager.descriptorBytes).toEqual(lazy.descriptorBytes); + expect(fs.exportLazyArchiveEntries()).toEqual([]); + expect(fs.isPathDeferred(`${SPEC.mount_prefix}/bin/brew`)).toBe(false); + expect(readFile(fs, `${SPEC.mount_prefix}/bin/brew`)).toBe("#!/bin/brew\n"); + assertPackageDeferredZipTreeState(fs, eager, "materialized"); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("fails closed on invalid recipes, incomplete archives, and collisions", () => { + expect(() => parsePackageDeferredZipTreeSpec({ + ...SPEC, + unexpected: true, + })).toThrow(/unsupported fields/); + expect(() => parsePackageDeferredZipTreeSpec({ + ...SPEC, + activation: { ...SPEC.activation, roots: ["/outside"] }, + })).toThrow(/escapes its mount/); + expect(() => parsePackageDeferredZipTreeSpec({ + ...SPEC, + owner: { uid: 0xffff_ffff, gid: 1000 }, + })).toThrow(/spec is invalid/); + + const incomplete = zipSync({ + "missing/parent/file": encoder.encode("bad"), + }); + expect(() => derivePackageDeferredZipTree(SPEC, incomplete)).toThrow( + /omits directory entry/, + ); + + const archive = packageArchive(); + const derived = derivePackageDeferredZipTree(SPEC, archive); + expect(() => packageFs().registerLazyTree( + { ...derived.content, modePolicy: "host-mode" } as unknown as typeof derived.content, + derived.entries, + SPEC.mount_prefix, + SPEC.activation, + )).toThrow(/mode policy is invalid/); + const fs = packageFs(); + fs.mkdir(`${SPEC.mount_prefix}/bin`, 0o700); + fs.chown(`${SPEC.mount_prefix}/bin`, 1000, 1000); + expect(() => registerPackageDeferredZipTree(fs, derived)).toThrow( + /collides with the base/, + ); + + const blockedFs = MemoryFileSystem.create( + new SharedArrayBuffer(32 * 1024 * 1024), + ); + blockedFs.symlink("elsewhere", "/blocked"); + const blockedSpec = { + ...SPEC, + mount_prefix: "/blocked/tree", + activation: { + ...SPEC.activation, + roots: ["/blocked/tree/bin/brew"], + }, + } satisfies PackageDeferredZipTreeSpec; + expect(() => registerPackageDeferredZipTree( + blockedFs, + derivePackageDeferredZipTree(blockedSpec, archive), + )).toThrow(/ancestor collides/); + }); + + it("publishes no deferred metadata before package ownership succeeds", () => { + const archive = packageArchive(); + const derived = derivePackageDeferredZipTree(SPEC, archive); + const fs = packageFs(); + const lchown = vi.spyOn(fs, "lchown").mockImplementationOnce(() => { + throw new SFSError(EIO); + }); + + expect(() => registerPackageDeferredZipTree(fs, derived)).toThrow(SFSError); + + lchown.mockRestore(); + expect(fs.exportLazyArchiveEntries()).toEqual([]); + expect(fs.isPathDeferred(`${SPEC.mount_prefix}/bin/brew`)).toBe(false); + }); + + it("rejects changed bytes before direct materialization", async () => { + const archive = packageArchive(); + const derived = derivePackageDeferredZipTree(SPEC, archive); + const fs = packageFs(); + const registered = registerPackageDeferredZipTree(fs, derived); + const changed = new Uint8Array(archive); + changed[0] ^= 1; + await expect( + materializePackageDeferredZipTree(fs, registered, changed), + ).rejects.toThrow(/changed identity/); + expect(fs.isPathDeferred(`${SPEC.mount_prefix}/bin/brew`)).toBe(true); + }); + + it("propagates namespace lookup errors instead of treating them as absence", () => { + const archive = packageArchive(); + const derived = derivePackageDeferredZipTree(SPEC, archive); + const fs = packageFs(); + const originalLstat = fs.lstat.bind(fs); + const lstat = vi.spyOn(fs, "lstat").mockImplementation((path) => { + if (path === `${SPEC.mount_prefix}/bin`) throw new SFSError(EIO); + return originalLstat(path); + }); + let caught: unknown; + try { + registerPackageDeferredZipTree(fs, derived); + } catch (error) { + caught = error; + } finally { + lstat.mockRestore(); + } + expect(caught).toBeInstanceOf(SFSError); + expect((caught as SFSError).code).toBe(EIO); + expect(fs.exportLazyArchiveEntries()).toEqual([]); + expect(fs.isPathDeferred(`${SPEC.mount_prefix}/bin/brew`)).toBe(false); + }); +}); + +function packageArchive(symlinkTarget = "brew"): Uint8Array { + const zippable: Zippable = { + "bin/": zipEntry(new Uint8Array(), 0o040700), + "bin/brew": zipEntry(encoder.encode("#!/bin/brew\n"), 0o100711), + "bin/brew-link": zipEntry(encoder.encode(symlinkTarget), 0o120700), + "Library/": zipEntry(new Uint8Array(), 0o040750), + "Library/Homebrew/": zipEntry(new Uint8Array(), 0o040777), + "Library/Homebrew/global.rb": zipEntry(encoder.encode("GLOBAL = true\n"), 0o100600), + }; + return zipSync(zippable, { level: 9 }); +} + +function zipEntry(bytes: Uint8Array, mode: number): Zippable[string] { + return [bytes, { os: 3, attrs: ((mode << 16) >>> 0) }]; +} + +function packageFs(): MemoryFileSystem { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(32 * 1024 * 1024)); + for (const path of ["/home", "/home/linuxbrew", SPEC.mount_prefix]) { + fs.mkdir(path, 0o755); + fs.chown(path, 1000, 1000); + } + return fs; +} + +function readFile(fs: MemoryFileSystem, path: string): string { + const stat = fs.stat(path); + const fd = fs.open(path, 0, 0); + try { + const bytes = new Uint8Array(stat.size); + expect(fs.read(fd, bytes, null, bytes.byteLength)).toBe(bytes.byteLength); + return decoder.decode(bytes); + } finally { + fs.close(fd); + } +} diff --git a/host/test/popen-daemon-regression.test.ts b/host/test/popen-daemon-regression.test.ts index 4e7d8be4b5..8da94c9029 100644 --- a/host/test/popen-daemon-regression.test.ts +++ b/host/test/popen-daemon-regression.test.ts @@ -5,8 +5,8 @@ * Initial PID must not be 1. daemon-failure checks `getppid() != 1` as the * "daemon did not detach" condition. If the test harness spawns user * programs at pid 1, forked children see ppid=1 and the test misfires. - * (Regressed when PR #289 moved PID allocation into the kernel worker - * and reset the counter to 1 — see host/src/node-kernel-worker-entry.ts.) + * (Regressed when PR #289 reset the former host allocator to 1.) The Rust + * kernel now reserves PID 1 and allocates every user-process PID itself. */ import { describe, it, expect } from "vitest"; import { join, dirname } from "node:path"; diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 9077147b46..70137dcc92 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -6,6 +6,7 @@ import { CH_SIG_FLAGS, CH_SIG_SIGNUM, CH_STATUS, + CH_SYSCALL, CHANNEL_STATUS_COMPLETE, CHANNEL_STATUS_PENDING, KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, @@ -47,6 +48,7 @@ describe("Rust-owned process wait lifecycle", () => { ); const waitChildPoll = vi.fn(( _parentPid: number, + _callerTid: number, _targetPid: number, _eventMask: number, _flags: number, @@ -71,12 +73,10 @@ describe("Rust-owned process wait lifecycle", () => { worker.completeWaitpid = vi.fn(); const rusagePtr = 512; - worker.handleWaitpid( - createChannel(7, processMemory), - [-1, statusPtr, 0, rusagePtr], - ); + const channel = registerMainChannel(worker, createChannel(7, processMemory)); + worker.handleWaitpid(channel, [-1, statusPtr, 0, rusagePtr]); - expect(waitChildPoll).toHaveBeenCalledWith(7, -1, WAIT_EVENT_EXITED, 0, 128); + expect(waitChildPoll).toHaveBeenCalledWith(7, 7, -1, WAIT_EVENT_EXITED, 0, 128); expect(reapExitedChild).not.toHaveBeenCalled(); expect(new DataView(processMemory.buffer).getInt32(statusPtr, true)).toBe(waitStatus); expect(new Uint8Array( @@ -100,6 +100,7 @@ describe("Rust-owned process wait lifecycle", () => { worker.completeWaitpid = vi.fn(); const channel = createChannel(7, createSharedMemory()); + registerMainChannel(worker, channel); worker.handleWaitpid(channel, [-1, 0, 0, 0]); expect(worker.completeWaitpid).not.toHaveBeenCalled(); @@ -176,7 +177,8 @@ describe("Rust-owned process wait lifecycle", () => { worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); - worker.handleWaitpid(createChannel(7, createSharedMemory()), [-1, 0, WAIT_WNOHANG, 0]); + const channel = registerMainChannel(worker, createChannel(7, createSharedMemory())); + worker.handleWaitpid(channel, [-1, 0, WAIT_WNOHANG, 0]); expect(worker.waitingForChild).toEqual([]); expect(worker.completeWaitpid).toHaveBeenCalledWith( @@ -194,9 +196,11 @@ describe("Rust-owned process wait lifecycle", () => { worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); - worker.handleWaitpid(createChannel(7, createSharedMemory()), [-1, 0, WAIT_WNOHANG, 0]); + const channel = registerMainChannel(worker, createChannel(7, createSharedMemory())); + worker.handleWaitpid(channel, [-1, 0, WAIT_WNOHANG, 0]); expect(waitChildPoll).toHaveBeenCalledWith( + 7, 7, -1, WAIT_EVENT_EXITED, @@ -239,6 +243,7 @@ describe("Rust-owned process wait lifecycle", () => { 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, @@ -258,9 +263,11 @@ describe("Rust-owned process wait lifecycle", () => { worker.completeWaitid = vi.fn(); const args = [1, 44, siginfoPtr, WAIT_WSTOPPED | WAIT_WNOWAIT, rusagePtr]; - worker.handleWaitid(createChannel(7, processMemory), args); + const channel = registerMainChannel(worker, createChannel(7, processMemory)); + worker.handleWaitid(channel, args); expect(waitChildPoll).toHaveBeenCalledWith( + 7, 7, 44, WAIT_EVENT_STOPPED, @@ -293,6 +300,7 @@ describe("Rust-owned process wait lifecycle", () => { const siginfoPtr = 512; const waitChildPoll = vi.fn(( _parentPid: number, + _callerTid: number, _targetPid: number, _eventMask: number, _flags: number, @@ -346,7 +354,8 @@ describe("Rust-owned process wait lifecycle", () => { worker.completeWaitid = vi.fn(); const args = [0, 0, siginfoPtr, WAIT_WEXITED | WAIT_WNOHANG, rusagePtr]; - worker.handleWaitid(createChannel(7, processMemory), args); + const channel = registerMainChannel(worker, createChannel(7, processMemory)); + worker.handleWaitid(channel, args); expect(new Uint8Array(processMemory.buffer, siginfoPtr, 128)) .toEqual(new Uint8Array(128)); @@ -573,6 +582,7 @@ describe("Rust-owned process wait lifecycle", () => { worker.recheckDeferredWaitpids(); expect(waitChildPoll).toHaveBeenCalledWith( + 7, 7, 0, WAIT_EVENT_EXITED, @@ -621,6 +631,7 @@ describe("Rust-owned process wait lifecycle", () => { let pollCount = 0; const waitChildPoll = vi.fn(( _parentPid: number, + _callerTid: number, _targetPid: number, _eventMask: number, _flags: number, @@ -642,6 +653,7 @@ describe("Rust-owned process wait lifecycle", () => { channels: [first, second], memory: processMemory, }]]); + worker.channelTids = new Map([["7:256", 8]]); worker.completeWaitpid = vi.fn(); worker.waitingForChild = [ { @@ -678,6 +690,7 @@ describe("Rust-owned process wait lifecycle", () => { const running = createChannel(7, processMemory, 512); const waitChildPoll = vi.fn(( _parentPid: number, + _callerTid: number, targetPid: number, _eventMask: number, _flags: number, @@ -699,6 +712,10 @@ describe("Rust-owned process wait lifecycle", () => { channels: [first, second, running], memory: processMemory, }]]); + worker.channelTids = new Map([ + ["7:256", 8], + ["7:512", 9], + ]); worker.completeWaitid = vi.fn(); const options = WAIT_WEXITED | WAIT_WNOWAIT; const makeWaiter = (channel: any, pid: number, siginfoPtr: number) => ({ @@ -720,10 +737,10 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.waitingForChild).toEqual([runningWaiter]); expect(worker.completeWaitid).toHaveBeenCalledTimes(2); - expect(waitChildPoll.mock.calls.filter((call: unknown[]) => call[1] === 42)) + expect(waitChildPoll.mock.calls.filter((call: unknown[]) => call[2] === 42)) .toEqual([ - [7, 42, WAIT_EVENT_EXITED, WAIT_WNOWAIT, 128], - [7, 42, WAIT_EVENT_EXITED, WAIT_WNOWAIT, 128], + [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); @@ -741,7 +758,7 @@ describe("Rust-owned process wait lifecycle", () => { shared: true, }); const channel = createChannel(7, processMemory); - const dequeue = vi.fn((_pid: number, outPtr: number) => { + const dequeue = vi.fn((_pid: number, _tid: number, outPtr: number) => { const view = new DataView(kernelMemory.buffer); view.setUint32(outPtr, SIGUSR1, true); view.setUint32(outPtr + 8, SA_RESTART, true); @@ -968,7 +985,7 @@ describe("Rust-owned process wait lifecycle", () => { shared: true, }); const channel = createChannel(42, processMemory); - const dequeue = vi.fn((_pid: number, outPtr: number) => { + const dequeue = vi.fn((_pid: number, _tid: number, outPtr: number) => { new DataView(kernelMemory.buffer).setUint32(outPtr, SIGCONT, true); return SIGCONT; }); @@ -1017,8 +1034,8 @@ describe("Rust-owned process wait lifecycle", () => { let state = PROCESS_STATE_STOPPED; let currentTid = 0; let secondScans = 0; - const dequeue = vi.fn((_pid: number, outPtr: number) => { - if (currentTid === 101) { + const dequeue = vi.fn((_pid: number, tid: number, outPtr: number) => { + if (tid === 101) { new DataView(kernelMemory.buffer).setUint32(outPtr, SIGCONT, true); return SIGCONT; } @@ -1028,7 +1045,10 @@ describe("Rust-owned process wait lifecycle", () => { }); const worker = createWorkerHarness({ kernel_get_process_state: vi.fn(() => state), - kernel_set_current_tid: vi.fn((tid: number) => { currentTid = tid; }), + kernel_set_current_tid: vi.fn((_pid: number, tid: number) => { + currentTid = tid; + return 0; + }), kernel_dequeue_signal: dequeue, kernel_get_process_exit_signal: vi.fn(() => -1), }); @@ -1092,7 +1112,7 @@ describe("Rust-owned process wait lifecycle", () => { const channel = createChannel(7, processMemory); markPending(channel); let state = PROCESS_STATE_STOPPED; - const dequeue = vi.fn((_pid: number, outPtr: number) => { + const dequeue = vi.fn((_pid: number, _tid: number, outPtr: number) => { new DataView(kernelMemory.buffer).setUint32(outPtr, SIGUSR1, true); return SIGUSR1; }); @@ -1296,6 +1316,227 @@ describe("Rust-owned process wait lifecycle", () => { expect(onExit).toHaveBeenCalledWith(42, 137); }); + it("settles only the exit handshake after Rust has reaped a process", () => { + const memory = createSharedMemory(); + const channel = createChannel(42, memory); + const setCurrentTid = vi.fn(() => -3); + const handleChannel = vi.fn(); + const worker = createWorkerHarness({ + kernel_set_current_tid: setCurrentTid, + kernel_handle_channel: handleChannel, + }); + worker.processes = new Map([[42, { channels: [channel], memory }]]); + worker.hostReaped = new Set([42]); + worker.completeChannelRaw = vi.fn(); + worker.relistenChannel = vi.fn(); + const processView = new DataView(memory.buffer, channel.channelOffset); + + 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); + + processView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Exit, true); + worker.handleSyscall(channel); + + expect(worker.completeChannelRaw).toHaveBeenCalledTimes(2); + expect(worker.relistenChannel).toHaveBeenCalledOnce(); + expect(setCurrentTid).not.toHaveBeenCalled(); + expect(handleChannel).not.toHaveBeenCalled(); + }); + + it("parks non-exit syscalls from a process Rust has already reaped", () => { + const memory = createSharedMemory(); + const channel = createChannel(42, memory); + markPending(channel); + const setCurrentTid = vi.fn(() => -3); + const handleChannel = vi.fn(); + const worker = createWorkerHarness({ + kernel_set_current_tid: setCurrentTid, + kernel_handle_channel: handleChannel, + }); + worker.processes = new Map([[42, { channels: [channel], memory }]]); + worker.hostReaped = new Set([42]); + worker.completeChannelRaw = vi.fn(); + worker.relistenChannel = vi.fn(); + new DataView(memory.buffer, channel.channelOffset).setUint32( + CH_SYSCALL, + ABI_SYSCALLS.SchedYield, + true, + ); + + worker.handleSyscall(channel); + + expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); + expect(channel.handling).toBe(true); + expect(worker.completeChannelRaw).not.toHaveBeenCalled(); + expect(worker.relistenChannel).not.toHaveBeenCalled(); + expect(setCurrentTid).not.toHaveBeenCalled(); + expect(handleChannel).not.toHaveBeenCalled(); + }); + + it("terminates a live process whose channel cannot bind to a kernel task", () => { + const pid = 42; + const tid = 101; + const memory = createSharedMemory(); + const channel = createChannel(pid, memory); + const setCurrentTid = vi.fn(() => -3); + const onExit = vi.fn(); + const worker = createWorkerHarness({ kernel_set_current_tid: setCurrentTid }); + worker.processes = new Map([[pid, { channels: [channel], memory }]]); + 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)); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + worker.handleSyscall(channel); + + expect(setCurrentTid).toHaveBeenCalledWith(pid, tid); + expect(worker.notifyHostProcessCrashed).toHaveBeenCalledWith(pid, 11); + expect(onExit).toHaveBeenCalledWith(pid, 139); + expect(channel.handling).toBe(true); + expect(worker.completeChannelRaw).not.toHaveBeenCalled(); + expect(worker.relistenChannel).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + "[handleSyscall] FATAL task binding error: " + + `Kernel rejected tid ${tid} for process ${pid}: errno 3`, + ); + } finally { + error.mockRestore(); + } + }); + + it("terminates instead of substituting the leader for a pthread with no TID mapping", () => { + const pid = 42; + const memory = createSharedMemory(); + const mainChannel = createChannel(pid, memory); + const threadChannel = createChannel(pid, memory, 256); + const onExit = vi.fn(); + const worker = createWorkerHarness(); + 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)); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const expected = + `No kernel-validated TID for non-main channel ${threadChannel.channelOffset} ` + + `of process ${pid}`; + + try { + worker.handleSyscall(threadChannel); + + expect(worker.notifyHostProcessCrashed).toHaveBeenCalledWith(pid, 11); + expect(onExit).toHaveBeenCalledWith(pid, 139); + expect(threadChannel.handling).toBe(true); + expect(worker.completeChannelRaw).not.toHaveBeenCalled(); + expect(worker.relistenChannel).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith( + `[handleSyscall] FATAL task binding error: ${expected}`, + ); + } finally { + error.mockRestore(); + } + }); + + it("still requests Worker teardown when recording a binding crash fails", () => { + 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 worker = createWorkerHarness({ + kernel_set_current_tid: vi.fn(() => -3), + }); + worker.processes = new Map([[pid, { channels: [channel], memory }]]); + worker.hostReaped = new Set(); + worker.callbacks = { onExit }; + worker.notifyHostProcessCrashed = vi.fn(() => { + throw transitionError; + }); + worker._handleSyscallInner = vi.fn(() => worker.bindKernelTid(pid, tid)); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + expect(() => worker.handleSyscall(channel)).toThrow(transitionError); + + expect(worker.notifyHostProcessCrashed).toHaveBeenCalledWith(pid, 11); + expect(onExit).toHaveBeenCalledWith(pid, 139); + expect(channel.handling).toBe(true); + expect(error).toHaveBeenCalledWith( + `[handleSyscall] Failed to record process ${pid} crash in kernel:`, + transitionError, + ); + } finally { + error.mockRestore(); + } + }); + + 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]), + }) as any; + + worker.deactivateProcess(pid); + + expect(Array.from(worker.channelTids.entries())).toEqual([ + [`${otherPid}:2000`, 2001], + ]); + expect(Array.from(worker.threadForkContexts.entries())).toEqual([ + [`${otherPid}:2000`, { 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 }]); + }); + it("host-observed crashes are marked in Rust before parent notification", () => { const calls: string[] = []; const markProcessSignaled = vi.fn(() => { @@ -1319,6 +1560,44 @@ describe("Rust-owned process wait lifecycle", () => { expect(worker.sharedMappings.has(42)).toBe(false); }); + it("does not publish a host crash when the kernel transition export is missing", () => { + const worker = createWorkerHarness({}); + worker.hostReaped = new Set(); + worker.discardStoppedChannelStateForProcess = vi.fn(); + worker.releaseAllSharedMemoryForProcess = vi.fn(); + worker.notifyParentOfExitedProcess = vi.fn(); + + expect(() => worker.notifyHostProcessCrashed(42, 11)).toThrow( + "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(); + }); + + it("does not publish a host crash rejected by the kernel", () => { + const markProcessSignaled = vi.fn(() => -3); + const worker = createWorkerHarness({ + kernel_mark_process_signaled: markProcessSignaled, + }); + worker.hostReaped = new Set(); + worker.discardStoppedChannelStateForProcess = vi.fn(); + worker.releaseAllSharedMemoryForProcess = vi.fn(); + worker.notifyParentOfExitedProcess = vi.fn(); + + expect(() => worker.notifyHostProcessCrashed(42, 11)).toThrow( + "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(); + }); + it("marks a host crash reaped before shared-state teardown can re-enter", () => { const worker = createWorkerHarness({ kernel_mark_process_signaled: vi.fn(() => 0), @@ -1356,6 +1635,46 @@ describe("Rust-owned process wait lifecycle", () => { expect(kernelHandle).not.toHaveBeenCalled(); }); + it("does not publish a clean exit when the trapped kernel leaves the process live", () => { + 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(() => { + 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(); + } + }); + it("uses the explicit termination signal instead of classifying high exit codes", () => { const exitSignals = new Map([[42, 0], [43, 15]]); const worker = createWorkerHarness({ @@ -1410,6 +1729,7 @@ function createWorkerHarness(exports: Record, kernelPtrWidth: 4 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, }, }, @@ -1440,6 +1760,15 @@ function createChannel(pid: number, memory: WebAssembly.Memory, channelOffset = }; } +function registerMainChannel(worker: any, channel: any): any { + worker.processes.set(channel.pid, { + pid: channel.pid, + memory: channel.memory, + channels: [channel], + }); + return channel; +} + function writeKernelWaitResult( memory: WebAssembly.Memory, ptr: number, diff --git a/host/test/readiness-deadline.test.ts b/host/test/readiness-deadline.test.ts index 95a174a2e4..6255f8b578 100644 --- a/host/test/readiness-deadline.test.ts +++ b/host/test/readiness-deadline.test.ts @@ -116,7 +116,11 @@ describe("host-emulated epoll signal delivery", () => { [7, 4096, 1, 1000, 0, 8], ); - expect(harness.dequeueSignal).toHaveBeenCalledWith(harness.channel.pid, CH_SIG_BASE); + expect(harness.dequeueSignal).toHaveBeenCalledWith( + harness.channel.pid, + harness.channel.pid, + CH_SIG_BASE, + ); expect( new DataView(harness.processMemory.buffer).getUint32(CH_SIG_BASE, true), ).toBe(15); @@ -154,7 +158,7 @@ function createEpollSignalHarness( channelOffset: 0, memory: processMemory, }; - const dequeueSignal = vi.fn((_pid: number, outPtr: number) => { + const dequeueSignal = vi.fn((_pid: number, _tid: number, outPtr: number) => { if (handlerSignal > 0) { new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); } @@ -176,6 +180,7 @@ function createEpollSignalHarness( kernelMemory, scratchOffset: 0, currentHandlePid: 0, + channelTids: new Map([["42:0", 42]]), epollInterests: new Map([ ["42:7", hasInterest ? [{ fd: 3, events: 0x001, data: 99n }] : []], ]), diff --git a/host/test/select-signal-outcome.test.ts b/host/test/select-signal-outcome.test.ts index 72e46ba915..1f82abd557 100644 --- a/host/test/select-signal-outcome.test.ts +++ b/host/test/select-signal-outcome.test.ts @@ -37,13 +37,13 @@ function createHarness(options: { view.setUint32(CH_ERRNO, errno, true); return 0; }); - const dequeueSignal = vi.fn((_pid: number, outPtr: number) => { + const dequeueSignal = vi.fn((_pid: number, _tid: number, outPtr: number) => { if (handlerSignal > 0) { new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); } return handlerSignal; }); - const setCurrentTid = vi.fn(); + const setCurrentTid = vi.fn(() => 0); const completeChannel = vi.fn(); const handleProcessTerminated = vi.fn(); const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { @@ -107,7 +107,7 @@ describe("select and pselect signal outcomes", () => { EINTR, ); expect(harness.worker.pendingSelectRetries.size).toBe(0); - expect(harness.setCurrentTid).toHaveBeenCalledWith(43); + expect(harness.setCurrentTid).toHaveBeenCalledWith(42, 43); expect(harness.setCurrentTid.mock.invocationCallOrder.at(-1)).toBeLessThan( harness.dequeueSignal.mock.invocationCallOrder[0], ); diff --git a/host/test/shared-memory-coherence.test.ts b/host/test/shared-memory-coherence.test.ts index a65eb22ca7..1408e6c6ec 100644 --- a/host/test/shared-memory-coherence.test.ts +++ b/host/test/shared-memory-coherence.test.ts @@ -234,9 +234,11 @@ function sysvHarness() { const memories = new Map(pids.map((pid) => [pid, sharedMemory()])); const kernelMemory = new WebAssembly.Memory({ initial: 2 }); const segment = new Uint8Array(size); - const setCurrentPid = vi.fn(); 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 readChunk = vi.fn((id: number, offset: number, outPtr: number, maxLen: number) => { expect(id).toBe(segId); const len = Math.min(maxLen, segment.length - offset); @@ -261,15 +263,18 @@ function sysvHarness() { })); const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { currentHandlePid: 0, + channelTids: new Map(), kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, kernelMemory, kernelInstance: { exports: { - kernel_set_current_pid: setCurrentPid, - kernel_ipc_shmat: shmat, - kernel_ipc_shmdt: shmdt, + 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, }, }, scratchOffset: 0, @@ -282,7 +287,20 @@ function sysvHarness() { ]), shmSegmentVersions: new Map([[segId, 0]]), }) as CentralizedKernelWorker; - return { kw, mapAddr, memories, pids, segment, segId, shmat, shmdt, size }; + return { + kw, + mapAddr, + memories, + pids, + segment, + segId, + shmat, + shmatForTask, + shmdt, + shmdtForTask, + size, + validateTask, + }; } describe("SysV SHM coherence and lifecycle", () => { @@ -330,11 +348,12 @@ describe("SysV SHM coherence and lifecycle", () => { it("increments inherited nattch and detaches the child exactly once", () => { const h = sysvHarness(); h.kw.inheritProcessSharedMappings(h.pids[0], h.pids[2]); - expect(h.shmat).toHaveBeenCalledWith(h.segId, h.mapAddr, 0); + expect(h.shmat).toHaveBeenCalledWith(h.pids[2], h.segId, h.mapAddr, 0); 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); (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[2]); expect(h.shmdt).toHaveBeenCalledTimes(1); }); @@ -366,12 +385,36 @@ describe("SysV SHM coherence and lifecycle", () => { completeChannelRaw: complete, relistenChannel: relisten, }); - const memory = h.memories.get(h.pids[2])!; - const channel = { pid: h.pids[2], memory, channelOffset: 0 }; + const channel = (h.kw as any).processes.get(h.pids[2]).channels[0]; (h.kw as any).handleIpcShmat(channel, [h.segId, 0, 0]); + expect(h.validateTask).toHaveBeenCalledWith(h.pids[2], h.pids[2]); + expect(h.shmatForTask).toHaveBeenCalledWith( + h.pids[2], + h.pids[2], + h.segId, + 0, + 0, + ); expect(h.shmdt).toHaveBeenCalledTimes(1); expect(complete).toHaveBeenCalledWith(channel, -12, 12); 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]; + + expect(() => { + (h.kw as any).handleIpcShmat(channel, [h.segId, 0, 0]); + }).toThrow(/rejected tid/); + + expect(h.validateTask).toHaveBeenCalledWith(h.pids[2], h.pids[2]); + expect(syncSegment).not.toHaveBeenCalled(); + expect(h.shmatForTask).not.toHaveBeenCalled(); + expect((h.kw as any).shmMappings.has(h.pids[2])).toBe(false); + }); }); diff --git a/host/test/shell-vfs-build.test.ts b/host/test/shell-vfs-build.test.ts index 61c3bb4c58..f01cc4023b 100644 --- a/host/test/shell-vfs-build.test.ts +++ b/host/test/shell-vfs-build.test.ts @@ -1,10 +1,17 @@ import { zstdCompressSync } from "node:zlib"; -import { readdirSync, readFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { loadShellBaseFileSystemFromImage } from "../../images/vfs/scripts/shell-vfs-build"; +import { + loadShellBaseFileSystemFromImage, + saveShellDerivedVfsImage, +} from "../../images/vfs/scripts/shell-vfs-build"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; import type { ZipEntry } from "../src/vfs/zip"; +import { + SHELL_DERIVED_VFS_PROFILE_MAX_BYTES, +} from "../../web-libs/kandelo-session/src/vfs-capacity"; const MiB = 1024 * 1024; const O_RDONLY = 0x0000; @@ -151,4 +158,71 @@ describe("shell VFS base composition", () => { expect(restored.sharedBuffer.maxByteLength).toBe(8 * MiB); expectContentsPreserved(restored); }); + + it("rejects an image that drifts from the standard product capacity", async () => { + const largerProfile = 1024 * MiB; + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(16 * MiB, { maxByteLength: largerProfile }), + largerProfile, + ); + + await expect( + saveShellDerivedVfsImage(fs, "/tmp/not-written.vfs.zst"), + ).rejects.toThrow( + new RegExp( + `${largerProfile}-byte VFS capacity.*` + + `${SHELL_DERIVED_VFS_PROFILE_MAX_BYTES} bytes are required`, + ), + ); + }); + + it("rejects an explicit product profile below the standard capacity", () => { + const smallerProfile = 512 * MiB; + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(16 * MiB, { maxByteLength: smallerProfile }), + smallerProfile, + ); + + expect(() => + saveShellDerivedVfsImage(fs, "/tmp/not-written.vfs.zst", { + expectedMaxByteLength: smallerProfile, + }) + ).toThrow( + new RegExp( + `must use the standard ${SHELL_DERIVED_VFS_PROFILE_MAX_BYTES}-byte ` + + "product profile or an explicitly reviewed, strictly larger profile", + ), + ); + }); + + const capacityProfiles: Array<[string, number, number | undefined]> = [ + ["the standard profile", SHELL_DERIVED_VFS_PROFILE_MAX_BYTES, undefined], + ["an explicit larger product profile", 1024 * MiB, 1024 * MiB], + ]; + + it.each(capacityProfiles)("saves %s only under its exact declared capacity", async ( + _label, + profileMaxBytes, + expectedMaxByteLength, + ) => { + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(16 * MiB, { maxByteLength: profileMaxBytes }), + profileMaxBytes, + ); + writeFile(fs, "/product.txt", "complete product"); + const dir = mkdtempSync(join(tmpdir(), "shell-derived-capacity-")); + try { + const image = await saveShellDerivedVfsImage( + fs, + join(dir, "product.vfs.zst"), + expectedMaxByteLength === undefined ? {} : { expectedMaxByteLength }, + ); + + expect(MemoryFileSystem.readImageCapacity(image).maxByteLength).toBe( + profileMaxBytes, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 7ab7822647..1dd35fce10 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -19,9 +19,17 @@ */ import { describe, expect, it, vi } from "vitest"; import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_SYSCALL, +} from "../src/generated/abi"; const SIGCHLD = 17; const SIGTERM = 15; +const SYS_TKILL = 204; function createSharedMemory(): WebAssembly.Memory { return new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); @@ -39,7 +47,7 @@ function createWorkerHarness(): any { kernelInstance: { exports: { kernel_handle_channel: () => 0, - kernel_set_current_tid: () => {}, + kernel_set_current_tid: () => 0, kernel_pick_signal_target_tid: (pid: number) => pid, kernel_thread_has_deliverable: () => 1, kernel_get_process_exit_signal: () => -1, @@ -178,12 +186,12 @@ describe("signal delivery to a process blocked in accept()", () => { } }); - it("binds a sleeping pthread before dequeuing its pending signal", () => { + it("passes a sleeping pthread's exact TID when dequeuing its pending signal", () => { const worker = createWorkerHarness(); const pid = 46; const tid = 47; const channel = createChannel(pid, 256); - const setCurrentTid = vi.fn(); + 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; @@ -192,24 +200,37 @@ describe("signal delivery to a process blocked in accept()", () => { worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); - expect(setCurrentTid).toHaveBeenCalledWith(tid); - expect(dequeueSignal).toHaveBeenCalledWith(pid, expect.any(Number)); - expect(setCurrentTid.mock.invocationCallOrder[0]).toBeLessThan( - dequeueSignal.mock.invocationCallOrder[0], - ); + expect(setCurrentTid).not.toHaveBeenCalled(); + expect(dequeueSignal).toHaveBeenCalledWith(pid, tid, expect.any(Number)); }); it("does not rebind an ordinary synchronous signal dequeue", () => { const worker = createWorkerHarness(); const pid = 48; const channel = createChannel(pid, 0); - const setCurrentTid = vi.fn(); + const setCurrentTid = vi.fn(() => 0); + worker.channelTids.set(`${pid}:${channel.channelOffset}`, pid); worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; - worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => 0); + const dequeueSignal = vi.fn(() => 0); + worker.kernelInstance.exports.kernel_dequeue_signal = dequeueSignal; worker.dequeueSignalForDelivery(channel); expect(setCurrentTid).not.toHaveBeenCalled(); + expect(dequeueSignal).toHaveBeenCalledWith(pid, pid, expect.any(Number)); + }); + + it("fails closed when Rust rejects an exact signal dequeue task", () => { + const worker = createWorkerHarness(); + 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); + + expect(() => worker.dequeueSignalForDelivery(channel)).toThrow( + /Kernel rejected signal dequeue/, + ); }); it("does not resume a sleeping pthread after dequeue terminates it", () => { @@ -314,4 +335,70 @@ describe("signal delivery to a process blocked in accept()", () => { error.mockRestore(); } }); + + it("does not change the ambient host PID when signal TID binding is rejected", () => { + const worker = createWorkerHarness(); + 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; + + worker.sendSignalToProcess(targetPid, SIGTERM); + + expect(setCurrentTid).toHaveBeenCalledWith(targetPid, targetPid); + expect(handleChannel).not.toHaveBeenCalled(); + expect(worker.currentHandlePid).toBe(priorPid); + }); + + it("does not downgrade a successful directed tkill to a shared waiter wake", () => { + const worker = createWorkerHarness(); + const pid = 55; + const targetTid = 56; + const channel = createChannel(pid, 0); + worker.channelTids.set(`${pid}:${channel.channelOffset}`, pid); + 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, + }); + 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.scratchOffset, + ); + kernelView.setBigInt64(CH_RETURN, 0n, true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }); + + worker._handleSyscallInner(channel); + + expect(exactWake).toHaveBeenCalledWith(pid, targetTid); + expect(sharedWake).not.toHaveBeenCalled(); + }); }); diff --git a/host/test/spawn-host-parity.test.ts b/host/test/spawn-host-parity.test.ts index 695c9da52d..08ac595e3a 100644 --- a/host/test/spawn-host-parity.test.ts +++ b/host/test/spawn-host-parity.test.ts @@ -44,6 +44,14 @@ function posixSpawnHandlerSource(src: string): string { return src.slice(start, end); } +function centralizedInitMessageSource(handler: string): string { + const start = handler.indexOf("const initData: CentralizedWorkerInitMessage"); + const end = handler.indexOf("\n };", start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return handler.slice(start, end); +} + describe("spawn host parity", () => { it("Node kernel-worker-entry wires both onResolveSpawn and onSpawn", () => { const src = readFileSync(nodeEntry, "utf8"); @@ -66,8 +74,11 @@ describe("spawn host parity", () => { expect(spawnHandler, `${nodeEntry} must publish posix_spawn parentage`).toMatch( /kind:\s*"spawn",\s*pid:\s*childPid,\s*ppid:\s*parentPid/, ); - expect(spawnHandler, `${nodeEntry} must initialize the child with its real parent`).toMatch( - /ppid:\s*parentPid/, + expect( + centralizedInitMessageSource(spawnHandler), + `${nodeEntry} must not duplicate kernel-owned parentage in worker init metadata`, + ).not.toMatch( + /\bppid\s*:/, ); }); @@ -92,8 +103,11 @@ describe("spawn host parity", () => { expect(spawnHandler, `${browserEntry} must publish posix_spawn parentage`).toMatch( /kind:\s*"spawn",\s*pid:\s*childPid,\s*ppid:\s*parentPid/, ); - expect(spawnHandler, `${browserEntry} must initialize the child with its real parent`).toMatch( - /ppid:\s*parentPid/, + expect( + centralizedInitMessageSource(spawnHandler), + `${browserEntry} must not duplicate kernel-owned parentage in worker init metadata`, + ).not.toMatch( + /\bppid\s*:/, ); }); diff --git a/host/test/spawn-pid-authority.test.ts b/host/test/spawn-pid-authority.test.ts index 1fccf5ff8c..f234f3a259 100644 --- a/host/test/spawn-pid-authority.test.ts +++ b/host/test/spawn-pid-authority.test.ts @@ -3,30 +3,206 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it, vi } from "vitest"; -import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, +} from "../src/kernel-worker"; import { WASM_PAGE_SIZE } from "../src/constants"; -import { HOST_INTERCEPTED_SYSCALLS } from "../src/generated/abi"; +import { + HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS, + HOST_INTERCEPTED_SYSCALLS, +} from "../src/generated/abi"; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); -describe("top-level spawn pid authority", () => { - it("does not reuse a pid while Node fork registration is still pending", async () => { +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 expected = + `No kernel-validated TID for non-main channel ${threadChannel.channelOffset} ` + + `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); + expect(kernelForkProcess).not.toHaveBeenCalled(); + expect(onFork).not.toHaveBeenCalled(); + 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( + 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} ` + + `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); + 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 }; + 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]; + + (kernelWorker as any).handleFork(channel, origArgs); + + expect(onFork).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + origArgs, + undefined, + -1, + 5, + ); + }); + + 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: 1, maximum: 1 }); + 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, + scratchOffset: 0, + completeChannel, + kernelInstance: { + exports: { kernel_spawn_process: kernelSpawnProcess }, + }, + }, + ) as CentralizedKernelWorker; + const origArgs = [1, 2, 3, 4, 5, 0]; + + (kernelWorker as any).handleSpawnAfterResolve( + channel, + origArgs, + parentPid, + parentPid, + 5, + new Uint8Array([1]), + 1, + {}, + [], + ); + + expect(kernelSpawnProcess).toHaveBeenCalledWith(parentPid, parentPid, 0, 1); + expect(onSpawn).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + origArgs, + undefined, + -1, + 5, + ); + }); + + 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 }; const completeChannel = vi.fn(); - const kernelPids = new Set([parentPid]); 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 }, - nextChildPid: 101, processes: new Map([[parentPid, { channels: [channel] }]]), + channelTids: new Map(), threadForkContexts: new Map(), sharedMappings: new Map(), tcpListenerTargets: new Map(), @@ -34,30 +210,21 @@ describe("top-level spawn pid authority", () => { completeChannel, kernelInstance: { exports: { - kernel_fork_process: vi.fn((_parent: number, child: number) => { - if (kernelPids.has(child)) return -17; - kernelPids.add(child); - return 0; - }), + kernel_fork_process: kernelForkProcess, kernel_clear_fork_child: vi.fn(() => 0), - kernel_reset_signal_mask: vi.fn(() => 0), kernel_get_process_exit_signal: vi.fn(() => -1), }, }, }, ) as CentralizedKernelWorker; - // This is the real fork state transition. It commits pid 101 in the Rust - // kernel, then calls Node's async onFork path. Keep that callback pending - // at the exact point before registerProcess adds the child host mapping. (kernelWorker as any).handleFork(channel, [0]); - expect(onFork).toHaveBeenCalledWith(parentPid, 101, memory, undefined); - expect(kernelPids.has(101)).toBe(true); - expect((kernelWorker as any).processes.has(101)).toBe(false); - // Node handleSpawn uses this same allocator. The old worker-local counter - // would still see pid 101 as absent from its process map and reuse it. - expect(kernelWorker.allocateTopLevelSpawnPid()).toBe(102); + expect(kernelForkProcess).toHaveBeenCalledOnce(); + expect(kernelForkProcess).toHaveBeenCalledWith(parentPid, parentPid); + expect(onFork).toHaveBeenCalledWith(parentPid, childPid, memory, undefined); + expect((kernelWorker as any).processes.has(childPid)).toBe(false); + expect("allocateTopLevelSpawnPid" in kernelWorker).toBe(false); finishForkRegistration([WASM_PAGE_SIZE]); await forkRegistration; @@ -67,24 +234,72 @@ describe("top-level spawn pid authority", () => { HOST_INTERCEPTED_SYSCALLS.SYS_FORK, [0], undefined, - 101, + childPid, 0, ); }); - it("routes Node top-level spawns through the kernel-worker allocator", () => { - const nodeEntry = readFileSync( - join(repoRoot, "host", "src", "node-kernel-worker-entry.ts"), - "utf8", - ); + 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 }, + }, + }, + ) as CentralizedKernelWorker; - expect(nodeEntry).toContain( - "const pid = kernelWorker.allocateTopLevelSpawnPid();", + expect(kernelWorker.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, + }, + ) as CentralizedKernelWorker; + + expect(() => kernelWorker.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, + }, + ) as CentralizedKernelWorker; + + expect(() => kernelWorker.removeProcessFromKernelTable(913)).toThrow( + "Kernel could not remove process 913: errno 5", ); - expect(nodeEntry).not.toContain("nextSpawnPid"); + expect(removeProcess).toHaveBeenCalledWith(913); + expect(drainWakeups).not.toHaveBeenCalled(); }); - it("does not let browser main-thread callers choose a pid", () => { + it("routes Node and browser top-level spawns through Rust creation", () => { + const nodeEntry = readFileSync( + join(repoRoot, "host", "src", "node-kernel-worker-entry.ts"), + "utf8", + ); const browserEntry = readFileSync( join(repoRoot, "host", "src", "browser-kernel-worker-entry.ts"), "utf8", @@ -97,11 +312,46 @@ describe("top-level spawn pid authority", () => { /export interface SpawnMessage \{[\s\S]*?\n\}/, )?.[0]; - expect(browserEntry).toContain( - "const pid = kernelWorker.allocateTopLevelSpawnPid();", - ); - expect(browserEntry).not.toContain("msg.pid ??"); + expect(nodeEntry).toContain("kernelWorker.createProcess("); + expect(browserEntry).toContain("kernelWorker.createProcess("); + expect(nodeEntry).not.toMatch(/next(?:Child|Spawn)Pid/); + expect(browserEntry).not.toMatch(/next(?:Child|Spawn)Pid/); expect(spawnMessage).toBeDefined(); expect(spawnMessage).not.toMatch(/\bpid\??:/); }); + + it("requires every kernel child-allocation path at startup and artifact validation", () => { + const requiredAuthorityExports = [ + "kernel_exec_prepare", + "kernel_exec_setup_for_thread", + "kernel_fork_process", + "kernel_spawn_process", + "kernel_thread_exit", + ]; + for (const exportName of requiredAuthorityExports) { + expect(HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS).toContain(exportName); + } + + const resolverWrapper = readFileSync( + join(repoRoot, "scripts", "resolve-binary.sh"), + "utf8", + ); + // The shell entrypoint deliberately delegates artifact policy to the + // generated standalone resolver. Check that boundary and inspect the + // executable bundle instead of requiring a second hard-coded export list. + expect(resolverWrapper).toContain( + 'exec node "$script_dir/resolve-binary.bundle.mjs" "$1"', + ); + + const artifactGuards = [ + "run.sh", + "scripts/resolve-binary.bundle.mjs", + "packages/registry/kernel/build-kernel.sh", + ].map((path) => readFileSync(join(repoRoot, path), "utf8")); + for (const source of artifactGuards) { + for (const exportName of requiredAuthorityExports) { + expect(source).toContain(exportName); + } + } + }); }); diff --git a/host/test/vfs-image-helpers.test.ts b/host/test/vfs-image-helpers.test.ts new file mode 100644 index 0000000000..46bfbcfae9 --- /dev/null +++ b/host/test/vfs-image-helpers.test.ts @@ -0,0 +1,351 @@ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { createServer } from "node:net"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + saveImage, + walkAndWrite, + writeVfsBinary, +} from "../../images/vfs/scripts/vfs-image-helpers"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const O_RDONLY = 0; + +function readFile(fs: MemoryFileSystem, path: string): Uint8Array { + const size = fs.stat(path).size; + const bytes = new Uint8Array(size); + const fd = fs.open(path, O_RDONLY, 0); + try { + const count = fs.read(fd, bytes, null, bytes.byteLength); + if (count !== bytes.byteLength) { + throw new Error(`short test read: ${count} of ${bytes.byteLength}`); + } + } finally { + fs.close(fd); + } + return bytes; +} + +function withSourceTree(run: (root: string) => void): void { + const root = mkdtempSync(join(tmpdir(), "vfs-walk-source-")); + try { + mkdirSync(join(root, "nested")); + writeFileSync(join(root, "keep.txt"), "kept"); + writeFileSync(join(root, "skip.txt"), "skipped"); + writeFileSync(join(root, "nested", "tool"), "tool"); + chmodSync(join(root, "nested"), 0o710); + chmodSync(join(root, "nested", "tool"), 0o751); + symlinkSync("keep.txt", join(root, "alias")); + run(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function artifactFileSystem(): MemoryFileSystem { + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + writeVfsBinary( + fs, + "/ordinary.bin", + new TextEncoder().encode("ordinary artifact bytes"), + ); + return fs; +} + +async function expectArtifactInspectionFailure( + fs: MemoryFileSystem, + failure: Error | RegExp, +): Promise { + const root = mkdtempSync(join(tmpdir(), "vfs-artifact-inspection-")); + const output = join(root, "guarded.vfs.zst"); + try { + await expect(saveImage(fs, output)).rejects.toThrow(failure); + expect(existsSync(output)).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +describe("walkAndWrite", () => { + it("copies files, directories, modes, and requested symlinks while honoring exclusions", () => { + withSourceTree((root) => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + const count = walkAndWrite(fs, root, "/payload", { + exclude: (path) => path === "skip.txt", + preserveMode: true, + preserveSymlinks: true, + }); + + expect(count).toBe(3); + expect( + new TextDecoder().decode(readFile(fs, "/payload/keep.txt")), + ).toBe("kept"); + expect( + new TextDecoder().decode(readFile(fs, "/payload/nested/tool")), + ).toBe("tool"); + expect(fs.stat("/payload/nested").mode & 0o7777).toBe(0o710); + expect(fs.stat("/payload/nested/tool").mode & 0o7777).toBe(0o751); + expect(fs.readlink("/payload/alias")).toBe("keep.txt"); + expect(() => fs.lstat("/payload/skip.txt")).toThrow(); + }); + }); + + it("rejects an unexcluded symlink unless preservation is requested", () => { + withSourceTree((root) => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(() => walkAndWrite(fs, root, "/payload")).toThrow( + new RegExp( + `VFS image source symlink requires preserveSymlinks or an explicit exclude: ` + + `${join(root, "alias")}`, + ), + ); + }); + }); + + it("omits a symlink only through an explicit exclusion", () => { + withSourceTree((root) => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + const count = walkAndWrite(fs, root, "/payload", { + exclude: (path) => path === "alias", + }); + + expect(count).toBe(3); + expect(() => fs.lstat("/payload/alias")).toThrow(); + expect(fs.stat("/payload/nested/tool").mode & 0o7777).toBe(0o644); + }); + }); + + it("propagates a host file read failure", () => { + withSourceTree((root) => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(() => + walkAndWrite(fs, root, "/payload", { + exclude: (path) => { + if (path === "alias") return true; + if (path === "keep.txt") unlinkSync(join(root, path)); + return false; + }, + }) + ).toThrow(); + }); + }); + + it("propagates a host symlink read failure", () => { + withSourceTree((root) => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(() => + walkAndWrite(fs, root, "/payload", { + preserveSymlinks: true, + exclude: (path) => { + if (path === "alias") unlinkSync(join(root, path)); + return false; + }, + }) + ).toThrow(); + }); + }); + + it("propagates a VFS write failure instead of silently omitting the file", () => { + const root = mkdtempSync(join(tmpdir(), "vfs-walk-error-")); + try { + writeFileSync(join(root, "payload.bin"), new Uint8Array([1, 2, 3])); + const failure = new Error("synthetic VFS write failure"); + const fs = { + mkdir: vi.fn(), + open: vi.fn(() => { throw failure; }), + } as unknown as MemoryFileSystem; + + expect(() => walkAndWrite(fs, root, "/payload")).toThrow(failure); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("propagates terminal ENOSPC after a partial product-tree write", () => { + const root = mkdtempSync(join(tmpdir(), "vfs-walk-enospc-")); + try { + writeFileSync(join(root, "payload.bin"), new Uint8Array(1024 * 1024)); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(128 * 1024)); + + expect(() => walkAndWrite(fs, root, "/payload")).toThrow(); + expect(fs.stat("/payload/payload.bin").size).toBeGreaterThan(0); + expect(fs.stat("/payload/payload.bin").size).toBeLessThan(1024 * 1024); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("rejects a source entry type that a VFS image cannot represent", async () => { + const root = mkdtempSync(join(tmpdir(), "vfs-walk-socket-")); + const socketPath = join(root, "runtime.sock"); + const server = createServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(socketPath, resolve); + }); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(() => walkAndWrite(fs, root, "/payload")).toThrow( + new RegExp(`Unsupported VFS image source entry: ${socketPath}`), + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe("VFS artifact publication inspection", () => { + it("skips only an explicitly deferred path while inspecting ordinary files", async () => { + const fs = artifactFileSystem(); + fs.registerLazyFile( + "/deferred.wasm", + "https://example.invalid/deferred.wasm", + 4, + 0o755, + ); + const realOpen = fs.open.bind(fs); + const open = vi.spyOn(fs, "open").mockImplementation( + (path, flags, mode) => { + if (path === "/deferred.wasm") { + throw new Error("deferred bytes must not be read during publication"); + } + return realOpen(path, flags, mode); + }, + ); + const root = mkdtempSync(join(tmpdir(), "vfs-deferred-inspection-")); + const output = join(root, "guarded.vfs.zst"); + try { + await expect(saveImage(fs, output)).resolves.toBeInstanceOf(Uint8Array); + expect( + open.mock.calls.some(([path]) => path === "/ordinary.bin"), + ).toBe(true); + expect( + open.mock.calls.some(([path]) => path === "/deferred.wasm"), + ).toBe(false); + expect(existsSync(output)).toBe(true); + } finally { + open.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("propagates a root-directory inspection failure", async () => { + const fs = artifactFileSystem(); + const failure = new Error("synthetic artifact opendir failure"); + vi.spyOn(fs, "opendir").mockImplementation(() => { + throw failure; + }); + + await expectArtifactInspectionFailure(fs, failure); + }); + + it("propagates a directory iteration failure", async () => { + const fs = artifactFileSystem(); + const failure = new Error("synthetic artifact readdir failure"); + vi.spyOn(fs, "readdir").mockImplementation(() => { + throw failure; + }); + + await expectArtifactInspectionFailure(fs, failure); + }); + + it("propagates a directory-entry metadata failure", async () => { + const fs = artifactFileSystem(); + const failure = new Error("synthetic artifact lstat failure"); + const realLstat = fs.lstat.bind(fs); + vi.spyOn(fs, "lstat").mockImplementation((path) => { + if (path === "/ordinary.bin") throw failure; + return realLstat(path); + }); + + await expectArtifactInspectionFailure(fs, failure); + }); + + it("propagates a non-deferred file stat failure", async () => { + const fs = artifactFileSystem(); + const failure = new Error("synthetic artifact stat failure"); + vi.spyOn(fs, "stat").mockImplementation(() => { + throw failure; + }); + + await expectArtifactInspectionFailure(fs, failure); + }); + + it("propagates a non-deferred file open failure", async () => { + const fs = artifactFileSystem(); + const failure = new Error("synthetic artifact open failure"); + vi.spyOn(fs, "open").mockImplementation(() => { + throw failure; + }); + + await expectArtifactInspectionFailure(fs, failure); + }); + + it("accepts partial reads only after consuming the complete file", async () => { + const fs = artifactFileSystem(); + const realRead = fs.read.bind(fs); + const read = vi.spyOn(fs, "read").mockImplementation( + (fd, buffer, position, length) => + realRead(fd, buffer, position, Math.min(length, 3)), + ); + const root = mkdtempSync(join(tmpdir(), "vfs-partial-inspection-")); + const output = join(root, "guarded.vfs.zst"); + try { + await expect(saveImage(fs, output)).resolves.toBeInstanceOf(Uint8Array); + expect(read.mock.calls.length).toBeGreaterThan(1); + expect(existsSync(output)).toBe(true); + } finally { + read.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("propagates a non-deferred file read failure", async () => { + const fs = artifactFileSystem(); + const failure = new Error("synthetic artifact read failure"); + vi.spyOn(fs, "read").mockImplementation(() => { + throw failure; + }); + + await expectArtifactInspectionFailure(fs, failure); + }); + + it("rejects premature EOF from a non-deferred artifact", async () => { + const fs = artifactFileSystem(); + vi.spyOn(fs, "read").mockReturnValue(0); + + await expectArtifactInspectionFailure( + fs, + /Incomplete VFS artifact read for \/ordinary\.bin: 0 of 23 bytes before result 0/, + ); + }); + + it("propagates a non-deferred file close failure", async () => { + const fs = artifactFileSystem(); + const failure = new Error("synthetic artifact close failure"); + vi.spyOn(fs, "close").mockImplementation(() => { + throw failure; + }); + + await expectArtifactInspectionFailure(fs, failure); + }); +}); diff --git a/host/test/vfs-image.test.ts b/host/test/vfs-image.test.ts index e52738c735..433e107897 100644 --- a/host/test/vfs-image.test.ts +++ b/host/test/vfs-image.test.ts @@ -1,11 +1,18 @@ import { describe, it, expect, vi } from "vitest"; import { zstdCompressSync } from "node:zlib"; -import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ABI_VERSION } from "../src/generated/abi"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; import { + assertVfsImageCapacity, assertVfsImageHeadroom, saveImage, sourceDateEpochMilliseconds, @@ -94,6 +101,81 @@ function stripStandaloneLazyIdentity(image: Uint8Array): Uint8Array { describe("VFS image save/restore", () => { describe("product image runtime headroom", () => { + it("validates the serialized product capacity contract and reports drift", async () => { + const mfs = createMemfs(); + const image = await mfs.saveImage(); + const maxByteLength = + MemoryFileSystem.readImageCapacity(image).maxByteLength; + + expect(() => + assertVfsImageCapacity(image, maxByteLength, "test image") + ).not.toThrow(); + expect(() => + assertVfsImageCapacity(image, maxByteLength + 4096, "test image") + ).toThrow(/test image has a .* VFS capacity; .* required/); + }); + + it.each([-1, 0, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( + "rejects invalid product capacity %s", + async (maxByteLength) => { + const image = await createMemfs().saveImage(); + expect(() => + assertVfsImageCapacity(image, maxByteLength, "test image") + ).toThrow(/expectedMaxByteLength must be a positive safe integer/); + }, + ); + + it("rejects malformed serialized capacity state", () => { + expect(() => + assertVfsImageCapacity(new Uint8Array(0), 1, "test image") + ).toThrow(/VFS image too small/); + }); + + it("rejects an encoded ceiling hidden by a smaller runtime buffer before writing", async () => { + const MiB = 1024 * 1024; + const encodedMaxBytes = 8 * MiB; + const runtimeMaxBytes = 4 * MiB; + const source = MemoryFileSystem.create( + new SharedArrayBuffer(1 * MiB, { maxByteLength: encodedMaxBytes }), + encodedMaxBytes, + ); + const sourceImage = await source.saveImage(); + const restored = MemoryFileSystem.fromImage(sourceImage, { + maxByteLength: runtimeMaxBytes, + }); + expect(restored.statfs("/").blocks * restored.statfs("/").bsize).toBe( + runtimeMaxBytes, + ); + + const maskedImage = await restored.saveImage(); + expect(MemoryFileSystem.readImageCapacity(maskedImage).maxByteLength).toBe( + encodedMaxBytes, + ); + expect(() => + assertVfsImageFitsProfile( + MemoryFileSystem.readImageCapacity(maskedImage), + runtimeMaxBytes, + undefined, + "masked.vfs.zst", + ) + ).toThrow(/requires 8388608 VFS bytes, but its profile permits 4194304/); + + const dir = mkdtempSync(join(tmpdir(), "vfs-masked-capacity-")); + const outFile = join(dir, "masked.vfs.zst"); + try { + await expect( + saveImage(restored, outFile, { + expectedMaxByteLength: runtimeMaxBytes, + }), + ).rejects.toThrow( + /has a 8388608-byte VFS capacity; 4194304 bytes are required/, + ); + expect(existsSync(outFile)).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it("checks free blocks and free inodes as independent resources", () => { const mfs = createMemfs(); const stats = mfs.statfs("/"); diff --git a/host/test/vm-interrupt-timer.test.ts b/host/test/vm-interrupt-timer.test.ts index f5b78af108..78dcd24389 100644 --- a/host/test/vm-interrupt-timer.test.ts +++ b/host/test/vm-interrupt-timer.test.ts @@ -144,7 +144,7 @@ describe("VmInterruptTimerManager", () => { expect(flag(newProcess, 31)).toBe(1); }); - it("drops a queued callback when the PID generation changes", () => { + it("drops a queued callback when the execution generation changes", () => { const oldProcess = generation(); const replacement = generation(); current.set(45, oldProcess); diff --git a/host/test/wasm-binary-parse.test.ts b/host/test/wasm-binary-parse.test.ts index ebb05a743a..d625dcce1d 100644 --- a/host/test/wasm-binary-parse.test.ts +++ b/host/test/wasm-binary-parse.test.ts @@ -13,7 +13,18 @@ import { readFileSync, readdirSync, existsSync } from "node:fs"; import { execFileSync } from "node:child_process"; import { homedir } from "node:os"; import { join } from "node:path"; -import { ABI_VERSION } from "../src/generated/abi"; +import { + ABI_VERSION, + WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, + 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, +} from "../src/generated/abi"; import { describeWasmArtifactPolicyFailures, extractHeapBase, @@ -90,7 +101,9 @@ interface FuncBody { locals: number[]; instructions: number[]; } function buildWasm(opts: { funcImports?: FuncImport[]; globalImports?: GlobalImport[]; + types?: { params: number[]; results: number[] }[]; funcTypes?: number[]; // type index per defined function + memoryPointerWidths?: Array<4 | 8>; globals?: DefinedGlobal[]; exports?: ExportEntry[]; funcBodies?: FuncBody[]; @@ -105,9 +118,19 @@ function buildWasm(opts: { bytes.push(...section(0, [...nameBytes(custom.name), ...(custom.data ?? [])])); } - // Type section (id=1): one type `() -> i32` so __abi_version-like funcs work. - // Encoded: count=1, [0x60 (func), 0 params, 1 result, 0x7F i32] - bytes.push(...section(1, [0x01, 0x60, 0x00, 0x01, 0x7F])); + // Default to one `() -> i32` type so __abi_version-like funcs work. + const types = opts.types ?? [{ params: [], results: [0x7F] }]; + const typePayload = [...uleb128(types.length)]; + for (const type of types) { + typePayload.push( + 0x60, + ...uleb128(type.params.length), + ...type.params, + ...uleb128(type.results.length), + ...type.results, + ); + } + bytes.push(...section(1, typePayload)); // Import section (id=2) const fImps = opts.funcImports ?? []; @@ -131,6 +154,15 @@ function buildWasm(opts: { bytes.push(...section(3, payload)); } + const memoryPointerWidths = opts.memoryPointerWidths ?? []; + if (memoryPointerWidths.length > 0) { + const payload = [...uleb128(memoryPointerWidths.length)]; + for (const pointerWidth of memoryPointerWidths) { + payload.push(pointerWidth === 8 ? 0x04 : 0x00, 0x01); + } + bytes.push(...section(5, payload)); + } + // Global section (id=6) const gs = opts.globals ?? []; if (gs.length > 0) { @@ -168,6 +200,77 @@ function buildWasm(opts: { const I32 = 0x7F; const I64 = 0x7E; +function linkedFrameDescriptor(pointerWidth: 4 | 8): number[] { + const pointerFormat = WPK_FORK_LINKED_FRAME_POINTER_WIDTHS.find( + ({ bytes }) => bytes === pointerWidth, + ); + if (!pointerFormat) throw new Error(`unsupported pointer width ${pointerWidth}`); + const bytes = new Uint8Array(WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE); + bytes.set(WPK_FORK_LINKED_FRAME_FORMAT_MAGIC, 0); + const view = new DataView(bytes.buffer); + view.setUint16(4, WPK_FORK_LINKED_FRAME_FORMAT_VERSION, true); + view.setUint16(6, WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE, true); + view.setUint8(8, pointerWidth); + view.setUint8(9, WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT); + view.setUint16(10, WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS, true); + view.setUint32(12, pointerFormat.chunkHeaderSize, true); + view.setUint32(16, pointerFormat.nodeHeaderSize, true); + view.setUint32(20, 16, true); + return [...bytes]; +} + +function completeForkWasm(options: { + pointerWidth?: 4 | 8; + memoryPointerWidth?: 4 | 8; + exportPointerWidth?: 4 | 8; +} = {}): 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 funcImports: FuncImport[] = [ + { module: "kernel", name: "kernel_fork", typeIdx: 0 }, + ...WPK_FORK_REQUIRED_IMPORTS.map((requirement) => ({ + module: requirement.module, + name: requirement.name, + typeIdx: requirement.results.length === 1 ? 3 : 4, + })), + ]; + const forkTypeIndices = WPK_FORK_REQUIRED_EXPORTS.map((requirement) => { + if (requirement.results.length === 1) return 0; + return requirement.params.length === 1 ? 1 : 2; + }); + const firstDefinedFunction = funcImports.length; + return buildWasm({ + customSections: [{ + name: WPK_FORK_LINKED_FRAME_FORMAT_SECTION, + data: linkedFrameDescriptor(pointerWidth), + }], + types, + funcImports, + funcTypes: [...forkTypeIndices, 0], + memoryPointerWidths: [options.memoryPointerWidth ?? pointerWidth], + exports: [ + ...WPK_FORK_REQUIRED_EXPORTS.map((requirement, index) => ({ + name: requirement.name, + kind: 0 as const, + index: firstDefinedFunction + index, + })), + { + name: "__abi_version", + kind: 0, + index: firstDefinedFunction + forkTypeIndices.length, + }, + ], + }); +} + // --------------------------------------------------------------------------- // extractHeapBase // --------------------------------------------------------------------------- @@ -401,29 +504,40 @@ describe("wasm artifact policy helpers", () => { }); expect(wasmHasCompleteForkInstrumentation(wasm)).toBe(false); - expect(describeWasmArtifactPolicyFailures(wasm, { expectedAbi: 12 })).toEqual([ - "incomplete wasm-fork-instrument exports; missing wpk_fork_unwind_begin, wpk_fork_unwind_end, wpk_fork_rewind_begin, wpk_fork_rewind_end", - "imports kernel.kernel_fork without complete wasm-fork-instrument exports", - ]); + 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).toContain( + `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", + ); }); - it("accepts fork-capable wasm with the complete instrumentation export set", () => { - const wasm = buildWasm({ - funcImports: [{ module: "kernel", name: "kernel_fork", typeIdx: 0 }], - funcTypes: [0], - funcBodies: [abiVersionBody(12)], - exports: [ - { name: "__abi_version", kind: 0, index: 1 }, - { name: "wpk_fork_unwind_begin", kind: 0, index: 1 }, - { name: "wpk_fork_unwind_end", kind: 0, index: 1 }, - { name: "wpk_fork_rewind_begin", kind: 0, index: 1 }, - { name: "wpk_fork_rewind_end", kind: 0, index: 1 }, - { name: "wpk_fork_state", kind: 0, index: 1 }, - ], - }); + it("accepts the complete ABI 42 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(wasmHasCompleteForkInstrumentation(wasm)).toBe(true); - expect(describeWasmArtifactPolicyFailures(wasm, { expectedAbi: 12 })).toEqual([]); + 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", + ); + }); + + it("rejects function signatures that drift from the descriptor pointer width", () => { + 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) -> ()", + ); }); it("does not require fork instrumentation for thread-only kernel_clone imports", () => { @@ -513,7 +627,7 @@ describe("wasm artifact policy helpers", () => { expectedAbi: 12, requireForkInstrumentation: false, forbidForkInstrumentation: true, - })).toContain("contains wasm-fork-instrument exports"); + })).toContain("contains ABI 42 wasm-fork-instrument metadata, imports, or exports"); }); }); diff --git a/host/test/wasm-guest-pointer.test.ts b/host/test/wasm-guest-pointer.test.ts new file mode 100644 index 0000000000..4d0a2792b2 --- /dev/null +++ b/host/test/wasm-guest-pointer.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { checkedWasmGuestPointerOffset } from "../src/wasm-guest-pointer"; + +describe("checkedWasmGuestPointerOffset", () => { + it.each([ + [0, 0], + [0x7fff_ffff, 0x7fff_ffff], + [-0x8000_0000, 0x8000_0000], + [-1, 0xffff_ffff], + [0xffff_ffff, 0xffff_ffff], + ])("normalizes the memory32 value %s to %s", (value, expected) => { + expect(checkedWasmGuestPointerOffset(value, 4, "memory32 test")).toBe(expected); + }); + + it.each([ + 0n, + -0x8000_0001, + 0x1_0000_0000, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + ])("rejects the invalid memory32 value %s", (value) => { + expect(() => checkedWasmGuestPointerOffset(value, 4, "memory32 test")) + .toThrow(new TypeError("memory32 test: expected an exact memory32 pointer")); + }); + + it.each([ + [0n, 0], + [1n, 1], + [BigInt(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER], + ] as const)("normalizes the memory64 value %s to %s", (value, expected) => { + expect(checkedWasmGuestPointerOffset(value, 8, "memory64 test")).toBe(expected); + }); + + it.each([ + 0, + -(1n << 63n) - 1n, + (1n << 64n), + ])("rejects the invalid memory64 representation %s", (value) => { + expect(() => checkedWasmGuestPointerOffset(value, 8, "memory64 test")) + .toThrow(new TypeError("memory64 test: expected an exact memory64 pointer")); + }); + + it.each([ + -1n, + -(1n << 63n), + BigInt(Number.MAX_SAFE_INTEGER) + 1n, + (1n << 64n) - 1n, + ])("rejects the unaddressable memory64 value %s", (value) => { + expect(() => checkedWasmGuestPointerOffset(value, 8, "memory64 test")) + .toThrow( + new RangeError( + "memory64 test: pointer exceeds JavaScript's exact address range", + ), + ); + }); +}); diff --git a/host/test/wordpress-source-layout.test.ts b/host/test/wordpress-source-layout.test.ts new file mode 100644 index 0000000000..295cc925f6 --- /dev/null +++ b/host/test/wordpress-source-layout.test.ts @@ -0,0 +1,189 @@ +import { + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + copyWordPressCoreSource, + isExcludedWordPressCoreSourceEntry, + isWordPressSetupOnlySourceEntry, + materializeWordPressSqlitePlugin, + resolveWordPressCoreSource, + resolveWordPressSqlitePluginSource, + WORDPRESS_CORE_GUEST_PATH, + WORDPRESS_SETUP_SQLITE_PLUGIN_ALIAS, + WORDPRESS_SQLITE_PLUGIN_GUEST_PATH, + WORDPRESS_SQLITE_PLUGIN_SHA256, + WORDPRESS_SQLITE_PLUGIN_URL, + WORDPRESS_SQLITE_PLUGIN_VERSION, +} from "../../images/vfs/scripts/wordpress-source-layout"; +import type { ExtractOptions } from "../../images/vfs/scripts/source-extract-helper"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const O_RDONLY = 0; + +function readFile(fs: MemoryFileSystem, path: string): string { + const size = fs.stat(path).size; + const bytes = new Uint8Array(size); + const fd = fs.open(path, O_RDONLY, 0); + try { + expect(fs.read(fd, bytes, null, bytes.byteLength)).toBe(bytes.byteLength); + } finally { + fs.close(fd); + } + return new TextDecoder().decode(bytes); +} + +describe("WordPress product source layout", () => { + it("resolves core and SQLite plugin through their pinned source contracts", () => { + const repoRoot = "/reviewed/kandelo"; + const coreResolver = vi.fn(( + _packageName: string, + _repoRoot: string, + _legacyLocalPath?: string, + ) => "/cache/wordpress"); + const pluginResolver = vi.fn((_options: ExtractOptions) => + "/cache/sqlite-plugin" + ); + + expect(resolveWordPressCoreSource(repoRoot, coreResolver)).toBe( + "/cache/wordpress", + ); + expect(coreResolver).toHaveBeenCalledWith("wordpress", repoRoot); + + expect(resolveWordPressSqlitePluginSource(pluginResolver)).toBe( + "/cache/sqlite-plugin", + ); + expect(pluginResolver).toHaveBeenCalledWith({ + url: WORDPRESS_SQLITE_PLUGIN_URL, + sha256: WORDPRESS_SQLITE_PLUGIN_SHA256, + cacheKey: + `sqlite-database-integration-${WORDPRESS_SQLITE_PLUGIN_VERSION}`, + }); + expect(WORDPRESS_SQLITE_PLUGIN_SHA256).toMatch(/^[a-f0-9]{64}$/); + }); + + it("copies core while explicitly omitting only reviewed generated entries", () => { + const root = mkdtempSync(join(tmpdir(), "wordpress-vfs-source-")); + const pluginRoot = mkdtempSync(join(tmpdir(), "wordpress-sqlite-plugin-")); + try { + const plugins = join(root, "wp-content", "plugins"); + mkdirSync(plugins, { recursive: true }); + writeFileSync(join(root, "index.php"), "core"); + writeFileSync(join(root, "wp-config.php"), "local config"); + writeFileSync(join(root, "build-state.db"), "database state"); + writeFileSync(join(root, "wp-content", "db.php"), "local drop-in"); + const similarName = join( + plugins, + "sqlite-database-integration-copy", + ); + mkdirSync(similarName); + writeFileSync(join(similarName, "keep.php"), "kept"); + writeFileSync(join(pluginRoot, "load.php"), "host-only plugin source"); + symlinkSync( + pluginRoot, + join(plugins, "sqlite-database-integration"), + ); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(copyWordPressCoreSource(fs, root)).toBe(2); + expect(readFile(fs, `${WORDPRESS_CORE_GUEST_PATH}/index.php`)).toBe( + "core", + ); + expect(readFile( + fs, + `${WORDPRESS_CORE_GUEST_PATH}/wp-content/plugins/` + + "sqlite-database-integration-copy/keep.php", + )).toBe("kept"); + expect(() => fs.lstat(WORDPRESS_SQLITE_PLUGIN_GUEST_PATH)).toThrow(); + expect(() => + fs.lstat(`${WORDPRESS_CORE_GUEST_PATH}/wp-config.php`) + ).toThrow(); + expect(() => + fs.lstat(`${WORDPRESS_CORE_GUEST_PATH}/build-state.db`) + ).toThrow(); + expect(() => + fs.lstat(`${WORDPRESS_CORE_GUEST_PATH}/wp-content/db.php`) + ).toThrow(); + expect(isWordPressSetupOnlySourceEntry( + `${WORDPRESS_SETUP_SQLITE_PLUGIN_ALIAS}/load.php`, + )).toBe(false); + expect(isExcludedWordPressCoreSourceEntry( + "wp-content/plugins/sqlite-database-integration-copy", + )).toBe(false); + } finally { + rmSync(root, { recursive: true, force: true }); + rmSync(pluginRoot, { recursive: true, force: true }); + } + }); + + it("rejects a similarly named unexpected core-source symlink", () => { + const root = mkdtempSync(join(tmpdir(), "wordpress-vfs-source-")); + try { + const plugins = join(root, "wp-content", "plugins"); + mkdirSync(plugins, { recursive: true }); + writeFileSync(join(root, "index.php"), "core"); + symlinkSync( + "index.php", + join(plugins, "sqlite-database-integration-copy"), + ); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(() => copyWordPressCoreSource(fs, root)).toThrow( + new RegExp( + "VFS image source symlink requires preserveSymlinks or an explicit exclude", + ), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("materializes the pinned SQLite plugin source at its guest path", () => { + const source = mkdtempSync(join(tmpdir(), "wordpress-sqlite-source-")); + try { + mkdirSync(join(source, "includes")); + writeFileSync(join(source, "load.php"), "plugin loader"); + writeFileSync(join(source, "includes", "driver.php"), "driver"); + writeFileSync(join(source, "build-state.db"), "excluded state"); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(materializeWordPressSqlitePlugin(fs, source)).toBe(2); + expect(readFile( + fs, + `${WORDPRESS_SQLITE_PLUGIN_GUEST_PATH}/load.php`, + )).toBe("plugin loader"); + expect(readFile( + fs, + `${WORDPRESS_SQLITE_PLUGIN_GUEST_PATH}/includes/driver.php`, + )).toBe("driver"); + expect(() => + fs.lstat(`${WORDPRESS_SQLITE_PLUGIN_GUEST_PATH}/build-state.db`) + ).toThrow(); + } finally { + rmSync(source, { recursive: true, force: true }); + } + }); + + it("still rejects every unrelated plugin-source symlink", () => { + const source = mkdtempSync(join(tmpdir(), "wordpress-sqlite-source-")); + try { + writeFileSync(join(source, "load.php"), "plugin loader"); + symlinkSync("load.php", join(source, "unexpected-alias.php")); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + + expect(() => materializeWordPressSqlitePlugin(fs, source)).toThrow( + new RegExp( + "VFS image source symlink requires preserveSymlinks or an explicit exclude", + ), + ); + } finally { + rmSync(source, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/worker-adapter.test.ts b/host/test/worker-adapter.test.ts index 0df11a803e..b509e97b9e 100644 --- a/host/test/worker-adapter.test.ts +++ b/host/test/worker-adapter.test.ts @@ -10,7 +10,7 @@ import { describe("MockWorkerAdapter", () => { it("should create a worker handle and capture workerData", () => { const adapter = new MockWorkerAdapter(); - const data = { type: "init", pid: 1 }; + const data = { type: "init", pid: 100 }; const handle = adapter.createWorker(data); expect(handle).toBeDefined(); expect(adapter.lastWorker).not.toBeNull(); @@ -22,9 +22,9 @@ describe("MockWorkerAdapter", () => { const handle = adapter.createWorker({}); const messages: unknown[] = []; handle.on("message", (msg) => messages.push(msg)); - adapter.lastWorker!.simulateMessage({ type: "ready", pid: 1 }); + adapter.lastWorker!.simulateMessage({ type: "ready", pid: 100 }); expect(messages).toHaveLength(1); - expect(messages[0]).toEqual({ type: "ready", pid: 1 }); + expect(messages[0]).toEqual({ type: "ready", pid: 100 }); }); it("should dispatch error events to registered handlers", () => { diff --git a/host/test/worker-entry.test.ts b/host/test/worker-entry.test.ts index 80cd9ded5d..aaa3b38b22 100644 --- a/host/test/worker-entry.test.ts +++ b/host/test/worker-entry.test.ts @@ -40,8 +40,7 @@ describe.skipIf(!hasBinary)("centralizedWorkerMain", () => { const initData: CentralizedWorkerInitMessage = { type: "centralized_init", - pid: 1, - ppid: 0, + pid: 100, programBytes: loadProgramBytes(), memory, channelOffset, @@ -56,8 +55,7 @@ describe.skipIf(!hasBinary)("centralizedWorkerMain", () => { const errorPort = createMockPort(); const errorInitData: CentralizedWorkerInitMessage = { type: "centralized_init", - pid: 2, - ppid: 0, + pid: 101, programBytes: new ArrayBuffer(0), memory, channelOffset, @@ -67,6 +65,6 @@ describe.skipIf(!hasBinary)("centralizedWorkerMain", () => { expect(errorPort.messages).toHaveLength(1); expect((errorPort.messages[0] as any).type).toBe("error"); - expect((errorPort.messages[0] as any).pid).toBe(2); + expect((errorPort.messages[0] as any).pid).toBe(101); }); }); diff --git a/images/vfs/scripts/build-erlang-vfs-image.ts b/images/vfs/scripts/build-erlang-vfs-image.ts index 7f8a151f38..f5947da012 100644 --- a/images/vfs/scripts/build-erlang-vfs-image.ts +++ b/images/vfs/scripts/build-erlang-vfs-image.ts @@ -80,7 +80,6 @@ async function main() { const otpRoot = "/usr/local/lib/erlang"; ensureDirRecursive(fs, otpRoot); const totalFiles = walkAndWrite(fs, INSTALL_DIR, otpRoot, { - failOnError: true, preserveMode: true, preserveSymlinks: true, }); diff --git a/images/vfs/scripts/build-homebrew-vfs-image.ts b/images/vfs/scripts/build-homebrew-vfs-image.ts index db8daf14b7..b99f526c64 100644 --- a/images/vfs/scripts/build-homebrew-vfs-image.ts +++ b/images/vfs/scripts/build-homebrew-vfs-image.ts @@ -50,6 +50,13 @@ import { MemoryFileSystem, type VfsImageMetadata, } from "../../../host/src/vfs/memory-fs"; +import { + assertPackageDeferredZipTreeState, + derivePackageDeferredZipTree, + materializePackageDeferredZipTree, + registerPackageDeferredZipTree, + type DerivedPackageDeferredZipTree, +} from "../../../host/src/vfs/package-deferred-tree"; import { KANDELO_DEMO_CONFIG_PATH, MAX_KANDELO_DEMO_CONFIG_BYTES, @@ -68,6 +75,7 @@ import { ensureDirRecursive, saveImage, sourceDateEpochMilliseconds, + type SaveImageOptions, writeVfsBinary, } from "./vfs-image-helpers"; @@ -100,6 +108,9 @@ interface CliOptions { materializationPolicy?: string; bottleMirrorRepository?: string; bottleMirrorOut?: string; + packageTreeSpec?: string; + packageTreeArchive?: string; + materializePackageTree: boolean; } /** @@ -127,6 +138,23 @@ export type HomebrewVfsImageMaterializer = ( options: HomebrewVfsImageMaterializationOptions, ) => Promise; +/** + * Serialize a Homebrew product image only when its encoded SharedFS ceiling + * matches the consumer contract before compression, directory creation, or + * output writes. + */ +export async function saveVerifiedHomebrewVfsImage( + fs: MemoryFileSystem, + outFile: string, + options: Omit, + expectedMaxByteLength: number, +): Promise { + return saveImage(fs, outFile, { + ...options, + expectedMaxByteLength, + }); +} + const DEFAULT_MAX_BYTES = 128 * 1024 * 1024; const SHARED_FS_BLOCK_BYTES = 4096; const HOMEBREW_COMPOSITION_PATH = "/etc/kandelo/homebrew-vfs.json"; @@ -311,6 +339,29 @@ export async function runHomebrewVfsImageBuilder( result = materializedBuild.result; materializedBuild.assert(fs); } + let packageTree: { + derived: DerivedPackageDeferredZipTree; + state: "deferred" | "materialized"; + } | undefined; + if (options.packageTreeSpec !== undefined) { + const archiveBytes = readPackageTreeArchive(options.packageTreeArchive!); + const derived = derivePackageDeferredZipTree( + readJsonFile(options.packageTreeSpec), + archiveBytes, + ); + if (basename(options.packageTreeArchive!) !== derived.descriptor.package.output) { + throw new Error( + `package tree archive must be named ${derived.descriptor.package.output}`, + ); + } + const registered = registerPackageDeferredZipTree(fs, derived); + if (options.materializePackageTree) { + await materializePackageDeferredZipTree(fs, registered, archiveBytes); + } + const state = options.materializePackageTree ? "materialized" : "deferred"; + assertPackageDeferredZipTreeState(fs, derived, state); + packageTree = { derived, state }; + } if (shellConfig) { assertShellExecutable(fs, shellConfig.config.path); if ( @@ -340,7 +391,7 @@ export async function runHomebrewVfsImageBuilder( writeVfsBinary(fs, KANDELO_DEMO_CONFIG_PATH, demoConfig.source, 0o644); } - const imageBytes = await saveImage(fs, options.out, { + const imageBytes = await saveVerifiedHomebrewVfsImage(fs, options.out, { normalizeTimestampsMs: sourceDateEpochMilliseconds( process.env.SOURCE_DATE_EPOCH, ), @@ -350,6 +401,9 @@ export async function runHomebrewVfsImageBuilder( createdBy: "images/vfs/scripts/build-homebrew-vfs-image.ts", capacity: { maxByteLength }, ...(baseImage ? { baseImage: baseImage.binding } : {}), + ...(packageTree === undefined ? {} : { + packageDeferredTrees: [packageTreeBinding(packageTree)], + }), homebrew: { tapRepository: plan.tapRepository, tapName: plan.tapName, @@ -432,7 +486,7 @@ export async function runHomebrewVfsImageBuilder( })), }, }, - }); + }, maxByteLength); const imageCapacity = MemoryFileSystem.readImageCapacity(imageBytes); if (imageCapacity.maxByteLength !== maxByteLength) { throw new Error( @@ -441,9 +495,16 @@ export async function runHomebrewVfsImageBuilder( ); } let bottleMirrorOutput: unknown; - if (materializedBuild !== undefined) { + if (materializedBuild !== undefined || packageTree !== undefined) { const restored = MemoryFileSystem.fromImagePreservingCapacity(imageBytes); - materializedBuild.assert(restored); + materializedBuild?.assert(restored); + if (packageTree !== undefined) { + assertPackageDeferredZipTreeState( + restored, + packageTree.derived, + packageTree.state, + ); + } if (shellConfig !== undefined) { assertShellExecutable(restored, shellConfig.config.path); if (restored.isPathDeferred(shellConfig.config.path)) { @@ -453,9 +514,11 @@ export async function runHomebrewVfsImageBuilder( ); } } - bottleMirrorOutput = materializedBuild.writeBottleMirrorBundle( - options.bottleMirrorOut!, - ); + if (materializedBuild !== undefined) { + bottleMirrorOutput = materializedBuild.writeBottleMirrorBundle( + options.bottleMirrorOut!, + ); + } } if (options.lazyLayerOut && options.lazyLayerDescriptor) { @@ -530,6 +593,9 @@ export async function runHomebrewVfsImageBuilder( ...(bottleMirrorOutput === undefined ? {} : { bottle_mirror: bottleMirrorOutput, }), + ...(packageTree === undefined ? {} : { + package_deferred_trees: [packageTreeBinding(packageTree)], + }), // Report a reproducible artifact identity, not a runner/worktree path. image: basename(options.out), }; @@ -550,6 +616,7 @@ function parseArgs(args: string[]): CliOptions { arch: "wasm32", allowFallback: true, writeProfile: false, + materializePackageTree: false, }; for (let i = 0; i < args.length; i += 1) { @@ -697,6 +764,24 @@ function parseArgs(args: string[]): CliOptions { } options.bottleMirrorOut = requireValue(args, ++i, arg); break; + case "--package-tree-spec": + if (options.packageTreeSpec !== undefined) { + usage("--package-tree-spec may be provided only once"); + } + options.packageTreeSpec = requireValue(args, ++i, arg); + break; + case "--package-tree-archive": + if (options.packageTreeArchive !== undefined) { + usage("--package-tree-archive may be provided only once"); + } + options.packageTreeArchive = requireValue(args, ++i, arg); + break; + case "--materialize-package-tree": + if (options.materializePackageTree) { + usage("--materialize-package-tree may be provided only once"); + } + options.materializePackageTree = true; + break; case "--help": case "-h": usage(undefined, 0); @@ -753,6 +838,18 @@ function parseArgs(args: string[]): CliOptions { if (options.bottleMirrorOut !== undefined && existsSync(options.bottleMirrorOut)) { usage(`bottle mirror output must not already exist: ${options.bottleMirrorOut}`); } + if (Boolean(options.packageTreeSpec) !== Boolean(options.packageTreeArchive)) { + usage("--package-tree-spec and --package-tree-archive must be provided together"); + } + if (options.materializePackageTree && options.packageTreeSpec === undefined) { + usage("--materialize-package-tree requires a package tree"); + } + if (options.packageTreeSpec !== undefined && !existsSync(options.packageTreeSpec)) { + usage(`package tree spec does not exist: ${options.packageTreeSpec}`); + } + if (options.packageTreeArchive !== undefined && !existsSync(options.packageTreeArchive)) { + usage(`package tree archive does not exist: ${options.packageTreeArchive}`); + } if (options.materializationPolicy !== undefined && options.lazyLayerOut !== undefined) { usage("materialized shell composition cannot also emit a runtime layer"); } @@ -868,6 +965,44 @@ function parseBaseImagePath(value: string): string { return value; } +function readPackageTreeArchive(path: string): Uint8Array { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size <= 0) { + throw new Error(`package tree archive is not a nonempty regular file: ${path}`); + } + return new Uint8Array(readFileSync(path)); +} + +function packageTreeBinding(tree: { + derived: DerivedPackageDeferredZipTree; + state: "deferred" | "materialized"; +}) { + const descriptor = tree.derived.descriptor; + return { + schema: descriptor.schema, + kind: descriptor.kind, + id: descriptor.id, + content_role: descriptor.content_role, + package: descriptor.package, + descriptor: { + sha256: tree.derived.descriptorSha256, + bytes: tree.derived.descriptorBytes.byteLength, + }, + archive: { + output: descriptor.package.output, + url: descriptor.archive.url, + sha256: descriptor.archive.sha256, + bytes: descriptor.archive.bytes, + expanded_bytes: descriptor.archive.expanded_bytes, + source_entry_count: descriptor.archive.source_entry_count, + }, + mount_prefix: descriptor.mount_prefix, + owner: descriptor.owner, + activation: descriptor.activation, + state: tree.state, + }; +} + function createFs( baseImage: string | undefined, maxBytes: number | undefined, @@ -1361,6 +1496,9 @@ function usage(message?: string, code = 2): never { [--materialization-policy \\ --bottle-mirror-repository \\ --bottle-mirror-out ] \\ + [--package-tree-spec \\ + --package-tree-archive \\ + [--materialize-package-tree]] \\ [--lazy-layer-out \\ --lazy-layer-descriptor \\ --lazy-layer-base-image \\ diff --git a/images/vfs/scripts/build-lamp-vfs-image.ts b/images/vfs/scripts/build-lamp-vfs-image.ts index ec8d1166e2..994be7a351 100644 --- a/images/vfs/scripts/build-lamp-vfs-image.ts +++ b/images/vfs/scripts/build-lamp-vfs-image.ts @@ -1,7 +1,7 @@ /** * Build a fully-bootable VFS image for the WordPress + MariaDB (LAMP) - * browser demo. The image starts from shell.vfs.zst, then dinit (PID 1) - * brings up the full stack: + * browser demo. The image starts from shell.vfs.zst, then dinit, the first + * user process, brings up the full stack: * * mariadb (process) — starts from a build-time-initialized /data * wp-config-init (internal) — dependency marker. The browser host writes @@ -21,7 +21,6 @@ import { writeVfsFile, writeVfsBinary, ensureDirRecursive, - walkAndWrite, } from "./vfs-image-helpers"; import { addDinitInit, @@ -54,6 +53,10 @@ import { SHELL_DERIVED_VFS_PROFILE_MAX_BYTES, } from "../../../web-libs/kandelo-session/src/vfs-capacity"; import { preinstallWordPressMariaDb } from "./wordpress-preinstall"; +import { + copyWordPressCoreSource, + resolveWordPressCoreSource, +} from "./wordpress-source-layout"; import { prepareMariadbWritableDirectories } from "./mariadb-image-helpers"; const REPO_ROOT = findRepoRoot(); @@ -63,11 +66,7 @@ const BROWSER_DIR = join(REPO_ROOT, "apps", "browser-demos"); // the system_tables SQL files are shipped only in the upstream MariaDB // source tarball, so we extract them on demand the same way // build-mariadb-vfs-image.ts does. -const WP_DIR = ensureSourceExtract( - "wordpress", - REPO_ROOT, - join(REPO_ROOT, "packages", "registry", "wordpress", "wordpress"), -); +const WP_DIR = resolveWordPressCoreSource(REPO_ROOT); const MARIADB_LEGACY_INSTALL = join(REPO_ROOT, "packages", "registry", "mariadb", "mariadb-install"); const MARIADB_SOURCE = ensureSourceExtract("mariadb", REPO_ROOT); const MARIADB_PATH = resolveBinary("programs/mariadb/mariadbd.wasm"); @@ -286,9 +285,9 @@ const MARIADB_BOOTSTRAP_SCRIPT = `# mariadbd --bootstrap doesn't exit at stdin E # Background it, watch for the canonical "bootstrap done" marker (the # \`wordpress\` database directory created by the LAST statement in # bootstrap.sql), then kill mariadbd. Falls back to a 60s safety cap -# if the marker never lands. **No \`wait\`** — dinit (PID 1) reaps -# orphans and races with dash's wait builtin, which then blocks. -# Letting dinit reap is fine. +# if the marker never lands. The shell remains the direct parent and waits +# after terminating mariadbd; the kernel's synthetic PID 1 has no reaper +# worker, while dinit is a separate ordinary user process. # # Polling the marker shaves ~30-50s off boot vs the previous fixed # 60s sleep — that sleep was the dominant boot-time cost, since the @@ -322,6 +321,7 @@ done kill -TERM $PID 2>/dev/null sleep 1 kill -KILL $PID 2>/dev/null +wait $PID 2>/dev/null || true exit 0 `; @@ -454,9 +454,7 @@ async function main() { ); console.log("Writing WordPress core files..."); - const excludeDb = (rel: string) => - rel.endsWith(".db") || rel === "wp-config.php" || rel.includes("wp-content/db.php"); - const wpCount = walkAndWrite(fs, WP_DIR, "/var/www/html", { exclude: excludeDb }); + const wpCount = copyWordPressCoreSource(fs, WP_DIR); patchWordPressPersistentMysqli(fs); console.log(` WordPress core: ${wpCount} files`); diff --git a/images/vfs/scripts/build-mariadb-test-vfs-image.ts b/images/vfs/scripts/build-mariadb-test-vfs-image.ts index edfcd659ff..35b4443fec 100644 --- a/images/vfs/scripts/build-mariadb-test-vfs-image.ts +++ b/images/vfs/scripts/build-mariadb-test-vfs-image.ts @@ -1,6 +1,6 @@ /** * Build a fully-bootable VFS image for the MariaDB mysql-test browser - * runner. dinit (PID 1) brings up the test-server tree: + * runner. dinit, the first user process, brings up the test-server tree: * * mariadb-bootstrap (scripted, oneshot) → mariadb (process) * @@ -15,8 +15,8 @@ * npx tsx images/vfs/scripts/build-mariadb-test-vfs-image.ts # curated tests * npx tsx images/vfs/scripts/build-mariadb-test-vfs-image.ts --all # ALL tests */ -import { readFileSync, readdirSync, lstatSync, existsSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; import { ensureDir, @@ -26,9 +26,10 @@ import { symlink, } from "../../../host/src/vfs/image-helpers"; import { resolveBinary, findRepoRoot } from "../../../host/src/binary-resolver"; -import { saveImage, walkAndWrite } from "./vfs-image-helpers"; +import { saveImage } from "./vfs-image-helpers"; import { addDinitInit, type DinitService } from "./dinit-image-helpers"; import { prepareMariadbWritableDirectories } from "./mariadb-image-helpers"; +import { copyMariaDbTestSources } from "./mariadb-test-source-copy"; import { ensureSourceExtract } from "./source-extract-helper"; const REPO_ROOT = findRepoRoot(); @@ -65,7 +66,7 @@ const COREUTILS_SYMLINK_NAMES = [ "md5sum", "seq", "test", "[", ]; -// 185 tests verified to pass in headless Chromium with MariaDB on kandelo. +// 184 tests verified to pass in headless Chromium with MariaDB on kandelo. const CURATED_TESTS = [ "1st", "adddate_454", "almost_full", "alter_table_combinations", "alter_table_lock", "alter_table_mdev539_maria", @@ -113,7 +114,7 @@ const CURATED_TESTS = [ "set_statement_notembedded", "show_create_user", "show_function_with_pad_char_to_full_length", "show_row_order-9226", "signal_demo1", "signal_demo2", "signal_demo3", - "signal_sqlmode", "simple_select", "single_delete_update", + "signal_sqlmode", "single_delete_update", "skip_log_bin", "sp-bugs2", "sp-condition-handler", "sp-destruct", "sp-memory-leak", "sp-no-code", "sp-no-valgrind", "sp-ucs2", "sp-vars", "sp_gis", "sp_missing_4665", "sql_mode_pad_char_to_full_length", @@ -180,9 +181,9 @@ function buildServices(): DinitService[] { type: "scripted", // mariadbd --bootstrap doesn't exit at stdin EOF in the wasm port. // The wrapper backgrounds it, sleeps long enough for bootstrap to - // drain the SQL, then kills it. **No `wait`** — dinit (PID 1) - // reaps orphans aggressively and dash's `wait` builtin then blocks - // indefinitely. Letting dinit reap is fine. + // drain the SQL, then kills and waits for it. The shell is the direct + // parent and must reap it; PID 1 is a synthetic kernel record with no + // worker, and dinit runs as the first ordinary user process. command: "/bin/sh /etc/mariadb/bootstrap.sh", logfile: "/var/log/mariadb-bootstrap.log", restart: false, @@ -251,9 +252,8 @@ async function main() { const bootstrapSql = `use mysql;\n${systemTables}\n${systemData}\nCREATE DATABASE IF NOT EXISTS test;\n`; writeVfsFile(fs, "/etc/mariadb/bootstrap.sql", bootstrapSql); - // bootstrap-runner: backgrounds mariadbd --bootstrap, sleeps to let - // it drain SQL, then SIGTERMs it. See per-engine notes in - // build-mariadb-vfs-image.ts for why `wait` is unsafe here. + // bootstrap-runner: backgrounds mariadbd --bootstrap, sleeps to let it + // drain SQL, then terminates and reaps the direct child before returning. const bootstrapArgs = [ ...commonMariadbArgs(), "--bootstrap", "--skip-networking", "--log-warnings=0", @@ -265,56 +265,25 @@ sleep 30 kill -TERM $PID 2>/dev/null sleep 1 kill -KILL $PID 2>/dev/null +wait $PID 2>/dev/null || true exit 0 `); - // Test files - ensureDirRecursive(fs, "/mysql-test/main"); - let testCount = 0; - - if (includeAll) { - console.log(" Writing ALL .test files from main/..."); - const mainDir = resolve(MYSQL_TEST_DIR, "main"); - for (const name of readdirSync(mainDir).sort()) { - if (!name.endsWith(".test")) continue; - const full = join(mainDir, name); - try { - const stat = lstatSync(full); - if (!stat.isFile()) continue; - const data = readFileSync(full); - writeVfsBinary(fs, `/mysql-test/main/${name}`, new Uint8Array(data), 0o644); - testCount++; - } catch { /* skip */ } - } - } else { - console.log(" Writing curated test files..."); - for (const name of CURATED_TESTS) { - const testFile = resolve(MYSQL_TEST_DIR, "main", `${name}.test`); - if (existsSync(testFile)) { - const data = readFileSync(testFile); - writeVfsBinary(fs, `/mysql-test/main/${name}.test`, new Uint8Array(data), 0o644); - testCount++; - } - } - } + console.log( + includeAll + ? " Writing ALL .test files and required fixtures..." + : " Writing curated .test files and required fixtures...", + ); + const testCount = copyMariaDbTestSources(fs, MYSQL_TEST_DIR, { + includeAll, + curatedTests: CURATED_TESTS, + }); console.log(` ${testCount} test files`); // Setup and reset SQL test files (run by the page after server-ready) writeVfsFile(fs, "/mysql-test/main/__setup.test", SETUP_SQL); writeVfsFile(fs, "/mysql-test/main/__reset.test", RESET_SQL); - // Include + std_data directories - const includeDir = resolve(MYSQL_TEST_DIR, "include"); - if (existsSync(includeDir)) { - console.log(" Writing include/ directory..."); - walkAndWrite(fs, includeDir, "/mysql-test/include"); - } - const stdDataDir = resolve(MYSQL_TEST_DIR, "std_data"); - if (existsSync(stdDataDir)) { - console.log(" Writing std_data/ directory..."); - walkAndWrite(fs, stdDataDir, "/mysql-test/std_data"); - } - // dinit service tree (no auto-boot — page passes target service as argv). // We use the default boot:true here because the page only ever wants // the mariadb tree up; no engine selection like the mariadb demo. diff --git a/images/vfs/scripts/build-mariadb-vfs-image.ts b/images/vfs/scripts/build-mariadb-vfs-image.ts index 44bb6b6529..35f8a1ad2a 100644 --- a/images/vfs/scripts/build-mariadb-vfs-image.ts +++ b/images/vfs/scripts/build-mariadb-vfs-image.ts @@ -1,6 +1,6 @@ /** * Build a fully-bootable VFS image for the MariaDB browser demo. - * dinit (PID 1) brings up the selected engine's service tree: + * dinit, the first user process, brings up the selected engine's service tree: * * -bootstrap (scripted, oneshot) → -mariadb (process) * diff --git a/images/vfs/scripts/build-nginx-php-vfs-image.ts b/images/vfs/scripts/build-nginx-php-vfs-image.ts index f7baa15b05..005cbb62d7 100644 --- a/images/vfs/scripts/build-nginx-php-vfs-image.ts +++ b/images/vfs/scripts/build-nginx-php-vfs-image.ts @@ -1,6 +1,6 @@ /** * Build a fully-bootable VFS image for the nginx + PHP-FPM demo. The image - * starts from shell.vfs.zst, then dinit (PID 1) brings up php-fpm on :9000 + * starts from shell.vfs.zst, then dinit, the first user process, brings up php-fpm on :9000 * and nginx on :8080 (depends-on chain ensures php-fpm is up first). * * Produces: apps/browser-demos/public/nginx-php.vfs @@ -202,7 +202,7 @@ sort($extensions);

This page is dynamically rendered by PHP-FPM, proxied via FastCGI from nginx, both running inside - the same POSIX kernel. dinit (PID 1) brought them up in dependency + the same POSIX kernel. dinit, the first user process, brought them up in dependency order: php-fpm first, then nginx.

diff --git a/images/vfs/scripts/build-nginx-vfs-image.ts b/images/vfs/scripts/build-nginx-vfs-image.ts index 30ab8a71d0..16549f585e 100644 --- a/images/vfs/scripts/build-nginx-vfs-image.ts +++ b/images/vfs/scripts/build-nginx-vfs-image.ts @@ -1,8 +1,8 @@ /** * Build a fully-bootable VFS image for the nginx demo. The image starts from - * shell.vfs.zst, then adds dinit (PID 1), nginx, the nginx config + static - * content, and a single dinit service file. The browser demo just fetches the - * image and boots — no JS-side orchestration. + * shell.vfs.zst, then adds dinit as the first user process, nginx, the nginx + * config + static content, and a single dinit service file. The browser demo + * just fetches the image and boots — no JS-side orchestration. * * Produces: apps/browser-demos/public/nginx.vfs * @@ -97,7 +97,7 @@ const INDEX_HTML = `

This page is served by nginx running inside a POSIX kernel compiled to WebAssembly. The kernel was booted with - /sbin/dinit as PID 1, which read + /sbin/dinit as the first user process, which read /etc/dinit.d/nginx and brought the service up.

Request flow: browser fetch → service worker → main thread → TCP connection injected into the kernel → nginx (Wasm) → response diff --git a/images/vfs/scripts/build-php-test-vfs-image.ts b/images/vfs/scripts/build-php-test-vfs-image.ts index 68f66178d2..b01ec9b2bb 100644 --- a/images/vfs/scripts/build-php-test-vfs-image.ts +++ b/images/vfs/scripts/build-php-test-vfs-image.ts @@ -406,7 +406,6 @@ async function main() { exclude: (childRel) => shouldExclude(phpSrc, rel ? `${rel}/${childRel}` : childRel), preserveMode: true, preserveSymlinks: true, - failOnError: true, }); } if (supportDirs.length > 0) { diff --git a/images/vfs/scripts/build-redis-vfs-image.ts b/images/vfs/scripts/build-redis-vfs-image.ts index 002d9dc4ae..f5ea5bbfbe 100644 --- a/images/vfs/scripts/build-redis-vfs-image.ts +++ b/images/vfs/scripts/build-redis-vfs-image.ts @@ -1,5 +1,5 @@ /** - * Build a fully-bootable VFS image for the Redis demo. dinit (PID 1) + * Build a fully-bootable VFS image for the Redis demo. dinit, the first user process, * brings up redis-server on port 6379 with persistence disabled. * * Produces: apps/browser-demos/public/redis.vfs diff --git a/images/vfs/scripts/build-wp-vfs-image.ts b/images/vfs/scripts/build-wp-vfs-image.ts index cc5d64d75f..f36bea73cb 100644 --- a/images/vfs/scripts/build-wp-vfs-image.ts +++ b/images/vfs/scripts/build-wp-vfs-image.ts @@ -1,6 +1,6 @@ /** * Build a fully-bootable VFS image for the WordPress browser demo. The image - * starts from shell.vfs.zst, then dinit (PID 1) brings up: + * starts from shell.vfs.zst, then dinit, the first user process, brings up: * * wp-config-init (internal) + smtp-capture (process) * → php-fpm (process) → nginx (process) @@ -19,10 +19,8 @@ import { writeVfsFile, writeVfsBinary, ensureDirRecursive, - walkAndWrite, } from "./vfs-image-helpers"; import { addDinitInit, type DinitService } from "./dinit-image-helpers"; -import { ensureSourceExtract, ensureExtract } from "./source-extract-helper"; import { prewarmOpcache } from "./opcache-prewarm"; import { webPresentation, @@ -46,27 +44,17 @@ import { wordpressConfigTemplate, } from "../../../apps/browser-demos/lib/init/wordpress-runtime-config"; import { preinstallWordPressSqlite } from "./wordpress-preinstall"; +import { + copyWordPressCoreSource, + materializeWordPressSqlitePlugin, + resolveWordPressCoreSource, + resolveWordPressSqlitePluginSource, +} from "./wordpress-source-layout"; const REPO_ROOT = findRepoRoot(); const BROWSER_DIR = join(REPO_ROOT, "apps", "browser-demos"); -const WP_SOURCE_DIR = join(REPO_ROOT, "packages", "registry", "wordpress"); -// WordPress + SQLite-Database-Integration plugin trees: prefer the local -// `packages/registry/wordpress/setup.sh` outputs if present, otherwise -// download both via source-extract-helper. The WP version + sha live in the -// wordpress package's package.toml; the SQLite plugin is a wp.org-hosted zip -// with no package.toml of its own, so its URL+sha are pinned here. -const SQLITE_PLUGIN_VERSION = "2.1.16"; -const SQLITE_PLUGIN_URL = - `https://downloads.wordpress.org/plugin/sqlite-database-integration.${SQLITE_PLUGIN_VERSION}.zip`; -const SQLITE_PLUGIN_SHA256 = - "ccc69cada05983e6c2dac8c0962b548c437b4c96c00ea41b0e130fc128671391"; -const WP_DIR = ensureSourceExtract("wordpress", REPO_ROOT, join(WP_SOURCE_DIR, "wordpress")); -const SQLITE_DIR = ensureExtract({ - url: SQLITE_PLUGIN_URL, - sha256: SQLITE_PLUGIN_SHA256, - cacheKey: `sqlite-database-integration-${SQLITE_PLUGIN_VERSION}`, - legacyPath: join(WP_SOURCE_DIR, "sqlite-database-integration"), -}); +const WP_DIR = resolveWordPressCoreSource(REPO_ROOT); +const SQLITE_DIR = resolveWordPressSqlitePluginSource(); const NGINX_PATH = resolveBinary("programs/nginx.wasm"); const PHP_FPM_PATH = resolveBinary("programs/php/php-fpm.wasm"); const OPCACHE_SO_PATH = resolveBinary("programs/php/opcache.so"); @@ -375,19 +363,13 @@ async function main() { ); // WordPress core files - const excludeDb = (rel: string) => rel.endsWith(".db") || rel === "wp-config.php"; console.log("Writing WordPress core files..."); - let wpCount = walkAndWrite(fs, WP_DIR, "/var/www/html", { exclude: excludeDb }); + let wpCount = copyWordPressCoreSource(fs, WP_DIR); console.log(` WordPress core: ${wpCount} files`); // SQLite plugin files console.log("Writing SQLite plugin files..."); - const sqliteCount = walkAndWrite( - fs, - SQLITE_DIR, - "/var/www/html/wp-content/plugins/sqlite-database-integration", - { exclude: excludeDb }, - ); + const sqliteCount = materializeWordPressSqlitePlugin(fs, SQLITE_DIR); console.log(` SQLite plugin: ${sqliteCount} files`); wpCount += sqliteCount; diff --git a/images/vfs/scripts/dinit-image-helpers.ts b/images/vfs/scripts/dinit-image-helpers.ts index ae10aedd85..5d116790bc 100644 --- a/images/vfs/scripts/dinit-image-helpers.ts +++ b/images/vfs/scripts/dinit-image-helpers.ts @@ -4,7 +4,9 @@ * and per-service config files into the image alongside the demo's * binaries and content. * - * The browser demo fetches the resulting .vfs and boots dinit as PID 1. + * The browser demo fetches the resulting .vfs and boots dinit as the first + * user process (normally PID 100). PID 1 is a kernel-reserved synthetic + * process record, not the dinit worker. * In container mode, pass a long-running target service such as `nginx` * (for example `["/sbin/dinit", "--container", "nginx"]`). The generated * `boot` service is only a dependency aggregator; as an initial container diff --git a/images/vfs/scripts/mariadb-test-source-copy.ts b/images/vfs/scripts/mariadb-test-source-copy.ts new file mode 100644 index 0000000000..5dc975a79b --- /dev/null +++ b/images/vfs/scripts/mariadb-test-source-copy.ts @@ -0,0 +1,75 @@ +import { + lstatSync, + readFileSync, + readdirSync, +} from "node:fs"; +import { join } from "node:path"; +import { type MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { + ensureDirRecursive, + walkAndWrite, + writeVfsBinary, +} from "./vfs-image-helpers"; + +export interface MariaDbTestSourceCopyOptions { + includeAll: boolean; + curatedTests: readonly string[]; +} + +function requireRegularTestSource(path: string): Uint8Array { + const stat = lstatSync(path); + if (!stat.isFile()) { + throw new Error(`MariaDB test source entry is not a regular file: ${path}`); + } + return new Uint8Array(readFileSync(path)); +} + +function copyRequiredFixtureTree( + fs: MemoryFileSystem, + mysqlTestDir: string, + name: "include" | "std_data", +): void { + const source = join(mysqlTestDir, name); + const count = walkAndWrite(fs, source, `/mysql-test/${name}`); + if (count === 0) { + throw new Error(`Required MariaDB test fixture tree is empty: ${source}`); + } +} + +/** + * Copy the declared MariaDB test closure without best-effort omissions. + * + * The upstream source is pinned, so a missing curated test or fixture tree is + * a broken build input rather than an optional feature. Any host read, source + * type, or VFS write failure must abort the artifact build. + */ +export function copyMariaDbTestSources( + fs: MemoryFileSystem, + mysqlTestDir: string, + options: MariaDbTestSourceCopyOptions, +): number { + const mainDir = join(mysqlTestDir, "main"); + const testFiles = options.includeAll + ? readdirSync(mainDir).filter((name) => name.endsWith(".test")).sort() + : options.curatedTests.map((name) => `${name}.test`); + if (testFiles.length === 0) { + throw new Error(`No MariaDB test sources were selected from ${mainDir}`); + } + if (new Set(testFiles).size !== testFiles.length) { + throw new Error("MariaDB test source selection contains duplicate entries"); + } + + ensureDirRecursive(fs, "/mysql-test/main"); + for (const fileName of testFiles) { + writeVfsBinary( + fs, + `/mysql-test/main/${fileName}`, + requireRegularTestSource(join(mainDir, fileName)), + 0o644, + ); + } + + copyRequiredFixtureTree(fs, mysqlTestDir, "include"); + copyRequiredFixtureTree(fs, mysqlTestDir, "std_data"); + return testFiles.length; +} diff --git a/images/vfs/scripts/shell-vfs-build.ts b/images/vfs/scripts/shell-vfs-build.ts index 8811c4766a..16d70dceef 100644 --- a/images/vfs/scripts/shell-vfs-build.ts +++ b/images/vfs/scripts/shell-vfs-build.ts @@ -41,6 +41,7 @@ import type { SaveImageOptions } from "./vfs-image-helpers"; import { SHELL_DERIVED_VFS_MIN_FREE_BYTES, SHELL_DERIVED_VFS_MIN_FREE_INODES, + SHELL_DERIVED_VFS_PROFILE_MAX_BYTES, } from "../../../web-libs/kandelo-session/src/vfs-capacity"; function depEnvKey(name: string): string { @@ -91,10 +92,34 @@ export function loadShellBaseFileSystem(maxByteLength: number): MemoryFileSystem export function saveShellDerivedVfsImage( fs: MemoryFileSystem, outFile: string, - options: Omit = {}, + options: Omit< + SaveImageOptions, + "headroom" | "expectedMaxByteLength" + > & { + /** Explicit escape hatch for a reviewed product profile above 768 MiB. */ + expectedMaxByteLength?: number; + } = {}, ): Promise { + const { + expectedMaxByteLength = SHELL_DERIVED_VFS_PROFILE_MAX_BYTES, + ...saveOptions + } = options; + if ( + expectedMaxByteLength !== SHELL_DERIVED_VFS_PROFILE_MAX_BYTES && + ( + !Number.isSafeInteger(expectedMaxByteLength) || + expectedMaxByteLength <= SHELL_DERIVED_VFS_PROFILE_MAX_BYTES + ) + ) { + throw new Error( + `${outFile} expectedMaxByteLength must use the standard ` + + `${SHELL_DERIVED_VFS_PROFILE_MAX_BYTES}-byte product profile or ` + + "an explicitly reviewed, strictly larger profile", + ); + } return saveImage(fs, outFile, { - ...options, + ...saveOptions, + expectedMaxByteLength, headroom: { minimumFreeBytes: SHELL_DERIVED_VFS_MIN_FREE_BYTES, minimumFreeInodes: SHELL_DERIVED_VFS_MIN_FREE_INODES, diff --git a/images/vfs/scripts/vfs-image-helpers.ts b/images/vfs/scripts/vfs-image-helpers.ts index 7313bf0a52..7cde858953 100644 --- a/images/vfs/scripts/vfs-image-helpers.ts +++ b/images/vfs/scripts/vfs-image-helpers.ts @@ -13,9 +13,9 @@ import { } from "fs"; import { join, relative } from "path"; import { zstdCompressSync, constants as zlibConstants } from "node:zlib"; -import type { +import { MemoryFileSystem, - VfsImageMetadata, + type VfsImageMetadata, } from "../../../host/src/vfs/memory-fs"; import { describeWasmArtifactPolicyFailures } from "../../../host/src/constants"; import { ABI_VERSION } from "../../../host/src/generated/abi"; @@ -33,12 +33,17 @@ import { writeVfsBinary, ensureDirRecursive } from "../../../host/src/vfs/image- export interface WalkOptions { exclude?: (relPath: string) => boolean; preserveMode?: boolean; - preserveSymlinks?: boolean; - failOnError?: boolean; + preserveSymlinks?: true; } /** * Walk a host directory and write all files into the VFS under mountPrefix. + * Any host-read or VFS-write failure aborts the build. Product images must not + * silently omit an entry; callers that intentionally exclude content must do + * so through `exclude`. + * + * Unexcluded symlinks must be preserved explicitly. Silently omitting a + * representable entry would produce an incomplete product image. * Returns the number of files written. */ export function walkAndWrite( @@ -56,32 +61,32 @@ export function walkAndWrite( const rel = relative(rootDir, full); const mountPath = mountPrefix + "/" + rel; - try { - const lstat = lstatSync(full); - if (opts?.exclude?.(rel)) continue; - if (lstat.isSymbolicLink()) { - if (opts?.preserveSymlinks) { - ensureDirRecursive(fs, mountPath.slice(0, mountPath.lastIndexOf("/")) || "/"); - fs.symlink(readlinkSync(full), mountPath); - count++; - } - } else if (lstat.isDirectory()) { - ensureDirRecursive(fs, mountPath); - if (opts?.preserveMode) fs.chmod(mountPath, lstat.mode & 0o7777); - walk(full); - } else if (lstat.isFile()) { - const data = readFileSync(full); - writeVfsBinary( - fs, - mountPath, - new Uint8Array(data), - opts?.preserveMode ? lstat.mode & 0o7777 : 0o644, + const lstat = lstatSync(full); + if (opts?.exclude?.(rel)) continue; + if (lstat.isSymbolicLink()) { + if (!opts?.preserveSymlinks) { + throw new Error( + `VFS image source symlink requires preserveSymlinks or an explicit exclude: ${full}`, ); - count++; } - } catch (err) { - if (opts?.failOnError) throw err; - // Skip unreadable files + ensureDirRecursive(fs, mountPath.slice(0, mountPath.lastIndexOf("/")) || "/"); + fs.symlink(readlinkSync(full), mountPath); + count++; + } else if (lstat.isDirectory()) { + ensureDirRecursive(fs, mountPath); + if (opts?.preserveMode) fs.chmod(mountPath, lstat.mode & 0o7777); + walk(full); + } else if (lstat.isFile()) { + const data = readFileSync(full); + writeVfsBinary( + fs, + mountPath, + new Uint8Array(data), + opts?.preserveMode ? lstat.mode & 0o7777 : 0o644, + ); + count++; + } else { + throw new Error(`Unsupported VFS image source entry: ${full}`); } } } @@ -107,6 +112,8 @@ export interface SaveImageOptions { normalizeTimestampsMs?: number; /** Runtime allocation reserve that must remain after build-time population. */ headroom?: VfsImageHeadroom; + /** Exact growth ceiling that the serialized artifact must encode. */ + expectedMaxByteLength?: number; } export interface VfsImageHeadroom { @@ -138,7 +145,18 @@ function readVfsBytes(fs: MemoryFileSystem, path: string): Uint8Array { const fd = fs.open(path, 0, 0); try { const buf = new Uint8Array(st.size); - fs.read(fd, buf, null, buf.length); + let offset = 0; + while (offset < buf.length) { + const remaining = buf.length - offset; + const count = fs.read(fd, buf.subarray(offset), null, remaining); + if (!Number.isSafeInteger(count) || count <= 0 || count > remaining) { + throw new Error( + `Incomplete VFS artifact read for ${path}: ` + + `${offset} of ${buf.length} bytes before result ${count}`, + ); + } + offset += count; + } return buf; } finally { fs.close(fd); @@ -188,25 +206,38 @@ export function assertVfsImageHeadroom( } } -function walkVfsFiles(fs: MemoryFileSystem, dir: string, out: string[] = []): string[] { - let dh: number; - try { - dh = fs.opendir(dir); - } catch { - return out; +/** Require a serialized artifact's encoded growth ceiling to match its product profile. */ +export function assertVfsImageCapacity( + image: Uint8Array, + expectedMaxByteLength: number, + label: string, +): void { + if (!Number.isSafeInteger(expectedMaxByteLength) || expectedMaxByteLength <= 0) { + throw new Error( + `${label} expectedMaxByteLength must be a positive safe integer`, + ); } + const actualMaxByteLength = + MemoryFileSystem.readImageCapacity(image).maxByteLength; + if (actualMaxByteLength !== expectedMaxByteLength) { + throw new Error( + `${label} has a ${actualMaxByteLength}-byte VFS capacity; ` + + `${expectedMaxByteLength} bytes are required by its product profile`, + ); + } +} + +function walkVfsFiles(fs: MemoryFileSystem, dir: string, out: string[] = []): string[] { + // WHY: this walk protects the artifact that will be published. A namespace + // inspection failure is not an intentional omission and must stop the build. + const dh = fs.opendir(dir); try { for (;;) { const entry = fs.readdir(dh); if (!entry) break; if (entry.name === "." || entry.name === "..") continue; const path = dir === "/" ? `/${entry.name}` : `${dir}/${entry.name}`; - let st; - try { - st = fs.lstat(path); - } catch { - continue; - } + const st = fs.lstat(path); const kind = st.mode & 0xf000; if (kind === 0x4000) { walkVfsFiles(fs, path, out); @@ -231,12 +262,11 @@ function isWasm(bytes: Uint8Array): boolean { function assertNoStaleWasmArtifacts(fs: MemoryFileSystem, kernelAbi: number): void { const failures: string[] = []; for (const path of walkVfsFiles(fs, "/")) { - let bytes: Uint8Array; - try { - bytes = readVfsBytes(fs, path); - } catch { - continue; - } + // WHY: a deferred entry deliberately has no local bytes to inspect; its + // closed package identity is validated at registration/materialization. + // Every non-deferred read failure could otherwise hide a stale artifact. + if (fs.isPathDeferred(path)) continue; + const bytes = readVfsBytes(fs, path); if (!isWasm(bytes)) continue; const artifactBytes = new Uint8Array(bytes.byteLength); artifactBytes.set(bytes); @@ -283,6 +313,9 @@ export async function saveImage( metadata, normalizeTimestampsMs: options.normalizeTimestampsMs, }); + if (options.expectedMaxByteLength !== undefined) { + assertVfsImageCapacity(image, options.expectedMaxByteLength, outFile); + } // Level 19 — slow build, smaller download. Decompression speed is // unaffected by compression level, so this is a one-sided trade. const compressed = zstdCompressSync(image, { diff --git a/images/vfs/scripts/wordpress-source-layout.ts b/images/vfs/scripts/wordpress-source-layout.ts new file mode 100644 index 0000000000..72f21e4090 --- /dev/null +++ b/images/vfs/scripts/wordpress-source-layout.ts @@ -0,0 +1,92 @@ +import type { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { + ensureExtract, + ensureSourceExtract, + type ExtractOptions, +} from "./source-extract-helper"; +import { walkAndWrite } from "./vfs-image-helpers"; + +/** + * `packages/registry/wordpress/setup.sh` creates this host-only alias so the + * local, unpacked WordPress tree can find the separately unpacked SQLite + * plugin. Product VFS builders compose their dependencies explicitly instead: + * the SQLite image copies the pinned plugin source into the VFS, while the + * MariaDB image must not include it. + * + * The setup alias is absolute and names the checkout that created it, so it + * must never be preserved in a portable VFS image or followed implicitly. + */ +export const WORDPRESS_SETUP_SQLITE_PLUGIN_ALIAS = + "wp-content/plugins/sqlite-database-integration"; +export const WORDPRESS_CORE_GUEST_PATH = "/var/www/html"; +export const WORDPRESS_SQLITE_PLUGIN_GUEST_PATH = + "/var/www/html/wp-content/plugins/sqlite-database-integration"; +export const WORDPRESS_SQLITE_PLUGIN_VERSION = "2.1.16"; +export const WORDPRESS_SQLITE_PLUGIN_URL = + `https://downloads.wordpress.org/plugin/sqlite-database-integration.${WORDPRESS_SQLITE_PLUGIN_VERSION}.zip`; +export const WORDPRESS_SQLITE_PLUGIN_SHA256 = + "ccc69cada05983e6c2dac8c0962b548c437b4c96c00ea41b0e130fc128671391"; + +type SourceExtract = typeof ensureSourceExtract; +type ArchiveExtract = (options: ExtractOptions) => string; + +/** Resolve WordPress core from its package.toml URL and SHA-256 contract. */ +export function resolveWordPressCoreSource( + repoRoot: string, + resolve: SourceExtract = ensureSourceExtract, +): string { + return resolve("wordpress", repoRoot); +} + +/** Resolve the separately pinned SQLite plugin archive used by WordPress. */ +export function resolveWordPressSqlitePluginSource( + resolve: ArchiveExtract = ensureExtract, +): string { + return resolve({ + url: WORDPRESS_SQLITE_PLUGIN_URL, + sha256: WORDPRESS_SQLITE_PLUGIN_SHA256, + cacheKey: + `sqlite-database-integration-${WORDPRESS_SQLITE_PLUGIN_VERSION}`, + }); +} + +export function isWordPressSetupOnlySourceEntry(relativePath: string): boolean { + return relativePath === WORDPRESS_SETUP_SQLITE_PLUGIN_ALIAS; +} + +/** Product policy for generated and mutable entries outside WordPress core. */ +export function isExcludedWordPressCoreSourceEntry( + relativePath: string, +): boolean { + return relativePath.endsWith(".db") || + relativePath === "wp-config.php" || + relativePath === "wp-content/db.php" || + isWordPressSetupOnlySourceEntry(relativePath); +} + +/** Copy verified WordPress core without local setup or mutable database state. */ +export function copyWordPressCoreSource( + fs: MemoryFileSystem, + sourceDir: string, +): number { + return walkAndWrite(fs, sourceDir, WORDPRESS_CORE_GUEST_PATH, { + exclude: isExcludedWordPressCoreSourceEntry, + }); +} + +/** + * Materialize the verified plugin source at the path WordPress loads. This is + * deliberately a second source-tree walk rather than following setup.sh's + * host-only alias through the WordPress core tree. + */ +export function materializeWordPressSqlitePlugin( + fs: MemoryFileSystem, + sourceDir: string, +): number { + return walkAndWrite( + fs, + sourceDir, + WORDPRESS_SQLITE_PLUGIN_GUEST_PATH, + { exclude: (relativePath) => relativePath.endsWith(".db") }, + ); +} diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index 97128fe098..0ff0a85295 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 41u +#define WASM_POSIX_ABI_VERSION 42u /* 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 fe4cb91db5..08b8d8c464 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -187,6 +187,7 @@ _Noreturn void kernel_exit(int32_t status); * fork callers, not every function that makes any syscall. */ void __fork_handler(int); +void __wasm_posix_after_fork_child(void); /* _Fork/fork/vfork MUST NOT be inlined. wasm-fork-instrument discovers * the call chain around kernel_fork. At -O2, LLVM inlines these wrappers into every caller @@ -206,6 +207,9 @@ int _Fork(void) *__errno_location() = (int)(-ret); return -1; } + if (ret == 0) { + __wasm_posix_after_fork_child(); + } return (int)ret; } diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index 556de55db5..3f4f722ed6 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -19,23 +19,9 @@ /* Process / Fork / Exec management */ /* ------------------------------------------------------------------ */ -KERNEL_IMPORT(kernel_init) -void kernel_init(uint32_t pid); - KERNEL_IMPORT(kernel_get_fork_state) int32_t kernel_get_fork_state(uint8_t *buf_ptr, uint32_t buf_len); -KERNEL_IMPORT(kernel_init_from_fork) -int32_t kernel_init_from_fork(const uint8_t *buf_ptr, uint32_t buf_len, - uint32_t child_pid); - -KERNEL_IMPORT(kernel_get_exec_state) -int32_t kernel_get_exec_state(uint8_t *buf_ptr, uint32_t buf_len); - -KERNEL_IMPORT(kernel_init_from_exec) -int32_t kernel_init_from_exec(const uint8_t *buf_ptr, uint32_t buf_len, - uint32_t pid); - KERNEL_IMPORT(kernel_convert_pipe_to_host) int32_t kernel_convert_pipe_to_host(uint32_t ofd_idx, int64_t new_host_handle); @@ -246,9 +232,6 @@ int32_t kernel_setsid(void); KERNEL_IMPORT(kernel_kill) int32_t kernel_kill(int32_t pid, uint32_t sig); -KERNEL_IMPORT(kernel_deliver_signal) -int32_t kernel_deliver_signal(uint32_t sig); - KERNEL_IMPORT(kernel_raise) int32_t kernel_raise(uint32_t sig); diff --git a/libc/musl-overlay/src/process/wasm32posix/post_fork_child.c b/libc/musl-overlay/src/process/wasm32posix/post_fork_child.c new file mode 100644 index 0000000000..a95e6b6550 --- /dev/null +++ b/libc/musl-overlay/src/process/wasm32posix/post_fork_child.c @@ -0,0 +1,21 @@ +/* + * Restore musl's single-threaded child state after Kandelo resumes a fork + * continuation. The child keeps the calling thread's TLS, so its copied + * pthread descriptor must be rebound to the task ID allocated by the kernel. + */ + +#include "pthread_impl.h" +#include "syscall.h" + +hidden void __wasm_posix_after_fork_child(void) +{ + pthread_t self = __pthread_self(); + + self->tid = __syscall(SYS_set_tid_address, &__thread_list_lock); + self->robust_list.off = 0; + self->robust_list.pending = 0; + self->next = self->prev = self; + __thread_list_lock = 0; + libc.threads_minus_1 = 0; + if (libc.need_locks) libc.need_locks = -1; +} diff --git a/packages/registry/bash/build-bash.sh b/packages/registry/bash/build-bash.sh index c94765b43f..f260d4e1d1 100755 --- a/packages/registry/bash/build-bash.sh +++ b/packages/registry/bash/build-bash.sh @@ -124,6 +124,9 @@ if [ ! -f Makefile ]; then export CFLAGS="-O2 -gline-tables-only -Wno-implicit-function-declaration -Wno-int-conversion -Wno-incompatible-pointer-types" export LDFLAGS="-Wl,-z,stack-size=1048576 ${LDFLAGS_NCURSES:-}" + # Stock Homebrew uses compgen while resetting Bash's builtin command set + # during every invocation. Keep programmable completion enabled even when + # the image does not install interactive completion scripts. wasm32posix-configure \ --prefix=/usr \ --without-bash-malloc \ @@ -134,7 +137,7 @@ if [ ! -f Makefile ]; then --disable-nls \ --disable-mem-scramble \ --disable-net-redirections \ - --disable-progcomp \ + --enable-progcomp \ 2>&1 | tail -30 echo "==> Configure complete." diff --git a/packages/registry/bash/build.toml b/packages/registry/bash/build.toml index 3374720076..410c9f054f 100644 --- a/packages/registry/bash/build.toml +++ b/packages/registry/bash/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/bash/build-bash.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 3 +revision = 4 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/bash/test/bash.test.ts b/packages/registry/bash/test/bash.test.ts index 6830f071ea..b65a6b53c9 100644 --- a/packages/registry/bash/test/bash.test.ts +++ b/packages/registry/bash/test/bash.test.ts @@ -179,6 +179,46 @@ describe.skipIf(!hasBash)("bash shell", () => { expect(result.stdout.trim()).toBe("y"); }); + it("can restore and enumerate the builtins used by Homebrew", async () => { + const result = await runCentralizedProgram({ + programPath: bashBinary, + argv: [ + "bash", + "-c", + [ + 'test "$(type -t printf)" = builtin', + "builtin enable -n printf", + 'test "$(type -t printf)" != builtin', + "builtin enable printf", + 'test "$(type -t printf)" = builtin', + "printf() { echo shadowed; }", + "builtin enable compgen unset", + "saw_compgen= saw_unset=", + "for cmd in $(builtin compgen -A builtin); do", + ' case "$cmd" in', + " compgen) saw_compgen=yes ;;", + " unset) saw_unset=yes ;;", + " esac", + ' builtin unset -f "$cmd"', + ' builtin enable "$cmd"', + "done", + 'test "$saw_compgen" = yes', + 'test "$saw_unset" = yes', + 'test "$(type -t compgen)" = builtin', + 'test "$(type -t complete)" = builtin', + 'test "$(type -t unset)" = builtin', + 'test "$(type -t printf)" = builtin', + 'printf "homebrew-builtins-ready\\n"', + ].join("\n"), + ], + env: bashEnv, + timeout: 20_000, + }); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe("homebrew-builtins-ready"); + expect(result.stderr).toBe(""); + }); + it("exits with the correct status", async () => { const result = await runCentralizedProgram({ programPath: bashBinary, diff --git a/packages/registry/bash/test/test-bash.ts b/packages/registry/bash/test/test-bash.ts index aeb0c5e4aa..b10b41abe2 100644 --- a/packages/registry/bash/test/test-bash.ts +++ b/packages/registry/bash/test/test-bash.ts @@ -58,6 +58,29 @@ const cases: [string, string, string][] = [ ["s=hello; echo ${s^^}", "HELLO\n", "case-mod expansion (bashism)"], ["type history >/dev/null && echo y", "y\n", "history builtin available"], ["type bind >/dev/null && echo y", "y\n", "readline bind builtin available"], + [ + 'test "$(type -t printf)" = builtin; ' + + "builtin enable -n printf; " + + 'test "$(type -t printf)" != builtin; ' + + "builtin enable printf; " + + 'test "$(type -t printf)" = builtin; ' + + "printf() { echo shadowed; }; " + + "builtin enable compgen unset; " + + "saw_compgen= saw_unset=; " + + "for cmd in $(builtin compgen -A builtin); do " + + 'case "$cmd" in compgen) saw_compgen=yes ;; unset) saw_unset=yes ;; esac; ' + + 'builtin unset -f "$cmd"; ' + + 'builtin enable "$cmd"; ' + + "done; " + + 'test "$saw_compgen" = yes && test "$saw_unset" = yes && ' + + 'test "$(type -t compgen)" = builtin && ' + + 'test "$(type -t complete)" = builtin && ' + + 'test "$(type -t unset)" = builtin && ' + + 'test "$(type -t printf)" = builtin && ' + + 'printf "homebrew-builtins-ready\\n"', + "homebrew-builtins-ready\n", + "Homebrew builtin restoration contract", + ], ["echo hello | cat", "hello\n", "simple pipe (cat)"], ["echo hello world | wc -c", "12\n", "pipe to wc -c"], ["printf 'b\\na\\n' | sort", "a\nb\n", "pipe to sort"], diff --git a/packages/registry/cpython/test/debug-test.ts b/packages/registry/cpython/test/debug-test.ts index 808f8aecff..5fbfd63a63 100644 --- a/packages/registry/cpython/test/debug-test.ts +++ b/packages/registry/cpython/test/debug-test.ts @@ -80,13 +80,12 @@ async function main() { memory.grow(MAX_PAGES - 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - const pid = 1; - kernelWorker.registerProcess(pid, memory, [channelOffset], { stdio: CAPTURED_STDIO }); + const pid = kernelWorker.createProcess(CAPTURED_STDIO); + kernelWorker.registerProcess(pid, memory, [channelOffset]); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes, memory, channelOffset, diff --git a/packages/registry/dinit/build-dinit.sh b/packages/registry/dinit/build-dinit.sh index 719d87f56c..a288bef0c6 100755 --- a/packages/registry/dinit/build-dinit.sh +++ b/packages/registry/dinit/build-dinit.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Build dinit (https://github.com/davmac314/dinit) for wasm32-posix. -# dinit is a service supervisor / init system. We use it as PID 1 in +# dinit is a service supervisor / init system. We use it to supervise # service-demo VFS images so the demos boot via real init mechanics # (per-service config files, dependency resolution, fail-fast on # upstream failures) rather than JS-side orchestration. diff --git a/packages/registry/dinit/test/dinit-scripted-service.test.ts b/packages/registry/dinit/test/dinit-scripted-service.test.ts index 253fab36e7..daa5f56bc5 100644 --- a/packages/registry/dinit/test/dinit-scripted-service.test.ts +++ b/packages/registry/dinit/test/dinit-scripted-service.test.ts @@ -116,7 +116,7 @@ describe.skipIf(!hasArtifacts)("dinit supervisor", () => { ).toContain("[ OK ] one-shot"); // A successful scripted service means dinit forked and exec'd the - // helper, reaped its zero exit status, and stayed alive as PID 1. + // helper, reaped its zero exit status, and stayed alive as supervisor. // Leaving dasynq's pselect pull_events() noexcept makes the Wasm SjLj // transfer reach std::terminate while handling SIGCHLD instead. expect(events).toEqual(expect.arrayContaining([ diff --git a/packages/registry/erlang/build-erlang.sh b/packages/registry/erlang/build-erlang.sh index 4e9f62062d..54b234188f 100755 --- a/packages/registry/erlang/build-erlang.sh +++ b/packages/registry/erlang/build-erlang.sh @@ -705,7 +705,7 @@ prepare_runtime_wasm() { wasm-strip "$artifact" if wasm_imports_kernel_fork "$artifact" && ! wasm_has_complete_fork_instrumentation "$artifact"; then - if wasm_has_any_wpk_fork_export "$artifact"; then + if wasm_has_any_fork_instrumentation "$artifact"; then wasm_require_fork_instrumentation_if_needed "$artifact" return 1 fi diff --git a/packages/registry/homebrew-bootstrap/build-homebrew-bootstrap.sh b/packages/registry/homebrew-bootstrap/build-homebrew-bootstrap.sh new file mode 100755 index 0000000000..4a59f2f5ee --- /dev/null +++ b/packages/registry/homebrew-bootstrap/build-homebrew-bootstrap.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +OUT_DIR="${WASM_POSIX_DEP_OUT_DIR:-}" +SOURCE_CHECKOUT="${WASM_POSIX_BUILD_GIT_HOMEBREW_BREW_DIR:-}" +SOURCE_COMMIT="${WASM_POSIX_BUILD_GIT_HOMEBREW_BREW_COMMIT:-}" +LOCK="$REPO_ROOT/homebrew/homebrew-bootstrap-source-lock.json" +VERIFY="$REPO_ROOT/scripts/verify-homebrew-bootstrap-source-lock.mjs" + +if [ -z "$OUT_DIR" ]; then + echo "ERROR: homebrew-bootstrap is a resolver-owned build; WASM_POSIX_DEP_OUT_DIR is required" >&2 + exit 2 +fi +if [ -z "$SOURCE_CHECKOUT" ] || [ -z "$SOURCE_COMMIT" ]; then + echo "ERROR: homebrew-bootstrap requires build.toml git input homebrew_brew (DIR and COMMIT)" >&2 + exit 2 +fi +if [ ! -f "$LOCK" ] || [ -L "$LOCK" ]; then + echo "ERROR: homebrew-bootstrap source lock must be a regular non-symlink file" >&2 + exit 2 +fi +if [ ! -f "$VERIFY" ] || [ -L "$VERIFY" ]; then + echo "ERROR: homebrew-bootstrap source-lock verifier must be a regular non-symlink file" >&2 + exit 2 +fi + +# shellcheck source=/dev/null +source "$REPO_ROOT/scripts/package-build-roots.sh" +DEFAULT_WORK_ROOT="${OUT_DIR}.homebrew-bootstrap-work" +OWNS_WORK_ROOT=0 +if [ -z "${WASM_POSIX_DEP_WORK_DIR:-}" ]; then + if [ -e "$DEFAULT_WORK_ROOT" ] || [ -L "$DEFAULT_WORK_ROOT" ]; then + echo "ERROR: homebrew-bootstrap work root already exists: $DEFAULT_WORK_ROOT" >&2 + exit 1 + fi + OWNS_WORK_ROOT=1 +fi +kandelo_package_prepare_build_roots "$DEFAULT_WORK_ROOT" wasm32 +kandelo_package_require_disjoint_paths \ + WASM_POSIX_DEP_WORK_DIR "$KANDELO_PACKAGE_WORK_DIR" \ + WASM_POSIX_DEP_OUT_DIR "$KANDELO_PACKAGE_OUT_DIR" + +WORK_ROOT="$KANDELO_PACKAGE_WORK_DIR" +BUILD_DIR="$WORK_ROOT/homebrew-bootstrap-package" +if [ -e "$BUILD_DIR" ] || [ -L "$BUILD_DIR" ]; then + echo "ERROR: homebrew-bootstrap build directory already exists: $BUILD_DIR" >&2 + exit 1 +fi +mkdir -m 0700 "$BUILD_DIR" +cleanup() { + rm -rf -- "$BUILD_DIR" + if [ "$OWNS_WORK_ROOT" -eq 1 ]; then + rmdir "$WORK_ROOT" 2>/dev/null || true + fi +} +trap cleanup EXIT + +read_lock_field() { + node "$VERIFY" --lock "$LOCK" --field "$1" +} + +PACKAGE_NAME="${WASM_POSIX_DEP_NAME:-}" +PACKAGE_VERSION="${WASM_POSIX_DEP_VERSION:-}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" +SOURCE_REPOSITORY="$(read_lock_field source.repository)" +LOCKED_REVISION="$(read_lock_field source.revision)" +PATCH_PATH="$(read_lock_field patch.path)" +PATCH_SHA256="$(read_lock_field patch.sha256)" +PATCH_FILE="$REPO_ROOT/$PATCH_PATH" +LICENSE_EVIDENCE_PATH="$(read_lock_field license.kandelo_patch.evidence_path)" +LICENSE_EVIDENCE="$REPO_ROOT/$LICENSE_EVIDENCE_PATH" +GIT_VERSION="$(git --version)" +GIT_VERSION="${GIT_VERSION#git version }" + +if [ ! -f "$PATCH_FILE" ] || [ -L "$PATCH_FILE" ]; then + echo "ERROR: reviewed Homebrew patch must be a regular non-symlink file: $PATCH_FILE" >&2 + exit 2 +fi +if [ ! -f "$LICENSE_EVIDENCE" ] || [ -L "$LICENSE_EVIDENCE" ]; then + echo "ERROR: Homebrew patch license evidence must be a regular non-symlink file: $LICENSE_EVIDENCE" >&2 + exit 2 +fi + +node "$VERIFY" \ + --lock "$LOCK" \ + --package-name "$PACKAGE_NAME" \ + --package-version "$PACKAGE_VERSION" \ + --target-arch "$TARGET_ARCH" \ + --source-url "$SOURCE_URL" \ + --source-sha256 "$SOURCE_SHA256" \ + --git-commit "$SOURCE_COMMIT" \ + --git-version "$GIT_VERSION" \ + --patch-path "$PATCH_PATH" \ + --license-evidence "$LICENSE_EVIDENCE" \ + --source-checkout "$SOURCE_CHECKOUT" + +# The source checkout is resolver-provisioned, exact, and sealed. Source +# preparation imports only its Git objects into this private work directory; +# no credential or network state participates in the package build. +unset GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ + HOMEBREW_GITHUB_PACKAGES_TOKEN HOMEBREW_DOCKER_REGISTRY_TOKEN +export SOURCE_DATE_EPOCH=0 +export TZ=UTC +export LC_ALL=C +export LANG=C + +ARCHIVE="$BUILD_DIR/homebrew-bootstrap.zip" +ENV_FILE="$BUILD_DIR/brew.env" +PROVENANCE="$BUILD_DIR/homebrew-source.json" +"$REPO_ROOT/scripts/prepare-homebrew-bootstrap-source.sh" \ + --repository "$SOURCE_REPOSITORY" \ + --revision "$LOCKED_REVISION" \ + --source-checkout "$SOURCE_CHECKOUT" \ + --patch "$PATCH_FILE" \ + --expected-patch-sha256 "$PATCH_SHA256" \ + --arch wasm32 \ + --git-dir "$BUILD_DIR/homebrew-brew.git" \ + --archive "$ARCHIVE" \ + --env "$ENV_FILE" \ + --provenance "$PROVENANCE" + +OUTPUT="$KANDELO_PACKAGE_OUT_DIR/homebrew-bootstrap.zip" +if [ -e "$OUTPUT" ] || [ -L "$OUTPUT" ]; then + echo "ERROR: homebrew-bootstrap output already exists: $OUTPUT" >&2 + exit 1 +fi +cp "$ARCHIVE" "$OUTPUT" +node "$VERIFY" \ + --lock "$LOCK" \ + --package-name "$PACKAGE_NAME" \ + --package-version "$PACKAGE_VERSION" \ + --target-arch "$TARGET_ARCH" \ + --source-url "$SOURCE_URL" \ + --source-sha256 "$SOURCE_SHA256" \ + --git-commit "$SOURCE_COMMIT" \ + --git-version "$GIT_VERSION" \ + --patch-path "$PATCH_PATH" \ + --license-evidence "$LICENSE_EVIDENCE" \ + --source-checkout "$SOURCE_CHECKOUT" \ + --provenance "$PROVENANCE" \ + --archive "$OUTPUT" + +echo "==> Built provenance-locked Homebrew bootstrap: $OUTPUT" diff --git a/packages/registry/homebrew-bootstrap/build.toml b/packages/registry/homebrew-bootstrap/build.toml new file mode 100644 index 0000000000..15a37e81fb --- /dev/null +++ b/packages/registry/homebrew-bootstrap/build.toml @@ -0,0 +1,21 @@ +script_path = "packages/registry/homebrew-bootstrap/build-homebrew-bootstrap.sh" +inputs = [ + "packages/registry/homebrew-bootstrap/build-homebrew-bootstrap.sh", + "scripts/package-build-roots.sh", + "scripts/prepare-homebrew-bootstrap-source.sh", + "scripts/verify-homebrew-bootstrap-source-lock.mjs", + "homebrew/homebrew-bootstrap-source-lock.json", + "homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch", + "homebrew/patches/README.md", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[[git_inputs]] +name = "homebrew_brew" +repository = "https://github.com/Homebrew/brew.git" +commit = "4ead8619231cb15cbe15e8e8188081e347d6f7cd" + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/homebrew-bootstrap/package.toml b/packages/registry/homebrew-bootstrap/package.toml new file mode 100644 index 0000000000..3e1524fc4b --- /dev/null +++ b/packages/registry/homebrew-bootstrap/package.toml @@ -0,0 +1,38 @@ +kind = "program" +name = "homebrew-bootstrap" +version = "6.0.3-4-g4ead861" +kernel_abi = 41 +arches = ["wasm32"] +depends_on = [] + +# The portable recipe identifies Homebrew's immutable upstream archive. The +# Kandelo project build additionally consumes the same commit through a sealed +# [[git_inputs]] checkout so source preparation never depends on ambient Git +# state or a second network fetch. +[source] +url = "https://github.com/Homebrew/brew/archive/4ead8619231cb15cbe15e8e8188081e347d6f7cd.tar.gz" +sha256 = "4b9fdfb4872bd2fbff001c69f91ec7b2c2b7a956459132b6c3adba878f551155" + +[license] +spdx = "BSD-2-Clause AND GPL-2.0-or-later" +url = "https://github.com/Automattic/kandelo/blob/main/homebrew/patches/README.md" + +[build] +script_path = "packages/registry/homebrew-bootstrap/build-homebrew-bootstrap.sh" + +[[outputs]] +name = "homebrew-bootstrap" +wasm = "homebrew-bootstrap.zip" +fork_instrumentation = "disabled" + +[[host_tools]] +name = "git" +version_constraint = ">=2.30" +probe = { args = ["--version"], version_regex = "git version (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "node" +version_constraint = ">=20.0" +probe = { args = ["--version"], version_regex = "v(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index f1f7c0be0f..bf9b009df6 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -30,15 +30,31 @@ wasm_require_exports "$OUT" \ 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 mkdir -p "$REPO_ROOT/local-binaries" diff --git a/packages/registry/lamp/build-lamp.sh b/packages/registry/lamp/build-lamp.sh index aeeb724931..d9658e7a65 100755 --- a/packages/registry/lamp/build-lamp.sh +++ b/packages/registry/lamp/build-lamp.sh @@ -6,9 +6,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -# Same WordPress-source bootstrap as build-wordpress.sh — see that file -# for rationale. Idempotent. -bash "$REPO_ROOT/packages/registry/wordpress/setup.sh" +# The VFS builder resolves and verifies the WordPress source archive itself. +# `packages/registry/wordpress/setup.sh` is only for the local unpacked demo; +# its checkout-specific SQLite plugin symlink is not a LAMP image input. # Build-time opcache prewarming boots NodeKernelHost against the half-built VFS, # so package builds need a host kernel even though lamp itself is a wasm32 diff --git a/packages/registry/lamp/build.toml b/packages/registry/lamp/build.toml index 7797dd6d2a..e52fa544c0 100644 --- a/packages/registry/lamp/build.toml +++ b/packages/registry/lamp/build.toml @@ -4,7 +4,6 @@ inputs = [ "packages/registry/lamp/package.toml", "packages/registry/mariadb/package.toml", "packages/registry/wordpress/package.toml", - "packages/registry/wordpress/setup.sh", "images/vfs/scripts/build-lamp-vfs-image.sh", "images/vfs/scripts/build-lamp-vfs-image.ts", "images/vfs/scripts/dinit-image-helpers.ts", @@ -20,6 +19,7 @@ inputs = [ "images/vfs/scripts/vfs-image-helpers.ts", "images/vfs/lib/init/shell-binaries.ts", "images/vfs/scripts/wordpress-preinstall.ts", + "images/vfs/scripts/wordpress-source-layout.ts", "apps/browser-demos/lib/init/mysql-benchmark.ts", "apps/browser-demos/lib/init/wordpress-runtime-config.ts", "host/src", diff --git a/packages/registry/lamp/demo/serve.ts b/packages/registry/lamp/demo/serve.ts index a3db37fea8..8874845238 100644 --- a/packages/registry/lamp/demo/serve.ts +++ b/packages/registry/lamp/demo/serve.ts @@ -2,7 +2,7 @@ * serve.ts — Full LAMP stack on kandelo. * * Runs MariaDB + PHP-FPM + nginx as separate Wasm processes in one kernel: - * - MariaDB (pid 1, threads for signal handler + timer) + * - MariaDB (threads for signal handler + timer) * - PHP-FPM (master + 6 worker processes) * - nginx (master + 2 worker processes) * diff --git a/packages/registry/lamp/package.toml b/packages/registry/lamp/package.toml index b7ad6ee9ce..96287c0161 100644 --- a/packages/registry/lamp/package.toml +++ b/packages/registry/lamp/package.toml @@ -3,7 +3,7 @@ name = "lamp" version = "0.1.0" kernel_abi = 7 # build-lamp-vfs-image.ts starts from shell.vfs.zst, then bakes a service -# image: dinit as PID 1, nginx, php-fpm/opcache, mariadbd, the MariaDB +# image: dinit as service supervisor, nginx, php-fpm/opcache, mariadbd, the MariaDB # preinitialized /data directory, and a preinstalled WordPress database. # The interactive terminal sees the same shell files that the shell demo ships. # It reads mariadbd.wasm via `resolveBinary("programs/mariadb/mariadbd.wasm")`. diff --git a/packages/registry/mariadb-test/build.toml b/packages/registry/mariadb-test/build.toml index 5c33ddc3f8..bcd3d21b53 100644 --- a/packages/registry/mariadb-test/build.toml +++ b/packages/registry/mariadb-test/build.toml @@ -6,6 +6,7 @@ inputs = [ "images/vfs/scripts/dinit-image-helpers.ts", "images/rootfs/etc/services", "images/vfs/scripts/mariadb-image-helpers.ts", + "images/vfs/scripts/mariadb-test-source-copy.ts", "images/vfs/scripts/source-extract-helper.ts", "images/vfs/scripts/vfs-image-helpers.ts", "host/src/binary-resolver.ts", diff --git a/packages/registry/mariadb-test/package.toml b/packages/registry/mariadb-test/package.toml index 4675d3cdcc..ff242871a7 100644 --- a/packages/registry/mariadb-test/package.toml +++ b/packages/registry/mariadb-test/package.toml @@ -25,7 +25,7 @@ depends_on = [ ] # Pre-built VFS image for the MariaDB test runner: mariadbd binary, -# mysql-test test files (curated 185 tests), system-table SQL, +# mysql-test test files (curated 184 tests), system-table SQL, # and init descriptors. Source at # images/vfs/scripts/build-mariadb-test-vfs-image.ts. Used by # apps/browser-demos/pages/mariadb-test/main.ts (Playwright harness). diff --git a/packages/registry/mariadb-vfs/package.toml b/packages/registry/mariadb-vfs/package.toml index ff25be0fa7..88292d032c 100644 --- a/packages/registry/mariadb-vfs/package.toml +++ b/packages/registry/mariadb-vfs/package.toml @@ -14,7 +14,7 @@ kernel_abi = 7 # missing) so the reviewed utility set cannot depend on ambient binaries # - calls `addDinitInit(...)` which calls `resolveDinitBinaries()` (strict — # throws if neither tryResolveBinary nor the source-tree path resolves) -# to bake `/sbin/dinit` + `/sbin/dinitctl` (PID 1 for the service tree). +# to bake `/sbin/dinit` + `/sbin/dinitctl` as the service supervisor. # Without these declarations a clean source-build (e.g., during manual # force-rebuild) fails. Folds each prereq's cache_key into ours so any # bump invalidates the mariadb-vfs cache. diff --git a/packages/registry/mariadb/test/run-tests.ts b/packages/registry/mariadb/test/run-tests.ts index 0f954efca0..c780b47951 100644 --- a/packages/registry/mariadb/test/run-tests.ts +++ b/packages/registry/mariadb/test/run-tests.ts @@ -115,8 +115,6 @@ function patchIncludeFiles(testDir: string) { let serverStderr = ""; let tmpTestDir = "/tmp"; const clientExitResolvers = new Map void>(); -let _nextPid = 10; -function nextPid(): number { return _nextPid++; } // Server mid-test restart state let autoRestartOnServerExit = false; @@ -196,6 +194,7 @@ async function main() { let resolveServerExit: ((status: number) => void) | null = null; let serverPort = 0; + let serverPid = 0; // Create kernel worker once (persistent for entire session) const kernelWorker = new CentralizedKernelWorker( @@ -216,12 +215,12 @@ async function main() { const childChannelOffset = (MAX_PAGES - 2) * 65536; new Uint8Array(childMemory.buffer, childChannelOffset, CH_TOTAL_SIZE).fill(0); - kernelWorker.registerProcess(childPid, childMemory, [childChannelOffset], { skipKernelCreate: true }); + kernelWorker.registerProcess(childPid, childMemory, [childChannelOffset]); const forkBufAddr = childChannelOffset - FORK_SAVE_BUFFER_SIZE; const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", - pid: childPid, ppid: parentPid, + pid: childPid, programBytes: mysqldBytes, memory: childMemory, channelOffset: childChannelOffset, isForkChild: true, forkBufAddr, @@ -235,9 +234,17 @@ async function main() { return [childChannelOffset]; }, - onClone: async (pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory) => { + onClone: async (attachment) => { + const { + pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory, + } = attachment; const alloc = threadAllocator.allocate(memory); - kernelWorker.addChannel(pid, alloc.channelOffset, tid); + try { + kernelWorker.attachThreadChannel(attachment, alloc.channelOffset); + } catch (error) { + threadAllocator.free(alloc.basePage); + throw error; + } const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", @@ -277,13 +284,12 @@ async function main() { currentTestWorker = null; } }); - return tid; }, onExec: async () => -38, // ENOSYS onExit: (exitPid, exitStatus) => { - if (exitPid === 1) { + if (exitPid === serverPid) { kernelWorker.unregisterProcess(exitPid); workers.delete(exitPid); if (autoRestartOnServerExit) { @@ -366,7 +372,7 @@ async function main() { } catch {} // Start server on same port with optional extra args - startServer(kw, wa, ws, serverBytes, dDir, port, extraArgs); + serverPid = startServer(kw, wa, ws, serverBytes, dDir, port, extraArgs); // Wait for TCP readiness for (let i = 0; i < 120; i++) { @@ -421,7 +427,9 @@ async function main() { console.error(`Starting MariaDB on port ${serverPort}...`); resolveServerExit = null; - startServer(kernelWorker, workerAdapter, workers, mysqldBytes, dataDir, serverPort); + serverPid = startServer( + kernelWorker, workerAdapter, workers, mysqldBytes, dataDir, serverPort, + ); // Wait for TCP readiness for (let i = 0; i < 120; i++) { @@ -723,10 +731,9 @@ async function runBootstrap( memory.grow(MAX_PAGES - 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - const pid = 1; - kernelWorker.registerProcess(pid, memory, [channelOffset], { stdio: CAPTURED_STDIO }); + const pid = kernelWorker.createProcess(CAPTURED_STDIO); + kernelWorker.registerProcess(pid, memory, [channelOffset]); kernelWorker.setCwd(pid, dataDir); - kernelWorker.setNextChildPid(2); const shareDir = resolve(installDir, "share/mysql"); const systemTables = readFileSync(resolve(shareDir, "mysql_system_tables.sql"), "utf-8"); @@ -746,7 +753,7 @@ async function runBootstrap( const exitPromise = new Promise((r) => { resolveExit = r; }); const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", pid, ppid: 0, + type: "centralized_init", pid, programBytes: mysqldBytes, memory, channelOffset, env: ["HOME=/tmp", "PATH=/usr/bin", "TMPDIR=/tmp"], argv, }; @@ -794,16 +801,15 @@ function startServer( dataDir: string, port: number, extraArgs: string[] = [], -) { +): number { const memory = new WebAssembly.Memory({ initial: 17, maximum: MAX_PAGES, shared: true }); const channelOffset = (MAX_PAGES - 2) * 65536; memory.grow(MAX_PAGES - 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - const pid = 1; - kernelWorker.registerProcess(pid, memory, [channelOffset], { stdio: CAPTURED_STDIO }); + const pid = kernelWorker.createProcess(CAPTURED_STDIO); + kernelWorker.registerProcess(pid, memory, [channelOffset]); kernelWorker.setCwd(pid, dataDir); - kernelWorker.setNextChildPid(2); const argv = [ "mariadbd", "--no-defaults", @@ -820,7 +826,7 @@ function startServer( ]; const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", pid, ppid: 0, + type: "centralized_init", pid, programBytes: mysqldBytes, memory, channelOffset, env: ["HOME=/tmp", "PATH=/usr/bin", "TMPDIR=/tmp"], argv, }; @@ -828,6 +834,7 @@ function startServer( const worker = workerAdapter.createWorker(initData); workers.set(pid, worker); worker.on("error", () => {}); + return pid; } /** Run a single mysqltest against the running server. */ @@ -842,7 +849,6 @@ async function runMysqlTest( port: number, timeout: number, ): Promise { - const pid = nextPid(); const start = Date.now(); // mysqltest needs much less memory than mariadbd — use 2048 pages (128MB) max @@ -852,7 +858,8 @@ async function runMysqlTest( memory.grow(CLIENT_MAX_PAGES - 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - kernelWorker.registerProcess(pid, memory, [channelOffset], { stdio: CAPTURED_STDIO }); + const pid = kernelWorker.createProcess(CAPTURED_STDIO); + kernelWorker.registerProcess(pid, memory, [channelOffset]); kernelWorker.setCwd(pid, mysqlTestDir); // Setup/reset operations use "mysql" database; real tests use "test" @@ -893,7 +900,7 @@ async function runMysqlTest( clientExitResolvers.set(pid, resolveExit!); const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", pid, ppid: 0, + type: "centralized_init", pid, programBytes: mysqlTestBytes, memory, channelOffset, env: [ "HOME=/tmp", "PATH=/usr/bin", "TMPDIR=/tmp", diff --git a/packages/registry/ncurses/build-ncurses.sh b/packages/registry/ncurses/build-ncurses.sh index 9e70790c02..6fc5e83883 100755 --- a/packages/registry/ncurses/build-ncurses.sh +++ b/packages/registry/ncurses/build-ncurses.sh @@ -35,7 +35,7 @@ SRC_DIR="$SCRIPT_DIR/ncurses-src" # --- Inputs from resolver, with legacy fallbacks --- NCURSES_VERSION="${WASM_POSIX_DEP_VERSION:-${NCURSES_VERSION:-6.5}}" INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/ncurses-install}" -SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://ftpmirror.gnu.org/gnu/ncurses/ncurses-${NCURSES_VERSION}.tar.gz}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://ftp.gnu.org/gnu/ncurses/ncurses-${NCURSES_VERSION}.tar.gz}" SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" if ! command -v wasm32posix-cc &>/dev/null; then diff --git a/packages/registry/ncurses/package.toml b/packages/registry/ncurses/package.toml index c61d091dda..15645fc337 100644 --- a/packages/registry/ncurses/package.toml +++ b/packages/registry/ncurses/package.toml @@ -19,7 +19,10 @@ kernel_abi = 7 depends_on = [] [source] -url = "https://ftpmirror.gnu.org/gnu/ncurses/ncurses-6.5.tar.gz" +# 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" sha256 = "136d91bc269a9a5785e5f9e980bc76ab57428f604ce3e5a5a90cebc767971cc6" [license] diff --git a/packages/registry/nginx/demo/serve.ts b/packages/registry/nginx/demo/serve.ts index 1ea13749f5..82c5572bf2 100644 --- a/packages/registry/nginx/demo/serve.ts +++ b/packages/registry/nginx/demo/serve.ts @@ -2,7 +2,7 @@ * serve.ts — Run nginx.wasm serving static files on the kernel. * * Starts nginx with master_process on and 2 worker processes. - * The master (pid 1) forks 2 workers that handle connections. + * 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. @@ -58,7 +58,7 @@ async function main() { await host.init(); console.log(`Starting nginx multi-worker (prefix=${prefix})...`); - console.log(" master (pid 1) + 2 worker processes"); + console.log(" master + 2 worker processes (kernel-assigned PIDs)"); console.log("Listening on http://localhost:8080/"); console.log("Press Ctrl+C to stop."); diff --git a/packages/registry/nginx/test/nginx-wrapper.ts b/packages/registry/nginx/test/nginx-wrapper.ts index e144e42a99..2386a2fd45 100644 --- a/packages/registry/nginx/test/nginx-wrapper.ts +++ b/packages/registry/nginx/test/nginx-wrapper.ts @@ -173,6 +173,7 @@ async function runNginx(opts: ReturnType) { const io = new NodePlatformIO(); const workers = new Map>(); + let masterPid = 0; const kernelWorker = new CentralizedKernelWorker( { maxWorkers: 8, dataBufferSize: 65536, useSharedMemory: true }, @@ -194,13 +195,12 @@ async function runNginx(opts: ReturnType) { const childChannelOffset = (MAX_PAGES - 2) * 65536; new Uint8Array(childMemory.buffer, childChannelOffset, CH_TOTAL_SIZE).fill(0); - kernelWorker.registerProcess(childPid, childMemory, [childChannelOffset], { skipKernelCreate: true }); + kernelWorker.registerProcess(childPid, childMemory, [childChannelOffset]); const forkBufAddr = childChannelOffset - FORK_SAVE_BUFFER_SIZE; const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: parentPid, programBytes: nginxBytes, memory: childMemory, channelOffset: childChannelOffset, @@ -221,7 +221,7 @@ async function runNginx(opts: ReturnType) { onExec: async () => -38, // ENOSYS onExit: (pid, exitStatus) => { - if (pid === 1) { + if (pid === masterPid) { kernelWorker.unregisterProcess(pid); } else { kernelWorker.deactivateProcess(pid); @@ -243,10 +243,10 @@ async function runNginx(opts: ReturnType) { memory.grow(MAX_PAGES - 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - const pid = 1; - kernelWorker.registerProcess(pid, memory, [channelOffset], { stdio: CAPTURED_STDIO }); + const pid = kernelWorker.createProcess(CAPTURED_STDIO); + masterPid = pid; + kernelWorker.registerProcess(pid, memory, [channelOffset]); kernelWorker.setCwd(pid, opts.prefix || process.cwd()); - kernelWorker.setNextChildPid(2); // Build nginx argv const argv = ["nginx", "-p", (opts.prefix || process.cwd()) + "/", "-c", opts.config]; @@ -260,7 +260,6 @@ async function runNginx(opts: ReturnType) { const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid, - ppid: 0, programBytes: nginxBytes, memory, channelOffset, diff --git a/packages/registry/nginx/test/nginx.test.ts b/packages/registry/nginx/test/nginx.test.ts index 6219e87d72..08ecf001a3 100644 --- a/packages/registry/nginx/test/nginx.test.ts +++ b/packages/registry/nginx/test/nginx.test.ts @@ -106,6 +106,7 @@ describe.skipIf(!nginxWasmPath)( const workers = new Map>(); let resolveExit: (status: number) => void; const exitPromise = new Promise((r) => (resolveExit = r)); + let masterPid = 0; const kw = new CentralizedKernelWorker( { maxWorkers: 8, dataBufferSize: 65536, useSharedMemory: true }, @@ -127,13 +128,12 @@ describe.skipIf(!nginxWasmPath)( const childChannelOffset = (MAX_PAGES - 2) * 65536; new Uint8Array(childMemory.buffer, childChannelOffset, CH_TOTAL_SIZE).fill(0); - kw.registerProcess(childPid, childMemory, [childChannelOffset], { skipKernelCreate: true }); + kw.registerProcess(childPid, childMemory, [childChannelOffset]); const forkBufAddr = childChannelOffset - FORK_SAVE_BUFFER_SIZE; const childInitData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: parentPid, programBytes, memory: childMemory, channelOffset: childChannelOffset, @@ -152,7 +152,7 @@ describe.skipIf(!nginxWasmPath)( }, onExec: async () => -38, onExit: (pid, status) => { - if (pid === 100) { + if (pid === masterPid) { kw.unregisterProcess(pid); resolveExit!(status); } else { @@ -175,18 +175,13 @@ describe.skipIf(!nginxWasmPath)( memory.grow(MAX_PAGES - 17); new Uint8Array(memory.buffer, channelOffset, CH_TOTAL_SIZE).fill(0); - // The kernel reserves PID 1 for a virtual init process (used by - // `kill(1, ...)` / EPERM semantics), so the test runs nginx at - // PID 100 with workers spawned at 101+. The actual PID nginx - // sees doesn't matter to its operation. - kw.registerProcess(100, memory, [channelOffset], { stdio: CAPTURED_STDIO }); - kw.setCwd(100, nginxPrefix); - kw.setNextChildPid(101); + masterPid = kw.createProcess(CAPTURED_STDIO); + kw.registerProcess(masterPid, memory, [channelOffset]); + kw.setCwd(masterPid, nginxPrefix); const initData: CentralizedWorkerInitMessage = { type: "centralized_init", - pid: 100, - ppid: 0, + pid: masterPid, programBytes, memory, channelOffset, @@ -195,7 +190,7 @@ describe.skipIf(!nginxWasmPath)( }; const masterWorker = workerAdapter.createWorker(initData); - workers.set(100, masterWorker); + workers.set(masterPid, masterWorker); masterWorker.on("error", () => {}); // Wait for the TCP listener to be ready (poll until port accepts) diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 042e54f30c..16cb27b045 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -4,337 +4,344 @@ "bash": { "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", "cacheKeys": { - "wasm32": "a43cb889225cd487f93af47eafe907467d21df9bc04428a5ad3df7c57cf0c6ba", - "wasm64": "aeb203ba1b78a3ca325ea9635ab2d687ea5e537db2637281f9f1124f5b333428" + "wasm32": "830ea09a72abe74ae1e3e728d80740f330250e1be2c52a9f1e053f151d15dc3c", + "wasm64": "db84b3df4280116b4206a96bb9b18fc30ab523dbe790439c405258c49000bfe0" } }, "bc": { "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", "cacheKeys": { - "wasm32": "5cd794c0a8203fa7a96dce3c944b5b5173ff3f39dd08a3ba08635ad5f65cd1e7", - "wasm64": "4641f9442ab1d74294ce74c1e25e236cf35208a984679a98e3f283e603d8d45f" + "wasm32": "87d9afde76b4f3d9ba5ca78fe38f55cdf86b312bdce8320a5c17f8b0dbbc1b38", + "wasm64": "c0bfc2c66ecf9c0e0350739120887b53da6023627105323a0ef97ac6748899ec" } }, "bzip2": { "manifestSha256": "59e1e53f5675e7c9148634933ed0f4c7192743727025cae4e843b6924c489abf", "cacheKeys": { - "wasm32": "8c43dfe973ffc1180e33126913a410f3fd17fa7c5300e2ec421f9896d3f54111", - "wasm64": "6b06242ff2397c0668a68850248f755ff0a97d48351f6a29c825316e91c06267" + "wasm32": "09122fb04b306e4f803ca3a461e075091be99e9bf17835a19d7013a803322a2f", + "wasm64": "fd73f37ad6d1a7254a402c5fba805abe5c5f01d2cf9e360a3b51ea51926beb2a" } }, "coreutils": { "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", "cacheKeys": { - "wasm32": "0d7bcbc4565d31d275d3dda0357abbf40747e5f6f1407762ad00b4ce0673b0ce", - "wasm64": "49b2ba4a2e42ce773866714628ea4979da63dac87eca8d4c54c73c510529f9b3" + "wasm32": "72765db88ce28cb67eb7b38a545b40e004215d7ccbb0d4eb02419c509b8d46cf", + "wasm64": "837bdc6360d3481994ccb24c2fb12fd509ce70f445582a1ca32b0b77101dd3ad" } }, "cpython": { "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", "cacheKeys": { - "wasm32": "9ebc9502240decca75ad4c6e155e6748a3d07d4f6fdb695b65d01ea799bc07af", - "wasm64": "ed14c23cc971bd9663da40ffb36c3abac91e87c2cf643998201d6cd6510b9634" + "wasm32": "314d476303cbf1886be4bf31ee1e21c9012bc2056222766f89ec33d9845f974e", + "wasm64": "31a23842219a401b6708940869b9ba614e35cd69d98f33315e625254074f993f" } }, "curl": { "manifestSha256": "55523d50261f46dc4aaaff458d1cd87c6f96eaecd687a7540ead35c96906366e", "cacheKeys": { - "wasm32": "ba032d8eef4b18230fe5fd7d0496b6abd1f291f3583a48f4ef3c94f441e8eaf1", - "wasm64": "84f37d6a8e59bb8e8242265d3126ef69de49917fb34352c2ebe32e7b88653865" + "wasm32": "63cca65e3571ba910489e52d3c0ec424570a8ebfa08b3b3e24c39747adcd51da", + "wasm64": "bbc392fd90a456565cd3d7e1d38d8b1a3cc5cfe5a9e7619270fd30bf5b94c3f2" } }, "dash": { "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", "cacheKeys": { - "wasm32": "30d3944c0d6c187c6eed2387e81b4be46eeb7ea9c1627f216499eded89650310", - "wasm64": "437585daf592ebf1c49b784cb31db47d60c40848008672848c0dcf7c31731e6a" + "wasm32": "0b2035e2d9b130f8edc85024720502b612e52db3c71c0c35ed55e26140515a93", + "wasm64": "981f288954ad9124917838d3deaf8514853a168f0f7c88c373b98aeafbebaeb0" } }, "diffutils": { "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", "cacheKeys": { - "wasm32": "b1c53d03d0e67919a48f3d5eb38e4e9fc7397cfff56a6c47fc9a9faec895d21e", - "wasm64": "faa99de40b13eabd2240aced8794eacd6c1419db7057fe99678dea6a2266cf46" + "wasm32": "5b829e0fee62ddcc82e67563f559482c77be4ad2a794b297d614a9ac0bfb96a2", + "wasm64": "6ae76b9d6a0cea809b7249e9957f8aa0d96a930eb06906a8ea345b3126ff0aa2" } }, "dinit": { "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", "cacheKeys": { - "wasm32": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781", - "wasm64": "262444a46b7c122cadad29850ca42dff322377652f48e822fc88d5ea1cc0b7ed" + "wasm32": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364", + "wasm64": "ce140644a3a60989c426590afbc252ed9b2042e7d810183b207dedb8c9f25738" } }, "erlang": { "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", "cacheKeys": { - "wasm32": "729f2550f5075a36a2fabdee2258bcd37abf016d8ef9885b1d6e324f36bba143", - "wasm64": "8340b36b11cefef75fecaf3c29cd42ff761f67ed201b8262c2bbde7de965a42a" + "wasm32": "fb022926bb648059ad387c1919207500dbdfd359eed86fb38f671584f2ea97e5", + "wasm64": "b8c4c2cccba15fa5662e0d20b99341c4afd6bea2488d686d48ca7b85eea122c6" } }, "erlang-vfs": { "manifestSha256": "15efb74f1825e2b85bedfeb8d98c19fa648c4be39cb9defb65330a8f21eb9141", "cacheKeys": { - "wasm32": "b4ea72967f923df9ae8e5f0d46cfc2b8c9a6c6a8e0cce59283f5e2ede6a0674a", - "wasm64": "d872240ea361ec6b9f2d76ac206dcb6b9d14a16dcc87930dd918260f98fc34bc" + "wasm32": "fd19b68ecacbba3881e594e17360a68b1b39800ddb2710b8a9f6ad48b3480faf", + "wasm64": "668be5f4ec2410bfac9f0a307b953dbaad8a44b639f9f206b79503cdb09256f5" } }, "fbdoom": { "manifestSha256": "a00e0d9c84fcdbb3bd95f296cb3422d60b86dcff4c40734eea1bb0bec4c7d902", "cacheKeys": { - "wasm32": "89f5953bb40e091907881848e6166675cfd8e5580de02c7848711736746832e3", - "wasm64": "7ec8ff84b79657220d9d70b8b8af743d94df0a72b1a931619b417e0608ecda70" + "wasm32": "ad115c1c6aba37e0b0246d728ec5343e931bc0b4b00c1fe8e24bf2e881399ce3", + "wasm64": "a94ea6497362a75f9341ff791856d7e5e7c572ccc7cd1eae2a299033c33e9ec2" } }, "file": { "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", "cacheKeys": { - "wasm32": "28c5bfeb2dddaebf9cc39451d2c2983812bd6c61abc05197ed584c51f310a2b1", - "wasm64": "775c68bb3e4fdb6275d184bbb48939e750953fd23265f9a6019ea13893541c6e" + "wasm32": "41f1cbea97d2331099f6ec8f24ad10c15d54d536dca8c5d96354d053685333f1", + "wasm64": "a0e3c4589201e83581916ea7028b90114c42cf05f508d0c3c4ecdba1fd00abdb" } }, "findutils": { "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", "cacheKeys": { - "wasm32": "51fb9260ceb9656bbb34bf723314ea6e6905915d65b736845f0fbf593b5eef00", - "wasm64": "589d688c25db4b5738af84ad782224f3840123277a971e219b6e16472f2f6623" + "wasm32": "9cf821c87b60d798687583dbb8e325ba26d10764f2404b01d15bd296f5630332", + "wasm64": "586f92247cb3b6d4628f8a40b4309e65709b4a140a7b64274f08f25f018d8bb9" } }, "gawk": { "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", "cacheKeys": { - "wasm32": "cee993ecea8042490957d500095605e9635f5e3247fff32b160a3569749e53fc", - "wasm64": "f19ac2fa56010cf7a8339409dbbb4ec02ba3fdd47d6a182efc9969c2e95d1a97" + "wasm32": "f25ff8e149130b153b32c462696ed336e03a217e5bf9098aefb31f62cb96cdb8", + "wasm64": "2b19a7621e1f262e81b925ef4cd366d850a4c61dd574151d7417df8f4b358b08" } }, "git": { "manifestSha256": "c2bc79e62c9a4e840ae94a16af0f41070460eaf3976a990dc60d2db977bee952", "cacheKeys": { - "wasm32": "8acb9c9dc14337fe6b17808630ba402c2d649822a618c6c83fad1201d2ef388e", - "wasm64": "e1c01bf30a31e8eb6927808ecfdc826006306dde27dafa2ac743af5917318943" + "wasm32": "956b61b3c170c0be48a6b482941cefd2bbe895cec099f5c748969c71b2e37ee8", + "wasm64": "3271f00c4954641a4d37e10e9cc5bb4edb56caaf8da103a9525deb09b742b569" } }, "grep": { "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", "cacheKeys": { - "wasm32": "cf4ea02c0d5ffbdec575a9fcce7c943d8d45bac06d8ee223171a3b41b6fa8e52", - "wasm64": "239dec4d8748773c19b8de231cc1df16633d5ecfe2ac831bbb002e507a699d00" + "wasm32": "2b95ab13353b8f112cd0eeb7ffcd01f745ec00814bf531f97bb4ec1f85d3a77f", + "wasm64": "0e5b2c50bcbeb21620c8876e305615e0ccf3100c23d72bda28a0383e903a4541" } }, "gzip": { "manifestSha256": "33853ebe2301caf2979b667830e6c5afc5256f8717137624f08079e6a27f370a", "cacheKeys": { - "wasm32": "c3d94eaf953ab465b07e356c413cc4c849ca76d49ae27c9ff2479456f3704479", - "wasm64": "672b7412bf57cb280cfd03eee984688f347ca8f596a1fce51a4bffcab4d8401c" + "wasm32": "c5fea74ec33f362e863e9d99f5d4719cc369ec754884d8183ded6ebce6c346ba", + "wasm64": "fae1b65fef4de74f0afafd78ff5220112e76fe4b8b7f0d3fd907c63b991e6727" + } + }, + "homebrew-bootstrap": { + "manifestSha256": "9446c30113764de79abe29df79586849ab4f296d723fcf9614f45d885d114388", + "cacheKeys": { + "wasm32": "3f44ee7f53ebf6d26e30f1dbb332484753df28728e341ba4473d73f16e6a6ebf", + "wasm64": "32c128e0b63f160a4d37e0b1cb025a1a28c6c926a5556758cf936e1edee722c6" } }, "icu": { "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", "cacheKeys": { - "wasm32": "5f2d9559cd1173f21fa621bcdf5451a35f3ffcc3435dd7b0e09169834092e402", - "wasm64": "a9a04f2d86a3dee4d26945deec86f95a60856f2b5dcdf73121e5d9bed15e1566" + "wasm32": "48a3ee79c8510a1fe933281741cb0fb56f2a67fa7dba9d2cd18c0085cf49f375", + "wasm64": "39dcb39e822ec17e87b3bcf2426f155568221d9fd8eace1c55645edd1e366247" } }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "cacheKeys": { - "wasm32": "58303f53b90f75f88b1b2fe74bc842968307f9a16acf8592a61b30f9c01ce35c", - "wasm64": "867fe0aab331094b527c85ecca6b2a5193c4dabbafedfb6f37a6685d66d834ff" + "wasm32": "5ec342f97928be06680f16f6bcb621b5265e3375a4d602f8a0e78b3adb1b79cc", + "wasm64": "69f5a9a871203842c5042886f036d52be61e6ed9c4146b8a8cac1da1e3c1980a" } }, "kernel": { "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", "cacheKeys": { - "wasm32": "e98596ee4e363710004ff969cc74e58e433a521011d6331681421c20cbaff721", - "wasm64": "9871e90d8d1106326585793cd39c6aea2fed5d7d31e68b1236505d7f41a287cf" + "wasm32": "d0e03c4b0ab4858f28cb0d74e4a38b6d0af49781a6c53e12d14baade637a7ec9", + "wasm64": "f59073d7dec9a33149bc57cbbbc1a7a225b2b9d2295f64c4fea0041f7e2f410f" } }, "lamp": { - "manifestSha256": "40b66d115967f8ea3e1229cccfd1e6636f5363b4fd02ef66850a2dd441ccf13d", + "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "c613db2f499d1c74b96dae783d3a82835d50dd377f7f633c7b786d501ea0ffa4", - "wasm64": "3c15868f8f21e4b94bac0513e891d5035234405fe386107f6dbef8e73de2589a" + "wasm32": "52b8525aaf27598d1d89e1b3ed0f84f9f7a415052c246d632eb8fda2b4a1fa99", + "wasm64": "2b72b49cf85f25af65fa88a65c552b2aa86bb0953f3f284f551a97b6720673cf" } }, "less": { "manifestSha256": "996d61545cfe83dcb663a33e8763e0edbd4db4a605fd69a91b243cf79b1f9b17", "cacheKeys": { - "wasm32": "a31a123f83a7dbc0e10f54d71b5b74ff0d2d249b5612daa8e1562e6eb36eaf11", - "wasm64": "0e4d9e9f360243eca279d945624dae9af5d296da4920cf18555c303abac85aec" + "wasm32": "69fd5aea1025f2974ce24964d3f93ba08df5f5082c48fd5e2f5c101df852cc07", + "wasm64": "9d9870cd7b66c39e23dcfbf8998468e431156ca37dec9329bd425b7c5691e0a9" } }, "libcurl": { "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", "cacheKeys": { - "wasm32": "cb147677f78c356f20e2581df7dbaab3c77f6e3b879c33f24484bb4eafa6160b", - "wasm64": "d32f46de09c7dad6325b9215db2d1b1f8d20d869d119b91381898ff4585d1857" + "wasm32": "8273da44c60d3edb2032e3566040ed8fd07c2d3d5e43bfd4bbe94ff7ec0b95c6", + "wasm64": "3b0cd009fe52f374ad5f09968d2bd19701fadafcf74eed404f3cd5f7bea12e4d" } }, "libcxx": { "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", "cacheKeys": { - "wasm32": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd", - "wasm64": "6b5b96e7d4f2fe4ceef0bca3603548d2544e31ea5d9794c9078e652a261ee6e5" + "wasm32": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89", + "wasm64": "986b49777db0287089ed8b493606cb6ef82f97992ab3d11d5f054d8fb239707f" } }, "libiconv": { "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", "cacheKeys": { - "wasm32": "9328da7f90a267e6eabfb49e8b00d0dd45296f0d9c4339fa1c58e2813def6c70", - "wasm64": "b267f67956dfc5d1234fd16644cb553c9611b29c6a9a632af198579e219012fd" + "wasm32": "b494e26a4bd579392ae78f894f37f1d7b7ce87f0401f9d53edbf982b0753f76b", + "wasm64": "aa8b571ab96d3178deb1658b5c7e0aec78f44504728087ad69eebd78e5f41c50" } }, "libpng": { "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", "cacheKeys": { - "wasm32": "cefa8c2f3cb40a07bba56f1649a7d12f095d3d59c6f1b114fb9f0dae0147633b", - "wasm64": "d717500e0d357d9b7628c0ec16b8bc72318c21b6c9a4a9a6a9963effa6b96df5" + "wasm32": "e31da77ef2ff8fee85f28978d0356ebb799f3ae71458f0814fd9bf5c9d708af3", + "wasm64": "21c6a7748c748ad86fb9183872f9815924f0c19433b978bd0f2b594126396562" } }, "libxml2": { "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", "cacheKeys": { - "wasm32": "ecbc5a52e3f7b2cc5d697d3982fe94e9dbc69e8354f62f145dad302437b1ce61", - "wasm64": "343fbc8f9b9df572517c1d45b270a7b94ff12d2dce793d1b6bc1856df7d0cac5" + "wasm32": "f8e7709dc6d6606b0d5ad24b1bb2f7aece6df4e171f92dfe7ea344713c14d4e8", + "wasm64": "7b775c7e413c81eca44fc5ec1d68a836ecfedeabde97b640bfc4cb4e7b99b1d1" } }, "libzip": { "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", "cacheKeys": { - "wasm32": "bb7925958ee1261e640a025e5801ffed415d4486e4a6339094f0fd7338f47073", - "wasm64": "54a164c7970389102fe2e3ed6b8ce78270405cbb61fa01b931002aeff717527b" + "wasm32": "0e020e8d1c342ccf5dac59079aab2b2ec35b083b03107ac172cc0f9ac97a13ec", + "wasm64": "87ade5abc15b17b2119681a85d1b1728ce018ff8a6df14975c249f34ec6308b0" } }, "lsof": { "manifestSha256": "cfd199bbe082435f7a2e703e2738a368af1eeb5b76b6e93c376054f21ce94419", "cacheKeys": { - "wasm32": "c448f0352bb9d9cfbb1fd4d63ee278c114a12b8b3292fe846c555319859d9ecc", - "wasm64": "3c12b32d0190b46a6cb3ec61cfb7679f7558be3008123c4ad8df9c34b06e1d28" + "wasm32": "0cbea55dc47e3ce5196d9b07cfa6a22a21fc80b87ba971fcac3f235bcfdfd6d5", + "wasm64": "6047711ddb7300e1bdbb539ab0184099919c05542bc2ca9f94e86f6e0f4489a3" } }, "m4": { "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", "cacheKeys": { - "wasm32": "2a76adafbe9deb20f29041be2fe5cba99e207f28afec04911d311d3387f9e664", - "wasm64": "a51df71ba63fe477beb36da30d4c14f6d5fa2e1a8d0cc576d1a4e6a49f9ad6b0" + "wasm32": "746a3cb4942a7375f68e1b8cfdc24e71dae6b1393da3b8bef29386af1af10302", + "wasm64": "99eb4e7a854e414f7473480957a8f785b6ec957e5ace06b8aacbe88c4855c470" } }, "make": { "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", "cacheKeys": { - "wasm32": "13cd37652953e64a44481f086429f2a44bd8a0dc66824eb7e1ae3bf2e6e08fe0", - "wasm64": "d9bb97fcabe1f4e6a32f99a372cd0955bfa4646695ca8d89cc4b15d43acf42fb" + "wasm32": "8bbaab344db8e332b3763ce4700af83beb4ca8f6aa6670cce47f5ee9bd420899", + "wasm64": "aeeb35e7884e1b1b401454ac5a291ed0baa047354ea1016ffc3586a0b0dd42ce" } }, "mariadb": { "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", "cacheKeys": { - "wasm32": "dfd36d445f70b37ac26f03363a3f400cb42c510e3404198b4904012c6bc19c82", - "wasm64": "815f13c9c636ea4ab21b291c35ce333ac30c97b7bdfc15c583a2686055d9065b" + "wasm32": "89cb71f508decd03f2b38c57e3f071c00a8d0b12d365f0f98cebabbda13cec79", + "wasm64": "f6668fed09920d9e3526da63be09a9fc9650880341e11e5d87d93c32e9cef494" } }, "mariadb-test": { - "manifestSha256": "7f388702b564e289c12206d41b6c982ee8dbd2b5f577654bd7630eb8b89e5c2a", + "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "4aa872170a6e9dd254b2552f8c0657c9448bd222f5a4e34a241adba7106381ab", - "wasm64": "a24dc3a6eb42fcaf9e4bbd6e5030097d55ab43a18e5306fad75e25f0089276c3" + "wasm32": "98b28ac6dc6c1c8c1501591a1e56c51ea7a4e9524e469994ffc0920dd54d1424", + "wasm64": "bbeab833155d9f653551aef1de7c89ba4a752589b051f885a30bc7f088f6a5f9" } }, "mariadb-vfs": { - "manifestSha256": "c4cd04a06d913650b0bb8c6895fa651a9a05fdba4805c576bb0c35a998276764", + "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "54750c03bafa83467aac4abd98169dfa3bd110bb49b6b8ef27b468095aca1e6a", - "wasm64": "e4d4ac50683d6bf5f94a00b0f5da8e4647beafa5b6e78df373dd588a2f9422ee" + "wasm32": "8ea599dea8866e4740a0c566e3699b30d607e0e41338394037676b129e22d655", + "wasm64": "ffd244834c1bed28c31d368b0bfa1393642bccae80e0acb515989beef5a1ff59" } }, "modeset": { "manifestSha256": "36a8683c1c7309701d361c5f77e543a6c1531397b26c72dbb864528c59c42954", "cacheKeys": { - "wasm32": "9e3e8604821fd9f2a8ba7e8c849826cbdd7d5d02f6ea56e801d07eb9fd14652b", - "wasm64": "98b1d2d88feaeecb7487aceca8822afc88f2fcd6e5575175058733f6a09a0506" + "wasm32": "8a5f567e2f8c33df0d1759f3e7cbeb0e6105044fd4b156855a9ebf79cde6bced", + "wasm64": "2ee657f174d5b6dae244226ea17b4a39b40eda6f284f145a29aa259f1b308610" } }, "msmtpd": { "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", "cacheKeys": { - "wasm32": "11fb3461f854cdf5fcd76e3056b0d1e50d4b1c01658a9b7806665b041db1a3c8", - "wasm64": "ea71c2d4cd85faad9aff11588c2790aace4155267d86ab2fa6b0dcbeebcbfe25" + "wasm32": "94c0ffb6de71a0cbffd57cbe6979ad7438b16b0ca7b32b46cce86bf65fa1d5a6", + "wasm64": "d2f5fde97e49635bbffc8d7ef44aec792a0ce2bbd39c6f5c806eac2f51f954f1" } }, "nano": { "manifestSha256": "1ba6d340c95581319982257afd6a3554b333b880a7e69991b8c573da883f86c4", "cacheKeys": { - "wasm32": "afea62511b76715a3494ec0b383cc978c2ac2bc7bfe5c9bdb8fc44b2e4078c68", - "wasm64": "c3d38830058d1c17979a2e17a35e8c0051ea782f6a0a1db3924eb49433d97a08" + "wasm32": "48c6e796b2e94c9a4b9e56890b1a04e7df245263464df19e6c19d4676167e7fd", + "wasm64": "65436ed7799f619a181956aa3e54b24f0512253d4aa60206ae10008098b87011" } }, "ncurses": { - "manifestSha256": "120bef02a4783a59f86f20a1953b120b603851e04c7d8400f16435f97f97779c", + "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "cacheKeys": { - "wasm32": "f7210d1ab36ab5f22676d221945bd944c58e35958947358e9824da81aac15f01", - "wasm64": "90a37ef184c797288709d11b709e986da4edc6fe4c52b6d19401c8c3db34641d" + "wasm32": "e95f67690426e286783716ea6e0b39edf44bca23e8f7fd225567a45109c13400", + "wasm64": "6e0707760f3fcb68e2250b2e1701b7a5dbc50f7f5091ba6cd7e078c99921678e" } }, "netcat": { "manifestSha256": "4cf33cd1ab768b3ad0108da8b68c2ff72e98469cd86a0bf2d0da54a7d4f6fa16", "cacheKeys": { - "wasm32": "b78885a086ad183e20c8a93c6b657a5ce056046d54cf8145c33bcaa5bfdd5cdc", - "wasm64": "da4b715bd5e37d801e693ce8af01d7ae90e5be155930e33b16bb98ea1410092a" + "wasm32": "3d57ef272670ff5652a26adb0dbafc55ba34bdd0726b75e64bb8b3cafb65555b", + "wasm64": "07cf97ce5e5223dbd34e7f5137fea620625d6f211bcd9a0d7053622ca7ef4b0b" } }, "nethack": { "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", "cacheKeys": { - "wasm32": "11b1e47c7777eaf5ea106a36a0d7e70cce14a31d52005542c31222ef44473319", - "wasm64": "53664d90d9cf556bb82409ebdaeea5a0f880fa013ddd1fc3a73a65647d2b1c82" + "wasm32": "35bd109acb9812c033d6d96996f5d00020554b1cee059811eec89b7aba5c1455", + "wasm64": "24102c2e6dbe5911533b42f8fb41ea83a7cc738a0b39d30356774f6767826564" } }, "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "c50cb85a33c8e4dd8157998a3c7fde468d33193054b7f0ce6cef111ccb26b011", - "wasm64": "3fee5741aceb0aae2e488b53849fe6b1b04234e313129ea64463901bb25f8279" + "wasm32": "bbde7202fd7ac9d0fe859540f69fa04547a7903705c7abda85861cf9f3a9bb3f", + "wasm64": "299820741076c5835ed6abc7a114143603919f74a9d5f0fb0b5a677a23ada2e5" } }, "nginx": { "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", "cacheKeys": { - "wasm32": "7f69eae6a9d05d93c4458093a1ac80d5685ea4ff991c948274400796c770f690", - "wasm64": "a521a8cd7c9b4188de37479410eeb81ffd182924a05c2262e679cd0370ff3861" + "wasm32": "cc1c9c86ad57a4b5a5ed23636c6c50a354f85e294cfc6addf0c9457cb33defc1", + "wasm64": "60e41f39704edd2c61ff3bb88b77d38913258e26c411fcf72cca8df6426e378e" } }, "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "5e7007504c945c77b3c8c2c237a72dfec1d56f366a5d7e983ee73e8548355f63", - "wasm64": "5926941134220c301180bb46b65a56459e61e58e0b07c028b23a2af0bab1c651" + "wasm32": "a87b91753dc5369b7a4ac5f8e57aa0018ac99d8ad2fcd3156656aa152c868718", + "wasm64": "8c05a763de5f2b0f3c76b9fc11a89ca99ada21398fdf8255f7c928e9af45b395" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "1538a04763355d4dd8a573ee318d9ef0c3421b31f44a7acff39884b2cf4ba14a", - "wasm64": "383576bc80602179112145bc3f66dc7162e215b1b7a501e494328a8872af5855" + "wasm32": "ca664401a0ead71d0761b6f3e15126285d239344ff918cbc28c3d54d0809f01b", + "wasm64": "38db505d84f3bb337960da2f6e8e5e65c80874d8a9f80f992ad9549cdb6c1a47" } }, "node": { "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", "cacheKeys": { - "wasm32": "286d3611dc0a71406479ac61a2a0f46f8d51683a78ddb2f59fdddfacf81b9515", - "wasm64": "4761f9fca12543404fb753eb94e4cdf2b63acbd8d41fdc5e91fa7ee3157b3263" + "wasm32": "62a1b6d2516b3676a069b611e6b0af454ffe26013151b8c71e7168ec31a7fa5d", + "wasm64": "05622518a0cb61bd985d2858bb15d8679d5a3e8be5846ac69a017b43647cfaa6" } }, "node-vfs": { "manifestSha256": "33315fb1b3030a4c187ae075eac08f717de8d7ab017b86c6458778ac9070eece", "cacheKeys": { - "wasm32": "2cbc8fc1c443a3e0aa1ed1d1638a5c3aca91603b85727c905860dc8e22afb997", - "wasm64": "1bfd6a98b32060c347eef77dbaad1ee55831860396bec12e1ccb5cf9b5588625" + "wasm32": "3c15243220cac0639aed4b8952f9a09a39036ea4d01b2e75949ebdbacf796d02", + "wasm64": "6be87229496865f993a2fbd8d27de139f35ce63375c16cc0fcfe5ecdc804b3aa" } }, "openssl": { "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", "cacheKeys": { - "wasm32": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362", - "wasm64": "e3240130e7372e5b007c915414527e4b8ef3010e89cc74c3301f3e5329db7bce" + "wasm32": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950", + "wasm64": "11532ba6511df310a86990312eab22eecc7f96b9803dfa8a520f3a146b237ffd" } }, "pcre2-source": { @@ -347,197 +354,197 @@ "perl": { "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", "cacheKeys": { - "wasm32": "f0d9f886fca2f6741563862628b059964bceefcdf2eb2a0b2ebfde5e30ac8adf", - "wasm64": "bb2309551b9893a8558c2eb85f89980c48a374a9db18c94e097b63eb0db04235" + "wasm32": "6aff0ce4fbc5a621ea551d1451edb0a372cf53444ea4cdb2267b15b457ebc97d", + "wasm64": "fd4f03b01c3e8ca89bb54997945ea197b96e7ddf87f1a8c92796466a6b20402c" } }, "perl-vfs": { "manifestSha256": "1345cc102bc8c24a6bc276410c00b4fcc99ba5f6adc9b031f882aaa35d53c8bc", "cacheKeys": { - "wasm32": "2a9ce3c668c531435ee31c71cfe89f9c28c4d1442fa6ad0ebd241a7004db7a7b", - "wasm64": "df5893d0f7d6ce2588a1c056cb78b23c67e0d76bae9bf0e874edb2a9e1a84f6a" + "wasm32": "c19bb5219bbca8610925ed7e021bfc15259e907d4bae5bdabe0728419cfaf010", + "wasm64": "03a69b841600f89a0ebfe3aa97c74f4649e765d25022a009cfa4f549af1b558e" } }, "php": { "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", "cacheKeys": { - "wasm32": "2b3b191f35c8adbfccdcfc138af00f8c00855e808ffb7336b503ac50f224b4a6", - "wasm64": "ed7b817ef25d43f46319fc6081e1dd964a79ace1783ce22d77fa6d0737c791a8" + "wasm32": "54c56075f8cdfca61bfbe51fded0cf9ce52ff6fa66f20e720d9d2d0c40b654dc", + "wasm64": "2be35fe201db3bd9cac87d16708bfef192fee38dc39f394e61c5b69f91f68d74" } }, "posix-utils-lite": { "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", "cacheKeys": { - "wasm32": "304227932e1878ec59ab186b6d025f43a2b3df928484313d2ab931a46a5f3ee7", - "wasm64": "628f61fb878c00da7229e5f1d2aec79952ba0b8cf0ba1719343caeea13400b7f" + "wasm32": "81206dd760c115c96d49ee53557f2e7b05f1fbf29502e201132aa8453469ed89", + "wasm64": "cbc5836815240d6d8fa0233f8dfd7b26314b29416759ada5579098c473447767" } }, "python-vfs": { "manifestSha256": "d1aa99feae65f06c0ca8cf52c2176534b2723fd4ee6eba6e72c205245e1c9c26", "cacheKeys": { - "wasm32": "245db3320c57cc36dac253a82b19b7a0240fed099bf45381b414261ea8abb435", - "wasm64": "286a5ef1c6ffcf5e4987467896df165d989a86f8bf6bdafbf32005f944825f0b" + "wasm32": "8990d72a3cf9965a726d7545d52f58efa2c40b64e747e5999b41b56d71f17c50", + "wasm64": "91243f662e0d8126df3df02da952733a076771b17f50accb09a2e9d2b1c556e6" } }, "redis": { "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", "cacheKeys": { - "wasm32": "a7391e556a100804e931a940f6a88b3afee2d7a866fa315e7787a9a5a8948403", - "wasm64": "bef3535753cb37406c4f9d04d751d59d9d72f0cefc96491ccc1352da6affe75c" + "wasm32": "9ff20fb4a59e14e06a729e8c36e4c8af15fc05d613d88ee3d6cd42d20b7ff1c1", + "wasm64": "10127b5744704616c44c49d15a7d712674cf3032c4519d719fc0a559df1ea43e" } }, "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "527cb56369cfda87657ba376b07742e33581329a38e6b8184dd09b39ae0714e5", - "wasm64": "a7d8745bcbc9c04c8faa1f049715da1c779fc0a27e46aafc0c6de757f2e68a28" + "wasm32": "dfbdea8ae43c488ebafdbb4e83cb5f4dd6bf0810fadd95ede4c1ced77174feb1", + "wasm64": "a257d8a55786e4faef259a3320f5177aed8307172d52ad2f5b07a4f0ef598146" } }, "rootfs": { "manifestSha256": "0420201025170f48c32949ac62707d4577cdc13a53ad1f01f59d318ec07c8d6e", "cacheKeys": { - "wasm32": "afd016a598b5bacde4a1275a142a2fb517a51dc22e120d9562c286765091324b", - "wasm64": "9002598fd087605171e3dcbd76faf847193ec4c771642fc1ceed4624bd791366" + "wasm32": "f37ef8a5f785aa3d9cd9f601b4e96ce916ad72ed701952dc2fdc671639d5d9d8", + "wasm64": "0aad55f0e4cfd37ce2dcd66a1f2ef8a96d7b24f754865af1ef97dbd18ba4cdb0" } }, "ruby": { "manifestSha256": "6ea67a246c29cd927a19decbb36ae4c4d478ff6642d2c77e08a6b2cfeb8251d2", "cacheKeys": { - "wasm32": "84ffe7f564d7631b51dd3951ff655eef315a89691e9efaa3791831643bd1e985", - "wasm64": "7db321d4f77faf87353d09e189792abdfd1d6dd233e0e7f4007290c67790dac9" + "wasm32": "b9554136ba3f6ec44897088250599687cfc0c7fc3af971b908f2668cd5cacd7a", + "wasm64": "75406b0ae56e0e61185e0e6b69d4f2366b6bf09ec536a3c25d9a16672f2e0f03" } }, "sed": { "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", "cacheKeys": { - "wasm32": "f687903421a723b81ead0a60f488b617375ce19ed97edc2820818f96f90e3c82", - "wasm64": "4df0bd8ef915d60f14f95a07f61ffdb53c536b3b8f89c9a0a7a6354a64869e59" + "wasm32": "6265486b1ef7c27412920d531290a1bd43e0e1f5c0459861c1908ef52da6adc7", + "wasm64": "1c4773040b9b81873325ce0789c39a3049de2ac94de8bd1863d1abb7b80b6ceb" } }, "shell": { "manifestSha256": "5d02cd8fee4e0d46c7552a3046c3898f9e3bd5667094e02cab01851eed97a321", "cacheKeys": { - "wasm32": "8f31a8307da11025cf70150730e19af8a5fef7e5bbbd72a94576b4359efb32c4", - "wasm64": "5a18d3a596bae1adbaba89dcf00e7528b7e2c74f606abe9100369036aff9daab" + "wasm32": "308558a09c4798e2c09083da90be88ce752307cde833d2a0f036b6a61bd28e18", + "wasm64": "e568a6050479b0ab7eea3a0c6faa38c25660ab247b764e5b5df9f66d18b526e9" } }, "spidermonkey": { "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", "cacheKeys": { - "wasm32": "85aca810215b5f02dcb66956292be3b345aa4469fa34c70a6cd602b8016909dd", - "wasm64": "24c5e3842b8b87ea1d2f6a31fa8487b30edce568028c3771f104e72fdd561cf9" + "wasm32": "2c2b1f5959b20978e10725bc42cf1d3e0c48a7c699f3b3d564d843c71b8b1ea7", + "wasm64": "a052012cc83b6ddaa1e2c6d5a4d7e275817f3e5843ff53b68fd47fd760e42013" } }, "spidermonkey-node": { "manifestSha256": "f156c4c5044d2a9447324e7a2a35f8c3e6fa367b3e44f674dcc30ab6b946fae7", "cacheKeys": { - "wasm32": "4867322d0e6f96809141a2dce6682483a2a6743899f5fd75f2271d82475f0269", - "wasm64": "c5eb2d2032ce40c037d57d769ff398b587fe684d037f326790edd95b6cf6e7dc" + "wasm32": "1fc4b5fe1002e77e10e531ecd916c99a766c69583716fca36d5fbba88fa389da", + "wasm64": "f2076505c95522a1394b79695c4edf13974ed05f68fbca8a2cfb9884be1fe615" } }, "sqlite": { "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", "cacheKeys": { - "wasm32": "3cabb24c5250387ef8e26e0a1566cb7b198a8783c3cc30fb2ae31c8d6d044e77", - "wasm64": "2daade2833893b1c0e28c556e2f70908e78ea1efb5eb813d328733bd1bd3e3f6" + "wasm32": "a21f509748a9f5a086db9ae7bb2341a3c57171c954b31a3982b2b47a98794694", + "wasm64": "1f7a45fb1bdab0c6e32713f5ddba158f0d4e6268110288b8d4f148e0eb990dc8" } }, "sqlite-cli": { "manifestSha256": "1cebb768f9473dc58fc6e72c9b24565760aedaec4cbdb29ad43b22a0a5fd7030", "cacheKeys": { - "wasm32": "a26d9e2b82748828e1f2e322c535892993237673d1759269341ca7e3d55b77ae", - "wasm64": "4819815d778c7958e2285a57b92fc8d06bfaad6367c1c13c49584bad7df06343" + "wasm32": "9c78c0e368f0cdb390dc2c54c075c990b4c462d918f2c2432387cbfa78bbeeec", + "wasm64": "2d757bcb6b7b2a459f5ac9614b1e41d6e4dddc69719ca8261f088f593d7ed9ed" } }, "tar": { "manifestSha256": "08fa090c122d3105c735d74560bdd7b8b083a6f9ca8bdec17de0b4993e1be7fd", "cacheKeys": { - "wasm32": "bedb0c34a78b5c4642b340e115feca87ed3752f4a836d3b5534716e3137c034c", - "wasm64": "75f3d189257503912bd668ad66b4c71159ec498b63b5c3253c0307fe4587a864" + "wasm32": "d842e794f28f0f7c3fed3be0b1ad1f26dbe563b13a8883bc77e2fc59f21b52b4", + "wasm64": "ae9255c98618602a0eef8c67c69ea47a7701412bf766658f420c4914df7bdbd3" } }, "tcl": { "manifestSha256": "67253d47de7df9184e68ec3c49835455746941835a26db9e588aa8fbee7d4636", "cacheKeys": { - "wasm32": "e18543aa20544ced46cb8a2a7bbee5d5227ec0c1076336eb3db6ad70ad3c4095", - "wasm64": "07f1fba2c47e39e38a8ae9b983cb37d56081b9be30fcd9e02034c910eff39b8b" + "wasm32": "edae667904ad078a3b3e53e0620d1cd0981c70924884e3948e65f7b99557c90e", + "wasm64": "bb80fdf497f261cbcbb432594b599fafa922f3f709bac263b9d21a38f2a0ab66" } }, "texlive": { "manifestSha256": "028ada023f8a8f9966c8a28575242a339aaaf0e8870fe4a221986133b381c3c0", "cacheKeys": { - "wasm32": "ff321955a73f9ce9c018eeb9faba1df0274ed6087f6df60f5903dcce9e05956c", - "wasm64": "ac3f28b923066fb4d53fbee134e95fe1f105ae31c852f7dc90a9962ffe9ae67b" + "wasm32": "5b650c94bb8c45ecec9376f87d9db1e0cbd3f26645cb736aed32882578d4baa3", + "wasm64": "873c228fff637627c1524c83f8f6994e4c01479ce1c343346027de0ed71389e1" } }, "unzip": { "manifestSha256": "ef4e5e34258827ab3cbc1930da3fd9514f08f2d7d56c7c3e0d5c495a4d3a20e9", "cacheKeys": { - "wasm32": "cc555c2eb64b33f1ca29d997a36c1e1e98df74a5411b0992dc4127352e6ffefa", - "wasm64": "8d0a48d9a7b84adf582c8194486a866300b3e047b5acb610c4f6f718a608920b" + "wasm32": "f4ee34b5872894f3a45faf97c7a13d3b0c5d7b46344c89905667a26b0ea5fdf6", + "wasm64": "beccd8ef50cc48279b881fdd94c354390025c497f391b01267f3b031e8d5d95a" } }, "userspace": { "manifestSha256": "221176f2a096dee19bbefd89da5c7f50138ecd92d37ec9d7d6b35dbb25e86924", "cacheKeys": { - "wasm32": "5bbaac807bc93e6acc7e52fbde8377f9d9c6f261155637a15a5d61591f3a2ff1", - "wasm64": "982bad219c6a6816275c2f9e0b44595519c15f0e554f926e53cf08c87b31ac2a" + "wasm32": "6538ad13ac4b15f5ca4a02c3eee15274bd7ff6aa959be24290ea5daab4b72135", + "wasm64": "cb6856ff8fcea64797e68d1a0e313b66a7ff9ca65bc396dbc632409777bd27cc" } }, "vim": { "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", "cacheKeys": { - "wasm32": "a4a47859e8a0a1d8f627327f1fb502f2ac7fee615985fa030b5f9fd88d03752b", - "wasm64": "36683fb33b1fec3828b34eacfebf7723c0beb1549075991e14a9fa30de84dc9f" + "wasm32": "39d28fe5dd326480d8b3e140317bcdb65dee6cbda536a6a45ed2fe0b623660e2", + "wasm64": "581485dd1b5181aaeef781f89e1f29d1954280c461fbef588a1663c13785ec43" } }, "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "cf5f9141bfc5047f7548121cafb993708a8da25b2639967cc603e4d0347a4347", - "wasm64": "79ae99127fa0c1426bb84ad794589923616ffc9dac85ccadff51097a9c18e9f1" + "wasm32": "2be4011bff29858481bd2e67c3a9ed5fb8d2424e2ada956ae42bd0a2f506de71", + "wasm64": "b2bc5053848452fe5ae433b6a8a6c104af6de32757cf9b3bb46f2e03508b0ddf" } }, "wget": { "manifestSha256": "d3c7ba9bc1ae708b99850a6eb2cae521c85bf1010c90135a935f5dfae57a53a0", "cacheKeys": { - "wasm32": "97d80a049d3f04139d1048566d4b0517b73e1200b06c9bb6745c065f5f1ca75d", - "wasm64": "79ebe8b418bda767a3e7e84f8b58f7c24b5200fa5a2c1c791ee3cf5c4da450cc" + "wasm32": "222061a6c8511df115329138bac0641312d7089558fbf6a2da886a50aa6150b3", + "wasm64": "1910da379eebf52b7940211b2cecc12100e0e64034df34231a2c1623b8fa9b67" } }, "wordpress": { - "manifestSha256": "2c95e72f657a05be445c9b264de769becfb50833d6ef7ee4d18feac690ca2b52", + "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "bcd426e3ad7266898c91892ad7adbcad82fcbfbe9d1768862d8abaa58974da56", - "wasm64": "ee4954e638302f8fbf4062dfa9908f93e22a0eca68bfe8101f6742e93be92f5c" + "wasm32": "225610679e2e521c0d861e2c8a3c36bb1a24c682e97ce909b1b0e786a4147aa0", + "wasm64": "b811c0fe3bb7743061ffc5f3504665845034191104c0bd16fadb8742fbc3425b" } }, "xz": { "manifestSha256": "8707d35b8647b1af8fa165dcb6ce7b2a6a007e19ab2daca2680c39395864a737", "cacheKeys": { - "wasm32": "e5a775c8202677e7819c82bfdc97d12e616873e776ee51b965246920ace2c052", - "wasm64": "661a91acb2951fb95554498838de01c0c275f903d5e47b2f819cb8b4559d1858" + "wasm32": "71047565d59bb74ecd56e2abb2bf57e500b1167638030fa52e6503de796f7682", + "wasm64": "f8202a6c4d1793455ec28ed4e33796a21ec0d4200611fb694a53985057fe43ec" } }, "zip": { "manifestSha256": "9c9cfa8220aaeaea26736b55f7cd0a7517b8853c07f9f0d241ad67dd43592e36", "cacheKeys": { - "wasm32": "eadd23e06a078d5f6760074f74e2c2ab18572e92959082bd49018d547243e5c1", - "wasm64": "6a10af8e54adc44b5d684ecf7addf114d0757fd0ac2f60a4a47193bcf04aec94" + "wasm32": "a73d281ebf1c5fead1576a20094dc528b4a48a02a6fc065c9168a368bebc3be3", + "wasm64": "b154bbbed9698922f37994ea59a3b174b539f6acc1f5b0fa65d3a51785ec1375" } }, "zlib": { "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", "cacheKeys": { - "wasm32": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e", - "wasm64": "81fcb013fc9a89a08ee2e28a95c4eb315f2d24a0e68d6cf6eec88fc4def6c5ff" + "wasm32": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1", + "wasm64": "09bd3336c2aea228832343eafe49f021f347fcb5dce9a827ccbeb11761dc0411" } }, "zstd": { "manifestSha256": "89f266938c2c253700a838e6352c9de66c976c84883325aaa979f72b732357e4", "cacheKeys": { - "wasm32": "f0cff41dee757dcf21bb7933a06c423a8c51eabb7bf94ff907e1d15b2db659ef", - "wasm64": "8ad106bd6a202d37c495d16b78d54efba46f17995694919dffa286edc795a2e1" + "wasm32": "1e00e7d149e4cb81a0fcf78121d9b3b173de21d1773f33f1d8c3700c7ef5dba1", + "wasm64": "747fd024b93d6ad99e93cd92a7b9c9cf4cdf5f9d8a179100074bf4ab6c30e460" } } }, @@ -548,14 +555,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a43cb889225cd487f93af47eafe907467d21df9bc04428a5ad3df7c57cf0c6ba" + "wasm32": "830ea09a72abe74ae1e3e728d80740f330250e1be2c52a9f1e053f151d15dc3c" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", - "manifestSha256": "120bef02a4783a59f86f20a1953b120b603851e04c7d8400f16435f97f97779c", - "cacheKey": "f7210d1ab36ab5f22676d221945bd944c58e35958947358e9824da81aac15f01" + "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", + "cacheKey": "e95f67690426e286783716ea6e0b39edf44bca23e8f7fd225567a45109c13400" } ] }, @@ -575,7 +582,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5cd794c0a8203fa7a96dce3c944b5b5173ff3f39dd08a3ba08635ad5f65cd1e7" + "wasm32": "87d9afde76b4f3d9ba5ca78fe38f55cdf86b312bdce8320a5c17f8b0dbbc1b38" }, "dependencyClosures": { "wasm32": [] @@ -596,7 +603,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8c43dfe973ffc1180e33126913a410f3fd17fa7c5300e2ec421f9896d3f54111" + "wasm32": "09122fb04b306e4f803ca3a461e075091be99e9bf17835a19d7013a803322a2f" }, "dependencyClosures": { "wasm32": [] @@ -617,7 +624,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "0d7bcbc4565d31d275d3dda0357abbf40747e5f6f1407762ad00b4ce0673b0ce" + "wasm32": "72765db88ce28cb67eb7b38a545b40e004215d7ccbb0d4eb02419c509b8d46cf" }, "dependencyClosures": { "wasm32": [] @@ -638,14 +645,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9ebc9502240decca75ad4c6e155e6748a3d07d4f6fdb695b65d01ea799bc07af" + "wasm32": "314d476303cbf1886be4bf31ee1e21c9012bc2056222766f89ec33d9845f974e" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -672,19 +679,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ba032d8eef4b18230fe5fd7d0496b6abd1f291f3583a48f4ef3c94f441e8eaf1" + "wasm32": "63cca65e3571ba910489e52d3c0ec424570a8ebfa08b3b3e24c39747adcd51da" }, "dependencyClosures": { "wasm32": [ { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -704,7 +711,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "30d3944c0d6c187c6eed2387e81b4be46eeb7ea9c1627f216499eded89650310" + "wasm32": "0b2035e2d9b130f8edc85024720502b612e52db3c71c0c35ed55e26140515a93" }, "dependencyClosures": { "wasm32": [] @@ -725,7 +732,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b1c53d03d0e67919a48f3d5eb38e4e9fc7397cfff56a6c47fc9a9faec895d21e" + "wasm32": "5b829e0fee62ddcc82e67563f559482c77be4ad2a794b297d614a9ac0bfb96a2" }, "dependencyClosures": { "wasm32": [] @@ -767,14 +774,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "wasm32": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" } ] }, @@ -808,7 +815,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "729f2550f5075a36a2fabdee2258bcd37abf016d8ef9885b1d6e324f36bba143" + "wasm32": "fb022926bb648059ad387c1919207500dbdfd359eed86fb38f671584f2ea97e5" }, "dependencyClosures": { "wasm32": [] @@ -836,14 +843,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b4ea72967f923df9ae8e5f0d46cfc2b8c9a6c6a8e0cce59283f5e2ede6a0674a" + "wasm32": "fd19b68ecacbba3881e594e17360a68b1b39800ddb2710b8a9f6ad48b3480faf" }, "dependencyClosures": { "wasm32": [ { "packageName": "erlang", "manifestSha256": "475d6037e73a0f1e2f4381067194b2422751206979ea17209e10efa5a2a93bbb", - "cacheKey": "729f2550f5075a36a2fabdee2258bcd37abf016d8ef9885b1d6e324f36bba143" + "cacheKey": "fb022926bb648059ad387c1919207500dbdfd359eed86fb38f671584f2ea97e5" } ] }, @@ -863,7 +870,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "89f5953bb40e091907881848e6166675cfd8e5580de02c7848711736746832e3" + "wasm32": "ad115c1c6aba37e0b0246d728ec5343e931bc0b4b00c1fe8e24bf2e881399ce3" }, "dependencyClosures": { "wasm32": [] @@ -884,7 +891,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "28c5bfeb2dddaebf9cc39451d2c2983812bd6c61abc05197ed584c51f310a2b1" + "wasm32": "41f1cbea97d2331099f6ec8f24ad10c15d54d536dca8c5d96354d053685333f1" }, "dependencyClosures": { "wasm32": [] @@ -912,7 +919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "51fb9260ceb9656bbb34bf723314ea6e6905915d65b736845f0fbf593b5eef00" + "wasm32": "9cf821c87b60d798687583dbb8e325ba26d10764f2404b01d15bd296f5630332" }, "dependencyClosures": { "wasm32": [] @@ -940,7 +947,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cee993ecea8042490957d500095605e9635f5e3247fff32b160a3569749e53fc" + "wasm32": "f25ff8e149130b153b32c462696ed336e03a217e5bf9098aefb31f62cb96cdb8" }, "dependencyClosures": { "wasm32": [] @@ -961,7 +968,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8acb9c9dc14337fe6b17808630ba402c2d649822a618c6c83fad1201d2ef388e" + "wasm32": "956b61b3c170c0be48a6b482941cefd2bbe895cec099f5c748969c71b2e37ee8" }, "dependencyClosures": { "wasm32": [] @@ -989,7 +996,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cf4ea02c0d5ffbdec575a9fcce7c943d8d45bac06d8ee223171a3b41b6fa8e52" + "wasm32": "2b95ab13353b8f112cd0eeb7ffcd01f745ec00814bf531f97bb4ec1f85d3a77f" }, "dependencyClosures": { "wasm32": [] @@ -1010,7 +1017,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c3d94eaf953ab465b07e356c413cc4c849ca76d49ae27c9ff2479456f3704479" + "wasm32": "c5fea74ec33f362e863e9d99f5d4719cc369ec754884d8183ded6ebce6c346ba" }, "dependencyClosures": { "wasm32": [] @@ -1025,20 +1032,41 @@ } ] }, + "homebrew-bootstrap": { + "manifestSha256": "9446c30113764de79abe29df79586849ab4f296d723fcf9614f45d885d114388", + "arches": [ + "wasm32" + ], + "cacheKeys": { + "wasm32": "3f44ee7f53ebf6d26e30f1dbb332484753df28728e341ba4473d73f16e6a6ebf" + }, + "dependencyClosures": { + "wasm32": [] + }, + "members": [ + { + "kind": "output", + "sourceArtifact": "homebrew-bootstrap.zip", + "mirrorPath": "homebrew-bootstrap.zip", + "outputName": "homebrew-bootstrap", + "forkInstrumentation": "disabled" + } + ] + }, "kandelo-sdk": { "manifestSha256": "c081879f1becd855917cff96f17429bcf2b9fd61bf95211eca9ff8fb625bb2eb", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "58303f53b90f75f88b1b2fe74bc842968307f9a16acf8592a61b30f9c01ce35c" + "wasm32": "5ec342f97928be06680f16f6bcb621b5265e3375a4d602f8a0e78b3adb1b79cc" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" } ] }, @@ -1053,69 +1081,69 @@ ] }, "lamp": { - "manifestSha256": "40b66d115967f8ea3e1229cccfd1e6636f5363b4fd02ef66850a2dd441ccf13d", + "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "c613db2f499d1c74b96dae783d3a82835d50dd377f7f633c7b786d501ea0ffa4" + "wasm32": "52b8525aaf27598d1d89e1b3ed0f84f9f7a415052c246d632eb8fda2b4a1fa99" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "cacheKey": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5f2d9559cd1173f21fa621bcdf5451a35f3ffcc3435dd7b0e09169834092e402" + "cacheKey": "48a3ee79c8510a1fe933281741cb0fb56f2a67fa7dba9d2cd18c0085cf49f375" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "cb147677f78c356f20e2581df7dbaab3c77f6e3b879c33f24484bb4eafa6160b" + "cacheKey": "8273da44c60d3edb2032e3566040ed8fd07c2d3d5e43bfd4bbe94ff7ec0b95c6" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9328da7f90a267e6eabfb49e8b00d0dd45296f0d9c4339fa1c58e2813def6c70" + "cacheKey": "b494e26a4bd579392ae78f894f37f1d7b7ce87f0401f9d53edbf982b0753f76b" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "ecbc5a52e3f7b2cc5d697d3982fe94e9dbc69e8354f62f145dad302437b1ce61" + "cacheKey": "f8e7709dc6d6606b0d5ad24b1bb2f7aece6df4e171f92dfe7ea344713c14d4e8" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "bb7925958ee1261e640a025e5801ffed415d4486e4a6339094f0fd7338f47073" + "cacheKey": "0e020e8d1c342ccf5dac59079aab2b2ec35b083b03107ac172cc0f9ac97a13ec" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "dfd36d445f70b37ac26f03363a3f400cb42c510e3404198b4904012c6bc19c82" + "cacheKey": "89cb71f508decd03f2b38c57e3f071c00a8d0b12d365f0f98cebabbda13cec79" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "11fb3461f854cdf5fcd76e3056b0d1e50d4b1c01658a9b7806665b041db1a3c8" + "cacheKey": "94c0ffb6de71a0cbffd57cbe6979ad7438b16b0ca7b32b46cce86bf65fa1d5a6" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "7f69eae6a9d05d93c4458093a1ac80d5685ea4ff991c948274400796c770f690" + "cacheKey": "cc1c9c86ad57a4b5a5ed23636c6c50a354f85e294cfc6addf0c9457cb33defc1" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "pcre2-source", @@ -1125,22 +1153,22 @@ { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "2b3b191f35c8adbfccdcfc138af00f8c00855e808ffb7336b503ac50f224b4a6" + "cacheKey": "54c56075f8cdfca61bfbe51fded0cf9ce52ff6fa66f20e720d9d2d0c40b654dc" }, { "packageName": "shell", "manifestSha256": "5d02cd8fee4e0d46c7552a3046c3898f9e3bd5667094e02cab01851eed97a321", - "cacheKey": "8f31a8307da11025cf70150730e19af8a5fef7e5bbbd72a94576b4359efb32c4" + "cacheKey": "308558a09c4798e2c09083da90be88ce752307cde833d2a0f036b6a61bd28e18" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "3cabb24c5250387ef8e26e0a1566cb7b198a8783c3cc30fb2ae31c8d6d044e77" + "cacheKey": "a21f509748a9f5a086db9ae7bb2341a3c57171c954b31a3982b2b47a98794694" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -1160,7 +1188,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a31a123f83a7dbc0e10f54d71b5b74ff0d2d249b5612daa8e1562e6eb36eaf11" + "wasm32": "69fd5aea1025f2974ce24964d3f93ba08df5f5082c48fd5e2f5c101df852cc07" }, "dependencyClosures": { "wasm32": [] @@ -1181,7 +1209,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c448f0352bb9d9cfbb1fd4d63ee278c114a12b8b3292fe846c555319859d9ecc" + "wasm32": "0cbea55dc47e3ce5196d9b07cfa6a22a21fc80b87ba971fcac3f235bcfdfd6d5" }, "dependencyClosures": { "wasm32": [] @@ -1202,7 +1230,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2a76adafbe9deb20f29041be2fe5cba99e207f28afec04911d311d3387f9e664" + "wasm32": "746a3cb4942a7375f68e1b8cfdc24e71dae6b1393da3b8bef29386af1af10302" }, "dependencyClosures": { "wasm32": [] @@ -1223,7 +1251,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "13cd37652953e64a44481f086429f2a44bd8a0dc66824eb7e1ae3bf2e6e08fe0" + "wasm32": "8bbaab344db8e332b3763ce4700af83beb4ca8f6aa6670cce47f5ee9bd420899" }, "dependencyClosures": { "wasm32": [] @@ -1245,15 +1273,15 @@ "wasm64" ], "cacheKeys": { - "wasm32": "dfd36d445f70b37ac26f03363a3f400cb42c510e3404198b4904012c6bc19c82", - "wasm64": "815f13c9c636ea4ab21b291c35ce333ac30c97b7bdfc15c583a2686055d9065b" + "wasm32": "89cb71f508decd03f2b38c57e3f071c00a8d0b12d365f0f98cebabbda13cec79", + "wasm64": "f6668fed09920d9e3526da63be09a9fc9650880341e11e5d87d93c32e9cef494" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "pcre2-source", @@ -1265,7 +1293,7 @@ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6b5b96e7d4f2fe4ceef0bca3603548d2544e31ea5d9794c9078e652a261ee6e5" + "cacheKey": "986b49777db0287089ed8b493606cb6ef82f97992ab3d11d5f054d8fb239707f" }, { "packageName": "pcre2-source", @@ -1292,39 +1320,39 @@ ] }, "mariadb-test": { - "manifestSha256": "7f388702b564e289c12206d41b6c982ee8dbd2b5f577654bd7630eb8b89e5c2a", + "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "4aa872170a6e9dd254b2552f8c0657c9448bd222f5a4e34a241adba7106381ab" + "wasm32": "98b28ac6dc6c1c8c1501591a1e56c51ea7a4e9524e469994ffc0920dd54d1424" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "0d7bcbc4565d31d275d3dda0357abbf40747e5f6f1407762ad00b4ce0673b0ce" + "cacheKey": "72765db88ce28cb67eb7b38a545b40e004215d7ccbb0d4eb02419c509b8d46cf" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "30d3944c0d6c187c6eed2387e81b4be46eeb7ea9c1627f216499eded89650310" + "cacheKey": "0b2035e2d9b130f8edc85024720502b612e52db3c71c0c35ed55e26140515a93" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "cacheKey": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "dfd36d445f70b37ac26f03363a3f400cb42c510e3404198b4904012c6bc19c82" + "cacheKey": "89cb71f508decd03f2b38c57e3f071c00a8d0b12d365f0f98cebabbda13cec79" }, { "packageName": "pcre2-source", @@ -1344,41 +1372,41 @@ ] }, "mariadb-vfs": { - "manifestSha256": "c4cd04a06d913650b0bb8c6895fa651a9a05fdba4805c576bb0c35a998276764", + "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "arches": [ "wasm32", "wasm64" ], "cacheKeys": { - "wasm32": "54750c03bafa83467aac4abd98169dfa3bd110bb49b6b8ef27b468095aca1e6a", - "wasm64": "e4d4ac50683d6bf5f94a00b0f5da8e4647beafa5b6e78df373dd588a2f9422ee" + "wasm32": "8ea599dea8866e4740a0c566e3699b30d607e0e41338394037676b129e22d655", + "wasm64": "ffd244834c1bed28c31d368b0bfa1393642bccae80e0acb515989beef5a1ff59" }, "dependencyClosures": { "wasm32": [ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "0d7bcbc4565d31d275d3dda0357abbf40747e5f6f1407762ad00b4ce0673b0ce" + "cacheKey": "72765db88ce28cb67eb7b38a545b40e004215d7ccbb0d4eb02419c509b8d46cf" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "30d3944c0d6c187c6eed2387e81b4be46eeb7ea9c1627f216499eded89650310" + "cacheKey": "0b2035e2d9b130f8edc85024720502b612e52db3c71c0c35ed55e26140515a93" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "cacheKey": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "dfd36d445f70b37ac26f03363a3f400cb42c510e3404198b4904012c6bc19c82" + "cacheKey": "89cb71f508decd03f2b38c57e3f071c00a8d0b12d365f0f98cebabbda13cec79" }, { "packageName": "pcre2-source", @@ -1390,27 +1418,27 @@ { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "49b2ba4a2e42ce773866714628ea4979da63dac87eca8d4c54c73c510529f9b3" + "cacheKey": "837bdc6360d3481994ccb24c2fb12fd509ce70f445582a1ca32b0b77101dd3ad" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "437585daf592ebf1c49b784cb31db47d60c40848008672848c0dcf7c31731e6a" + "cacheKey": "981f288954ad9124917838d3deaf8514853a168f0f7c88c373b98aeafbebaeb0" }, { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "262444a46b7c122cadad29850ca42dff322377652f48e822fc88d5ea1cc0b7ed" + "cacheKey": "ce140644a3a60989c426590afbc252ed9b2042e7d810183b207dedb8c9f25738" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6b5b96e7d4f2fe4ceef0bca3603548d2544e31ea5d9794c9078e652a261ee6e5" + "cacheKey": "986b49777db0287089ed8b493606cb6ef82f97992ab3d11d5f054d8fb239707f" }, { "packageName": "mariadb", "manifestSha256": "aeb221be233e4b57bccb4b3375020f94c3ec593dac2734631f00e3b1aec9f48c", - "cacheKey": "815f13c9c636ea4ab21b291c35ce333ac30c97b7bdfc15c583a2686055d9065b" + "cacheKey": "f6668fed09920d9e3526da63be09a9fc9650880341e11e5d87d93c32e9cef494" }, { "packageName": "pcre2-source", @@ -1435,7 +1463,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "9e3e8604821fd9f2a8ba7e8c849826cbdd7d5d02f6ea56e801d07eb9fd14652b" + "wasm32": "8a5f567e2f8c33df0d1759f3e7cbeb0e6105044fd4b156855a9ebf79cde6bced" }, "dependencyClosures": { "wasm32": [] @@ -1456,7 +1484,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "11fb3461f854cdf5fcd76e3056b0d1e50d4b1c01658a9b7806665b041db1a3c8" + "wasm32": "94c0ffb6de71a0cbffd57cbe6979ad7438b16b0ca7b32b46cce86bf65fa1d5a6" }, "dependencyClosures": { "wasm32": [] @@ -1477,7 +1505,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "afea62511b76715a3494ec0b383cc978c2ac2bc7bfe5c9bdb8fc44b2e4078c68" + "wasm32": "48c6e796b2e94c9a4b9e56890b1a04e7df245263464df19e6c19d4676167e7fd" }, "dependencyClosures": { "wasm32": [] @@ -1493,12 +1521,12 @@ ] }, "ncurses": { - "manifestSha256": "120bef02a4783a59f86f20a1953b120b603851e04c7d8400f16435f97f97779c", + "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "f7210d1ab36ab5f22676d221945bd944c58e35958947358e9824da81aac15f01" + "wasm32": "e95f67690426e286783716ea6e0b39edf44bca23e8f7fd225567a45109c13400" }, "dependencyClosures": { "wasm32": [] @@ -1582,7 +1610,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "b78885a086ad183e20c8a93c6b657a5ce056046d54cf8145c33bcaa5bfdd5cdc" + "wasm32": "3d57ef272670ff5652a26adb0dbafc55ba34bdd0726b75e64bb8b3cafb65555b" }, "dependencyClosures": { "wasm32": [] @@ -1603,14 +1631,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "11b1e47c7777eaf5ea106a36a0d7e70cce14a31d52005542c31222ef44473319" + "wasm32": "35bd109acb9812c033d6d96996f5d00020554b1cee059811eec89b7aba5c1455" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", - "manifestSha256": "120bef02a4783a59f86f20a1953b120b603851e04c7d8400f16435f97f97779c", - "cacheKey": "f7210d1ab36ab5f22676d221945bd944c58e35958947358e9824da81aac15f01" + "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", + "cacheKey": "e95f67690426e286783716ea6e0b39edf44bca23e8f7fd225567a45109c13400" } ] }, @@ -1630,19 +1658,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "c50cb85a33c8e4dd8157998a3c7fde468d33193054b7f0ce6cef111ccb26b011" + "wasm32": "bbde7202fd7ac9d0fe859540f69fa04547a7903705c7abda85861cf9f3a9bb3f" }, "dependencyClosures": { "wasm32": [ { "packageName": "ncurses", - "manifestSha256": "120bef02a4783a59f86f20a1953b120b603851e04c7d8400f16435f97f97779c", - "cacheKey": "f7210d1ab36ab5f22676d221945bd944c58e35958947358e9824da81aac15f01" + "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", + "cacheKey": "e95f67690426e286783716ea6e0b39edf44bca23e8f7fd225567a45109c13400" }, { "packageName": "nethack", "manifestSha256": "1a2f12ec2770bd8d40006d6c878a3493b97c71c2bb63ad08c7f6ad99bfd53504", - "cacheKey": "11b1e47c7777eaf5ea106a36a0d7e70cce14a31d52005542c31222ef44473319" + "cacheKey": "35bd109acb9812c033d6d96996f5d00020554b1cee059811eec89b7aba5c1455" } ] }, @@ -1662,7 +1690,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "7f69eae6a9d05d93c4458093a1ac80d5685ea4ff991c948274400796c770f690" + "wasm32": "cc1c9c86ad57a4b5a5ed23636c6c50a354f85e294cfc6addf0c9457cb33defc1" }, "dependencyClosures": { "wasm32": [] @@ -1683,79 +1711,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "5e7007504c945c77b3c8c2c237a72dfec1d56f366a5d7e983ee73e8548355f63" + "wasm32": "a87b91753dc5369b7a4ac5f8e57aa0018ac99d8ad2fcd3156656aa152c868718" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "cacheKey": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5f2d9559cd1173f21fa621bcdf5451a35f3ffcc3435dd7b0e09169834092e402" + "cacheKey": "48a3ee79c8510a1fe933281741cb0fb56f2a67fa7dba9d2cd18c0085cf49f375" }, { "packageName": "kernel", "manifestSha256": "db1d66db8575562ac7b3e720dbaa06d7dd5eef577d80220ea4167dc2d69b863b", - "cacheKey": "e98596ee4e363710004ff969cc74e58e433a521011d6331681421c20cbaff721" + "cacheKey": "d0e03c4b0ab4858f28cb0d74e4a38b6d0af49781a6c53e12d14baade637a7ec9" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "cb147677f78c356f20e2581df7dbaab3c77f6e3b879c33f24484bb4eafa6160b" + "cacheKey": "8273da44c60d3edb2032e3566040ed8fd07c2d3d5e43bfd4bbe94ff7ec0b95c6" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9328da7f90a267e6eabfb49e8b00d0dd45296f0d9c4339fa1c58e2813def6c70" + "cacheKey": "b494e26a4bd579392ae78f894f37f1d7b7ce87f0401f9d53edbf982b0753f76b" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "ecbc5a52e3f7b2cc5d697d3982fe94e9dbc69e8354f62f145dad302437b1ce61" + "cacheKey": "f8e7709dc6d6606b0d5ad24b1bb2f7aece6df4e171f92dfe7ea344713c14d4e8" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "bb7925958ee1261e640a025e5801ffed415d4486e4a6339094f0fd7338f47073" + "cacheKey": "0e020e8d1c342ccf5dac59079aab2b2ec35b083b03107ac172cc0f9ac97a13ec" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "7f69eae6a9d05d93c4458093a1ac80d5685ea4ff991c948274400796c770f690" + "cacheKey": "cc1c9c86ad57a4b5a5ed23636c6c50a354f85e294cfc6addf0c9457cb33defc1" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "2b3b191f35c8adbfccdcfc138af00f8c00855e808ffb7336b503ac50f224b4a6" + "cacheKey": "54c56075f8cdfca61bfbe51fded0cf9ce52ff6fa66f20e720d9d2d0c40b654dc" }, { "packageName": "shell", "manifestSha256": "5d02cd8fee4e0d46c7552a3046c3898f9e3bd5667094e02cab01851eed97a321", - "cacheKey": "8f31a8307da11025cf70150730e19af8a5fef7e5bbbd72a94576b4359efb32c4" + "cacheKey": "308558a09c4798e2c09083da90be88ce752307cde833d2a0f036b6a61bd28e18" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "3cabb24c5250387ef8e26e0a1566cb7b198a8783c3cc30fb2ae31c8d6d044e77" + "cacheKey": "a21f509748a9f5a086db9ae7bb2341a3c57171c954b31a3982b2b47a98794694" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -1775,29 +1803,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "1538a04763355d4dd8a573ee318d9ef0c3421b31f44a7acff39884b2cf4ba14a" + "wasm32": "ca664401a0ead71d0761b6f3e15126285d239344ff918cbc28c3d54d0809f01b" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "cacheKey": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "7f69eae6a9d05d93c4458093a1ac80d5685ea4ff991c948274400796c770f690" + "cacheKey": "cc1c9c86ad57a4b5a5ed23636c6c50a354f85e294cfc6addf0c9457cb33defc1" }, { "packageName": "shell", "manifestSha256": "5d02cd8fee4e0d46c7552a3046c3898f9e3bd5667094e02cab01851eed97a321", - "cacheKey": "8f31a8307da11025cf70150730e19af8a5fef7e5bbbd72a94576b4359efb32c4" + "cacheKey": "308558a09c4798e2c09083da90be88ce752307cde833d2a0f036b6a61bd28e18" } ] }, @@ -1817,29 +1845,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "286d3611dc0a71406479ac61a2a0f46f8d51683a78ddb2f59fdddfacf81b9515" + "wasm32": "62a1b6d2516b3676a069b611e6b0af454ffe26013151b8c71e7168ec31a7fa5d" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "85aca810215b5f02dcb66956292be3b345aa4469fa34c70a6cd602b8016909dd" + "cacheKey": "2c2b1f5959b20978e10725bc42cf1d3e0c48a7c699f3b3d564d843c71b8b1ea7" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -1859,39 +1887,39 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2cbc8fc1c443a3e0aa1ed1d1638a5c3aca91603b85727c905860dc8e22afb997" + "wasm32": "3c15243220cac0639aed4b8952f9a09a39036ea4d01b2e75949ebdbacf796d02" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "node", "manifestSha256": "2131a24dfda8587860e57fc65b573086477d376b065b3b9ca4e1dfe4d79f5790", - "cacheKey": "286d3611dc0a71406479ac61a2a0f46f8d51683a78ddb2f59fdddfacf81b9515" + "cacheKey": "62a1b6d2516b3676a069b611e6b0af454ffe26013151b8c71e7168ec31a7fa5d" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "shell", "manifestSha256": "5d02cd8fee4e0d46c7552a3046c3898f9e3bd5667094e02cab01851eed97a321", - "cacheKey": "8f31a8307da11025cf70150730e19af8a5fef7e5bbbd72a94576b4359efb32c4" + "cacheKey": "308558a09c4798e2c09083da90be88ce752307cde833d2a0f036b6a61bd28e18" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "85aca810215b5f02dcb66956292be3b345aa4469fa34c70a6cd602b8016909dd" + "cacheKey": "2c2b1f5959b20978e10725bc42cf1d3e0c48a7c699f3b3d564d843c71b8b1ea7" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -1911,7 +1939,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f0d9f886fca2f6741563862628b059964bceefcdf2eb2a0b2ebfde5e30ac8adf" + "wasm32": "6aff0ce4fbc5a621ea551d1451edb0a372cf53444ea4cdb2267b15b457ebc97d" }, "dependencyClosures": { "wasm32": [] @@ -1932,14 +1960,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2a9ce3c668c531435ee31c71cfe89f9c28c4d1442fa6ad0ebd241a7004db7a7b" + "wasm32": "c19bb5219bbca8610925ed7e021bfc15259e907d4bae5bdabe0728419cfaf010" }, "dependencyClosures": { "wasm32": [ { "packageName": "perl", "manifestSha256": "6cdc4dbc54d0e4008cff41f82ec8918c0e44b4aef23903539c1bc0f538fe3ad2", - "cacheKey": "f0d9f886fca2f6741563862628b059964bceefcdf2eb2a0b2ebfde5e30ac8adf" + "cacheKey": "6aff0ce4fbc5a621ea551d1451edb0a372cf53444ea4cdb2267b15b457ebc97d" } ] }, @@ -1959,54 +1987,54 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2b3b191f35c8adbfccdcfc138af00f8c00855e808ffb7336b503ac50f224b4a6" + "wasm32": "54c56075f8cdfca61bfbe51fded0cf9ce52ff6fa66f20e720d9d2d0c40b654dc" }, "dependencyClosures": { "wasm32": [ { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5f2d9559cd1173f21fa621bcdf5451a35f3ffcc3435dd7b0e09169834092e402" + "cacheKey": "48a3ee79c8510a1fe933281741cb0fb56f2a67fa7dba9d2cd18c0085cf49f375" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "cb147677f78c356f20e2581df7dbaab3c77f6e3b879c33f24484bb4eafa6160b" + "cacheKey": "8273da44c60d3edb2032e3566040ed8fd07c2d3d5e43bfd4bbe94ff7ec0b95c6" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9328da7f90a267e6eabfb49e8b00d0dd45296f0d9c4339fa1c58e2813def6c70" + "cacheKey": "b494e26a4bd579392ae78f894f37f1d7b7ce87f0401f9d53edbf982b0753f76b" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "ecbc5a52e3f7b2cc5d697d3982fe94e9dbc69e8354f62f145dad302437b1ce61" + "cacheKey": "f8e7709dc6d6606b0d5ad24b1bb2f7aece6df4e171f92dfe7ea344713c14d4e8" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "bb7925958ee1261e640a025e5801ffed415d4486e4a6339094f0fd7338f47073" + "cacheKey": "0e020e8d1c342ccf5dac59079aab2b2ec35b083b03107ac172cc0f9ac97a13ec" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "3cabb24c5250387ef8e26e0a1566cb7b198a8783c3cc30fb2ae31c8d6d044e77" + "cacheKey": "a21f509748a9f5a086db9ae7bb2341a3c57171c954b31a3982b2b47a98794694" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -2082,7 +2110,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "304227932e1878ec59ab186b6d025f43a2b3df928484313d2ab931a46a5f3ee7" + "wasm32": "81206dd760c115c96d49ee53557f2e7b05f1fbf29502e201132aa8453469ed89" }, "dependencyClosures": { "wasm32": [] @@ -2355,19 +2383,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "245db3320c57cc36dac253a82b19b7a0240fed099bf45381b414261ea8abb435" + "wasm32": "8990d72a3cf9965a726d7545d52f58efa2c40b64e747e5999b41b56d71f17c50" }, "dependencyClosures": { "wasm32": [ { "packageName": "cpython", "manifestSha256": "7dd4f446697a73941ec940c2ccba4d53be73fe6947f1e701031a4d0aa964c4ff", - "cacheKey": "9ebc9502240decca75ad4c6e155e6748a3d07d4f6fdb695b65d01ea799bc07af" + "cacheKey": "314d476303cbf1886be4bf31ee1e21c9012bc2056222766f89ec33d9845f974e" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -2387,7 +2415,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a7391e556a100804e931a940f6a88b3afee2d7a866fa315e7787a9a5a8948403" + "wasm32": "9ff20fb4a59e14e06a729e8c36e4c8af15fc05d613d88ee3d6cd42d20b7ff1c1" }, "dependencyClosures": { "wasm32": [] @@ -2415,24 +2443,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "527cb56369cfda87657ba376b07742e33581329a38e6b8184dd09b39ae0714e5" + "wasm32": "dfbdea8ae43c488ebafdbb4e83cb5f4dd6bf0810fadd95ede4c1ced77174feb1" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "cacheKey": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "redis", "manifestSha256": "151fb507de953ba2ba94b8f881bc7d66b4c741c1359a2f590cc07f9a4cd368a3", - "cacheKey": "a7391e556a100804e931a940f6a88b3afee2d7a866fa315e7787a9a5a8948403" + "cacheKey": "9ff20fb4a59e14e06a729e8c36e4c8af15fc05d613d88ee3d6cd42d20b7ff1c1" } ] }, @@ -2452,79 +2480,79 @@ "wasm32" ], "cacheKeys": { - "wasm32": "afd016a598b5bacde4a1275a142a2fb517a51dc22e120d9562c286765091324b" + "wasm32": "f37ef8a5f785aa3d9cd9f601b4e96ce916ad72ed701952dc2fdc671639d5d9d8" }, "dependencyClosures": { "wasm32": [ { "packageName": "bash", "manifestSha256": "6478060f28d430d18a6ebe7c603392d35a41d9bcf6bc74322351d1450b9c5335", - "cacheKey": "a43cb889225cd487f93af47eafe907467d21df9bc04428a5ad3df7c57cf0c6ba" + "cacheKey": "830ea09a72abe74ae1e3e728d80740f330250e1be2c52a9f1e053f151d15dc3c" }, { "packageName": "bc", "manifestSha256": "a65661463bb7047b91ff00153bd99fd962c96e571934ab4e923f5215fb6ecfbd", - "cacheKey": "5cd794c0a8203fa7a96dce3c944b5b5173ff3f39dd08a3ba08635ad5f65cd1e7" + "cacheKey": "87d9afde76b4f3d9ba5ca78fe38f55cdf86b312bdce8320a5c17f8b0dbbc1b38" }, { "packageName": "coreutils", "manifestSha256": "b8baabc9af9283434e0d80f7fdc0aaef242ffa31092ab0fc39a0603a12b3cca7", - "cacheKey": "0d7bcbc4565d31d275d3dda0357abbf40747e5f6f1407762ad00b4ce0673b0ce" + "cacheKey": "72765db88ce28cb67eb7b38a545b40e004215d7ccbb0d4eb02419c509b8d46cf" }, { "packageName": "dash", "manifestSha256": "10ce1bb611fefd78a6c9ec9038f6113e29a7062b1a73e08eccf9b33004cea9a8", - "cacheKey": "30d3944c0d6c187c6eed2387e81b4be46eeb7ea9c1627f216499eded89650310" + "cacheKey": "0b2035e2d9b130f8edc85024720502b612e52db3c71c0c35ed55e26140515a93" }, { "packageName": "diffutils", "manifestSha256": "3a78f0a46bae43ce5ea235c6638c2cbd1d8559b0b62b1fb42443b35968c1aca3", - "cacheKey": "b1c53d03d0e67919a48f3d5eb38e4e9fc7397cfff56a6c47fc9a9faec895d21e" + "cacheKey": "5b829e0fee62ddcc82e67563f559482c77be4ad2a794b297d614a9ac0bfb96a2" }, { "packageName": "file", "manifestSha256": "7874c1affcbbf2c8ab8c8d4beb087f275af57b5caae6a8947bf5a63a181552b2", - "cacheKey": "28c5bfeb2dddaebf9cc39451d2c2983812bd6c61abc05197ed584c51f310a2b1" + "cacheKey": "41f1cbea97d2331099f6ec8f24ad10c15d54d536dca8c5d96354d053685333f1" }, { "packageName": "findutils", "manifestSha256": "cceedf52aea67fbb0da03cfce6f2e9b1a7b5561fa1656c2762b43c651a625d02", - "cacheKey": "51fb9260ceb9656bbb34bf723314ea6e6905915d65b736845f0fbf593b5eef00" + "cacheKey": "9cf821c87b60d798687583dbb8e325ba26d10764f2404b01d15bd296f5630332" }, { "packageName": "gawk", "manifestSha256": "a2567b8b0778805e385e1d3a41ac8b5d74aaf9d293b222001b17d09b11e5a0ba", - "cacheKey": "cee993ecea8042490957d500095605e9635f5e3247fff32b160a3569749e53fc" + "cacheKey": "f25ff8e149130b153b32c462696ed336e03a217e5bf9098aefb31f62cb96cdb8" }, { "packageName": "grep", "manifestSha256": "59270d3bfb33167b32d13246bf855b49308f8c9c0a66d9635c804f702421fbc6", - "cacheKey": "cf4ea02c0d5ffbdec575a9fcce7c943d8d45bac06d8ee223171a3b41b6fa8e52" + "cacheKey": "2b95ab13353b8f112cd0eeb7ffcd01f745ec00814bf531f97bb4ec1f85d3a77f" }, { "packageName": "m4", "manifestSha256": "2c6582d99d6eabfb9da52badf49b899e9fdcba76a728536b25bc3741f3331543", - "cacheKey": "2a76adafbe9deb20f29041be2fe5cba99e207f28afec04911d311d3387f9e664" + "cacheKey": "746a3cb4942a7375f68e1b8cfdc24e71dae6b1393da3b8bef29386af1af10302" }, { "packageName": "make", "manifestSha256": "f878d2d730f36a4c6dfe1fff1b4ccc704757ea22d8c4c8ccb95b11cd641e5fed", - "cacheKey": "13cd37652953e64a44481f086429f2a44bd8a0dc66824eb7e1ae3bf2e6e08fe0" + "cacheKey": "8bbaab344db8e332b3763ce4700af83beb4ca8f6aa6670cce47f5ee9bd420899" }, { "packageName": "ncurses", - "manifestSha256": "120bef02a4783a59f86f20a1953b120b603851e04c7d8400f16435f97f97779c", - "cacheKey": "f7210d1ab36ab5f22676d221945bd944c58e35958947358e9824da81aac15f01" + "manifestSha256": "0a1c180eeade627e204aa47065dbfebfcdc9359d25e2db436932c7fa3a86fc1e", + "cacheKey": "e95f67690426e286783716ea6e0b39edf44bca23e8f7fd225567a45109c13400" }, { "packageName": "posix-utils-lite", "manifestSha256": "8fd7190b2848ef80143adc7b9268c79e13cd4db3430f7105faf49a4b4407a06f", - "cacheKey": "304227932e1878ec59ab186b6d025f43a2b3df928484313d2ab931a46a5f3ee7" + "cacheKey": "81206dd760c115c96d49ee53557f2e7b05f1fbf29502e201132aa8453469ed89" }, { "packageName": "sed", "manifestSha256": "2a08e9c5dacc5facc8983c1ff35ff2a72db328405e9a1f464cc7ad155c4d08af", - "cacheKey": "f687903421a723b81ead0a60f488b617375ce19ed97edc2820818f96f90e3c82" + "cacheKey": "6265486b1ef7c27412920d531290a1bd43e0e1f5c0459861c1908ef52da6adc7" } ] }, @@ -2544,14 +2572,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "84ffe7f564d7631b51dd3951ff655eef315a89691e9efaa3791831643bd1e985" + "wasm32": "b9554136ba3f6ec44897088250599687cfc0c7fc3af971b908f2668cd5cacd7a" }, "dependencyClosures": { "wasm32": [ { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -2578,7 +2606,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f687903421a723b81ead0a60f488b617375ce19ed97edc2820818f96f90e3c82" + "wasm32": "6265486b1ef7c27412920d531290a1bd43e0e1f5c0459861c1908ef52da6adc7" }, "dependencyClosures": { "wasm32": [] @@ -2599,7 +2627,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "8f31a8307da11025cf70150730e19af8a5fef7e5bbbd72a94576b4359efb32c4" + "wasm32": "308558a09c4798e2c09083da90be88ce752307cde833d2a0f036b6a61bd28e18" }, "dependencyClosures": { "wasm32": [] @@ -2620,24 +2648,24 @@ "wasm32" ], "cacheKeys": { - "wasm32": "85aca810215b5f02dcb66956292be3b345aa4469fa34c70a6cd602b8016909dd" + "wasm32": "2c2b1f5959b20978e10725bc42cf1d3e0c48a7c699f3b3d564d843c71b8b1ea7" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -2657,29 +2685,29 @@ "wasm32" ], "cacheKeys": { - "wasm32": "4867322d0e6f96809141a2dce6682483a2a6743899f5fd75f2271d82475f0269" + "wasm32": "1fc4b5fe1002e77e10e531ecd916c99a766c69583716fca36d5fbba88fa389da" }, "dependencyClosures": { "wasm32": [ { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "spidermonkey", "manifestSha256": "c72ef4e43ee2a4fc74390d03a239fce3b545a6d3b64f02ccc4d44972cec626bd", - "cacheKey": "85aca810215b5f02dcb66956292be3b345aa4469fa34c70a6cd602b8016909dd" + "cacheKey": "2c2b1f5959b20978e10725bc42cf1d3e0c48a7c699f3b3d564d843c71b8b1ea7" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -2699,7 +2727,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a26d9e2b82748828e1f2e322c535892993237673d1759269341ca7e3d55b77ae" + "wasm32": "9c78c0e368f0cdb390dc2c54c075c990b4c462d918f2c2432387cbfa78bbeeec" }, "dependencyClosures": { "wasm32": [] @@ -2720,7 +2748,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bedb0c34a78b5c4642b340e115feca87ed3752f4a836d3b5534716e3137c034c" + "wasm32": "d842e794f28f0f7c3fed3be0b1ad1f26dbe563b13a8883bc77e2fc59f21b52b4" }, "dependencyClosures": { "wasm32": [] @@ -2741,7 +2769,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e18543aa20544ced46cb8a2a7bbee5d5227ec0c1076336eb3db6ad70ad3c4095" + "wasm32": "edae667904ad078a3b3e53e0620d1cd0981c70924884e3948e65f7b99557c90e" }, "dependencyClosures": { "wasm32": [] @@ -2762,19 +2790,19 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ff321955a73f9ce9c018eeb9faba1df0274ed6087f6df60f5903dcce9e05956c" + "wasm32": "5b650c94bb8c45ecec9376f87d9db1e0cbd3f26645cb736aed32882578d4baa3" }, "dependencyClosures": { "wasm32": [ { "packageName": "libpng", "manifestSha256": "79ed5c5c072a0267cce5c7a2fe37d8494571b17e44b952f619e04ec1c4a9db10", - "cacheKey": "cefa8c2f3cb40a07bba56f1649a7d12f095d3d59c6f1b114fb9f0dae0147633b" + "cacheKey": "e31da77ef2ff8fee85f28978d0356ebb799f3ae71458f0814fd9bf5c9d708af3" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -2801,7 +2829,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cc555c2eb64b33f1ca29d997a36c1e1e98df74a5411b0992dc4127352e6ffefa" + "wasm32": "f4ee34b5872894f3a45faf97c7a13d3b0c5d7b46344c89905667a26b0ea5fdf6" }, "dependencyClosures": { "wasm32": [] @@ -2822,7 +2850,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a4a47859e8a0a1d8f627327f1fb502f2ac7fee615985fa030b5f9fd88d03752b" + "wasm32": "39d28fe5dd326480d8b3e140317bcdb65dee6cbda536a6a45ed2fe0b623660e2" }, "dependencyClosures": { "wasm32": [] @@ -2843,14 +2871,14 @@ "wasm32" ], "cacheKeys": { - "wasm32": "cf5f9141bfc5047f7548121cafb993708a8da25b2639967cc603e4d0347a4347" + "wasm32": "2be4011bff29858481bd2e67c3a9ed5fb8d2424e2ada956ae42bd0a2f506de71" }, "dependencyClosures": { "wasm32": [ { "packageName": "vim", "manifestSha256": "c211660ef41d01f95f3fbdf675b74891e897e3f304e04f1bf5586f326007382c", - "cacheKey": "a4a47859e8a0a1d8f627327f1fb502f2ac7fee615985fa030b5f9fd88d03752b" + "cacheKey": "39d28fe5dd326480d8b3e140317bcdb65dee6cbda536a6a45ed2fe0b623660e2" } ] }, @@ -2870,7 +2898,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "97d80a049d3f04139d1048566d4b0517b73e1200b06c9bb6745c065f5f1ca75d" + "wasm32": "222061a6c8511df115329138bac0641312d7089558fbf6a2da886a50aa6150b3" }, "dependencyClosures": { "wasm32": [] @@ -2886,84 +2914,84 @@ ] }, "wordpress": { - "manifestSha256": "2c95e72f657a05be445c9b264de769becfb50833d6ef7ee4d18feac690ca2b52", + "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "arches": [ "wasm32" ], "cacheKeys": { - "wasm32": "bcd426e3ad7266898c91892ad7adbcad82fcbfbe9d1768862d8abaa58974da56" + "wasm32": "225610679e2e521c0d861e2c8a3c36bb1a24c682e97ce909b1b0e786a4147aa0" }, "dependencyClosures": { "wasm32": [ { "packageName": "dinit", "manifestSha256": "44e125e1337503e27f8cff531cdbd79c4f406fd2d282b916a7746cf3a14057bf", - "cacheKey": "e4d6e040b3c583d15e3a80feea9db153c1e8b0ff02bc86d00fa28e5870287781" + "cacheKey": "28167e431807693bad67358e88d98cfb0603fd2d6efd9808bb051544846c5364" }, { "packageName": "icu", "manifestSha256": "f5b1f02d169ec1108fc32d9da55be53c3efb96e3a7aeba6a03aec62e0175f0dc", - "cacheKey": "5f2d9559cd1173f21fa621bcdf5451a35f3ffcc3435dd7b0e09169834092e402" + "cacheKey": "48a3ee79c8510a1fe933281741cb0fb56f2a67fa7dba9d2cd18c0085cf49f375" }, { "packageName": "libcurl", "manifestSha256": "c771e6cdc83b43840db4dd1fdce4b6189ba4f281596f7485bcda9c65e2ecda00", - "cacheKey": "cb147677f78c356f20e2581df7dbaab3c77f6e3b879c33f24484bb4eafa6160b" + "cacheKey": "8273da44c60d3edb2032e3566040ed8fd07c2d3d5e43bfd4bbe94ff7ec0b95c6" }, { "packageName": "libcxx", "manifestSha256": "6e1ebfa6914043770eae2a6c156b915acf6a56fe763f07d215ffe6f6f2305007", - "cacheKey": "6d08aa913e932d33232f8076de99312f87dd7b4d866c31f1c168999910a4fffd" + "cacheKey": "53612e1c46d4ce4e8d07b911f753fda620b8b8597c38c3775fc1769f8a63ba89" }, { "packageName": "libiconv", "manifestSha256": "fa60b386f4ba0cd5bbbd595f67477cbfb0cc401a14bbd4769630c502bb3f24b1", - "cacheKey": "9328da7f90a267e6eabfb49e8b00d0dd45296f0d9c4339fa1c58e2813def6c70" + "cacheKey": "b494e26a4bd579392ae78f894f37f1d7b7ce87f0401f9d53edbf982b0753f76b" }, { "packageName": "libxml2", "manifestSha256": "908490efcdc7783c76ce36af31d1db24bfcc7ba5f1e7b9b5298cc81cb110256c", - "cacheKey": "ecbc5a52e3f7b2cc5d697d3982fe94e9dbc69e8354f62f145dad302437b1ce61" + "cacheKey": "f8e7709dc6d6606b0d5ad24b1bb2f7aece6df4e171f92dfe7ea344713c14d4e8" }, { "packageName": "libzip", "manifestSha256": "83c65d63a6416e79436bf4e792759a4701d568df0e8c010394a9732cf659f0cc", - "cacheKey": "bb7925958ee1261e640a025e5801ffed415d4486e4a6339094f0fd7338f47073" + "cacheKey": "0e020e8d1c342ccf5dac59079aab2b2ec35b083b03107ac172cc0f9ac97a13ec" }, { "packageName": "msmtpd", "manifestSha256": "09de04a422ddf631a29a259d816e081f8d317d1077808ede55d8c500baae748f", - "cacheKey": "11fb3461f854cdf5fcd76e3056b0d1e50d4b1c01658a9b7806665b041db1a3c8" + "cacheKey": "94c0ffb6de71a0cbffd57cbe6979ad7438b16b0ca7b32b46cce86bf65fa1d5a6" }, { "packageName": "nginx", "manifestSha256": "41b0aaf05fdd0d8da42642703f9e60fd7b9545d8029896918b87c771c359147b", - "cacheKey": "7f69eae6a9d05d93c4458093a1ac80d5685ea4ff991c948274400796c770f690" + "cacheKey": "cc1c9c86ad57a4b5a5ed23636c6c50a354f85e294cfc6addf0c9457cb33defc1" }, { "packageName": "openssl", "manifestSha256": "374f5dce6b2691b630b55cb82955b33d2305384e4bd64746c581621bb0793993", - "cacheKey": "ef7615079b83e8bb69afeabcc5abaf026b5fc16a6a4b6234e25d762effdcb362" + "cacheKey": "bdef227990c15092dfb24dc2abfcb8cd2699b409bef100c9d69a62c770b3b950" }, { "packageName": "php", "manifestSha256": "fcd9d7915ed8935b05418e148edcb312adf0b1e4e627fe4aab75444b35b33bbc", - "cacheKey": "2b3b191f35c8adbfccdcfc138af00f8c00855e808ffb7336b503ac50f224b4a6" + "cacheKey": "54c56075f8cdfca61bfbe51fded0cf9ce52ff6fa66f20e720d9d2d0c40b654dc" }, { "packageName": "shell", "manifestSha256": "5d02cd8fee4e0d46c7552a3046c3898f9e3bd5667094e02cab01851eed97a321", - "cacheKey": "8f31a8307da11025cf70150730e19af8a5fef7e5bbbd72a94576b4359efb32c4" + "cacheKey": "308558a09c4798e2c09083da90be88ce752307cde833d2a0f036b6a61bd28e18" }, { "packageName": "sqlite", "manifestSha256": "ab9560b4eab53445034e2a87a27ccee9d05ddb0db73fee28f5000325b0b1cba5", - "cacheKey": "3cabb24c5250387ef8e26e0a1566cb7b198a8783c3cc30fb2ae31c8d6d044e77" + "cacheKey": "a21f509748a9f5a086db9ae7bb2341a3c57171c954b31a3982b2b47a98794694" }, { "packageName": "zlib", "manifestSha256": "d4f4701c0c30dc843ebe2111ed3e2284836cdfc4bc05a130c8fab4f31b8e6357", - "cacheKey": "d17bfc607f79188de27ed14ceac45d191f537d7376d8e69ab83e01a374d5428e" + "cacheKey": "a826c5a1662265850d13e48202fda10c240400126b9fcc0ca33ee0f57e5969f1" } ] }, @@ -2983,7 +3011,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "e5a775c8202677e7819c82bfdc97d12e616873e776ee51b965246920ace2c052" + "wasm32": "71047565d59bb74ecd56e2abb2bf57e500b1167638030fa52e6503de796f7682" }, "dependencyClosures": { "wasm32": [] @@ -3004,7 +3032,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "eadd23e06a078d5f6760074f74e2c2ab18572e92959082bd49018d547243e5c1" + "wasm32": "a73d281ebf1c5fead1576a20094dc528b4a48a02a6fc065c9168a368bebc3be3" }, "dependencyClosures": { "wasm32": [] @@ -3025,7 +3053,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "f0cff41dee757dcf21bb7933a06c423a8c51eabb7bf94ff907e1d15b2db659ef" + "wasm32": "1e00e7d149e4cb81a0fcf78121d9b3b173de21d1773f33f1d8c3700c7ef5dba1" }, "dependencyClosures": { "wasm32": [] diff --git a/packages/registry/shell/build.toml b/packages/registry/shell/build.toml index 840e4a608c..046582cc6d 100644 --- a/packages/registry/shell/build.toml +++ b/packages/registry/shell/build.toml @@ -48,6 +48,7 @@ inputs = [ "host/src/vfs/hardlink-graph.ts", "host/src/vfs/image-helpers.ts", "host/src/vfs/memory-fs.ts", + "host/src/vfs/package-deferred-tree.ts", "host/src/vfs/sharedfs-vendor.ts", "host/src/vfs/tar.ts", "host/src/vfs/types.ts", diff --git a/packages/registry/wordpress/build-wordpress.sh b/packages/registry/wordpress/build-wordpress.sh index ee276f8493..0a38d01062 100755 --- a/packages/registry/wordpress/build-wordpress.sh +++ b/packages/registry/wordpress/build-wordpress.sh @@ -6,10 +6,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -# WordPress PHP source and the SQLite db.php drop-in are downloaded into this -# registry package. The VFS image builder reads from there. setup.sh is -# idempotent: it skips downloads when the trees are already present. -bash "$REPO_ROOT/packages/registry/wordpress/setup.sh" +# The VFS builder resolves and verifies the WordPress and SQLite plugin source +# archives itself. `setup.sh` remains the local unpacked-demo entrypoint; it +# creates a checkout-specific plugin symlink that is not a product-image input. # Build-time opcache prewarming boots NodeKernelHost against the half-built VFS, # so package builds need a host kernel even though wordpress itself is a diff --git a/packages/registry/wordpress/build.toml b/packages/registry/wordpress/build.toml index f44d0cb8f9..73a000d27d 100644 --- a/packages/registry/wordpress/build.toml +++ b/packages/registry/wordpress/build.toml @@ -1,7 +1,6 @@ script_path = "packages/registry/wordpress/build-wordpress.sh" inputs = [ "packages/registry/wordpress/build-wordpress.sh", - "packages/registry/wordpress/setup.sh", "packages/registry/wordpress/package.toml", "images/vfs/scripts/build-wp-vfs-image.sh", "images/vfs/scripts/build-wp-vfs-image.ts", @@ -17,6 +16,7 @@ inputs = [ "images/vfs/scripts/vfs-image-helpers.ts", "images/vfs/lib/init/shell-binaries.ts", "images/vfs/scripts/wordpress-preinstall.ts", + "images/vfs/scripts/wordpress-source-layout.ts", "apps/browser-demos/lib/init/wordpress-runtime-config.ts", "host/src", "web-libs/kandelo-session/src/demo-config.ts", diff --git a/packages/registry/wordpress/package.toml b/packages/registry/wordpress/package.toml index 6281590bc5..5eb24a76a0 100644 --- a/packages/registry/wordpress/package.toml +++ b/packages/registry/wordpress/package.toml @@ -3,7 +3,7 @@ name = "wordpress" version = "7.0" kernel_abi = 7 # build-wp-vfs-image.ts starts from shell.vfs.zst, then bakes a service -# image: dinit as PID 1, nginx, php-fpm/opcache, the WordPress tree, and +# image: dinit as service supervisor, nginx, php-fpm/opcache, the WordPress tree, and # the SQLite drop-in with a preinstalled WordPress database. The interactive # terminal sees the same shell files that the shell demo ships. depends_on = [ diff --git a/packages/registry/wordpress/setup.sh b/packages/registry/wordpress/setup.sh index bd628a6740..400d4858bc 100644 --- a/packages/registry/wordpress/setup.sh +++ b/packages/registry/wordpress/setup.sh @@ -1,8 +1,13 @@ #!/usr/bin/env bash # -# Download WordPress + SQLite Database Integration plugin. +# Prepare the local unpacked WordPress demo. # Idempotent — skips downloads if files already exist. # +# Product package builds deliberately do not invoke this script. Their VFS +# builders resolve SHA-pinned archives through source-extract-helper instead. +# This local workflow creates a checkout-specific absolute plugin symlink that +# must not become a portable product-image input. +# set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/programs/p_10_deep_linked_continuation.c b/programs/p_10_deep_linked_continuation.c new file mode 100644 index 0000000000..e555624560 --- /dev/null +++ b/programs/p_10_deep_linked_continuation.c @@ -0,0 +1,48 @@ +// P-10 — fork below enough recursive Wasm activations to exceed the retired +// 60 KiB contiguous continuation reserve. + +#include +#include +#include +#include + +__attribute__((noinline)) +static pid_t fork_at_depth(int depth) { + if (depth == 0) return fork(); + + pid_t result = fork_at_depth(depth - 1); + // Keep this as genuine non-tail recursion and retain scalar state across + // the call. The empty asm preserves the runtime value while preventing + // the optimizer from proving it constant across the recursive call. + __asm__ volatile("" : "+r"(depth)); + return result + (depth == -1); +} + +int main(void) { + printf("PRE_DEEP_FORK\n"); + fflush(stdout); + + pid_t pid = fork_at_depth(4096); + if (pid < 0) { + printf("FAIL: deep fork errno=%d\n", errno); + return 1; + } + if (pid == 0) { + printf("DEEP_CHILD: ok\n"); + fflush(stdout); + _exit(0); + } + + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + printf("FAIL: deep waitpid errno=%d\n", errno); + return 1; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + printf("FAIL: deep child status=%d\n", status); + return 1; + } + printf("DEEP_PARENT: child=%d\n", pid); + printf("PASS: P-10\n"); + return 0; +} diff --git a/programs/p_11_fork_continuation_enomem.c b/programs/p_11_fork_continuation_enomem.c new file mode 100644 index 0000000000..416792f528 --- /dev/null +++ b/programs/p_11_fork_continuation_enomem.c @@ -0,0 +1,261 @@ +// P-11 — root and later linked-fork continuation allocations that exhaust +// process address space must return ENOMEM without creating a child or +// poisoning the still-running parent. + +#include +#include +#include +#include +#include +#include + +#define WASM_PAGE_BYTES (64u * 1024u) +#define MAX_FILLER_MAPPINGS 512 + +static void *filler_mappings[MAX_FILLER_MAPPINGS]; + +__attribute__((noinline)) +static pid_t fork_at_depth(int depth) { + if (depth == 0) return fork(); + + pid_t result = fork_at_depth(depth - 1); + // WHY: keep each recursive activation live across fork so instrumentation + // must save enough frames to request a second continuation chunk. + __asm__ volatile("" : "+r"(depth)); + return result + (depth == -1); +} + +static int release_fillers(size_t count) { + int failed = 0; + for (size_t i = 0; i < count; i++) { + if (munmap(filler_mappings[i], WASM_PAGE_BYTES) != 0) failed = 1; + } + return failed; +} + +static int emit_marker(const char *text, size_t length) { + while (length > 0) { + const ssize_t written = write(STDOUT_FILENO, text, length); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) return -1; + text += (size_t)written; + length -= (size_t)written; + } + return 0; +} + +static int prove_parent_syscalls_remain_usable(void) { + int pipefd[2]; + char sent = 'K'; + char received = '\0'; + + if (pipe(pipefd) != 0) return -1; + if (write(pipefd[1], &sent, 1) != 1 || read(pipefd[0], &received, 1) != 1) { + const int saved_errno = errno; + close(pipefd[0]); + close(pipefd[1]); + errno = saved_errno; + return -1; + } + const int read_close_result = close(pipefd[0]); + const int read_close_errno = errno; + const int write_close_result = close(pipefd[1]); + if (read_close_result != 0) { + errno = read_close_errno; + return -1; + } + if (write_close_result != 0) { + return -1; + } + if (received != sent) { + errno = EIO; + return -1; + } + return 0; +} + +int main(void) { + const pid_t original_pid = getpid(); + size_t filler_count = 0; + + while (filler_count < MAX_FILLER_MAPPINGS) { + void *mapping = mmap( + NULL, + WASM_PAGE_BYTES, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + -1, + 0 + ); + if (mapping == MAP_FAILED) break; + filler_mappings[filler_count++] = mapping; + } + + if (filler_count == MAX_FILLER_MAPPINGS || errno != ENOMEM) { + printf( + "FAIL: address-space fill count=%zu errno=%d\n", + filler_count, + errno + ); + release_fillers(filler_count); + return 1; + } + if (filler_count == 0) { + printf("FAIL: no filler mapping was available\n"); + return 1; + } + + // WHY: leave the address space completely full for this first fork. Its + // initial 64-KiB continuation mmap must fail before unwind starts, proving + // the real worker-side root-allocation error path rather than only the + // host arena unit. + errno = 0; + const pid_t root_failed_child = fork(); + const int root_fork_errno = errno; + if (root_failed_child != -1 || root_fork_errno != ENOMEM) { + printf( + "FAIL: root-allocation fork result=%d errno=%d\n", + (int)root_failed_child, + root_fork_errno + ); + release_fillers(filler_count); + return 1; + } + if (getpid() != original_pid) { + printf("FAIL: process identity changed after root-allocation failure\n"); + release_fillers(filler_count); + return 1; + } + static const char root_enomem_marker[] = "ROOT_CONTINUATION_ENOMEM: ok\n"; + if (emit_marker(root_enomem_marker, sizeof(root_enomem_marker) - 1) != 0) { + release_fillers(filler_count); + return 1; + } + + int status = 0; + errno = 0; + const pid_t root_phantom = waitpid(-1, &status, WNOHANG); + if (root_phantom != -1 || errno != ECHILD) { + printf( + "FAIL: root-allocation failure left child=%d errno=%d\n", + (int)root_phantom, + errno + ); + release_fillers(filler_count); + return 1; + } + static const char no_phantom_marker[] = "ROOT_NO_PHANTOM_CHILD: ok\n"; + if (emit_marker(no_phantom_marker, sizeof(no_phantom_marker) - 1) != 0) { + release_fillers(filler_count); + return 1; + } + + if (prove_parent_syscalls_remain_usable() != 0 || getpid() != original_pid) { + printf("FAIL: parent unusable after root-allocation failure errno=%d\n", errno); + release_fillers(filler_count); + return 1; + } + static const char usable_marker[] = "ROOT_PARENT_USABLE: ok\n"; + if (emit_marker(usable_marker, sizeof(usable_marker) - 1) != 0) { + release_fillers(filler_count); + 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; + } + + errno = 0; + const pid_t failed_child = fork_at_depth(4096); + const int fork_errno = errno; + if (failed_child != -1 || fork_errno != ENOMEM) { + printf( + "FAIL: deep fork result=%d errno=%d\n", + (int)failed_child, + fork_errno + ); + release_fillers(filler_count); + return 1; + } + if (getpid() != original_pid) { + printf("FAIL: process identity changed after failed fork\n"); + release_fillers(filler_count); + return 1; + } + printf("CONTINUATION_ENOMEM: ok\n"); + + status = 0; + errno = 0; + const pid_t phantom = waitpid(-1, &status, WNOHANG); + if (phantom != -1 || errno != ECHILD) { + printf("FAIL: failed fork left child=%d errno=%d\n", (int)phantom, errno); + release_fillers(filler_count); + return 1; + } + 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; + } + if (munmap(probe, WASM_PAGE_BYTES) != 0) { + printf("FAIL: probe cleanup errno=%d\n", errno); + release_fillers(filler_count); + return 1; + } + printf("CONTINUATION_PAGE_REUSED: ok\n"); + + const pid_t recovery_child = fork(); + if (recovery_child < 0) { + printf("FAIL: recovery fork errno=%d\n", errno); + release_fillers(filler_count); + return 1; + } + if (recovery_child == 0) { + if (getppid() != original_pid) _exit(2); + printf("RECOVERY_CHILD: ok\n"); + fflush(stdout); + _exit(0); + } + if (waitpid(recovery_child, &status, 0) != recovery_child) { + printf("FAIL: recovery waitpid errno=%d\n", errno); + release_fillers(filler_count); + return 1; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + printf("FAIL: recovery child status=%d\n", status); + release_fillers(filler_count); + return 1; + } + if (getpid() != original_pid) { + printf("FAIL: parent identity changed after recovery fork\n"); + release_fillers(filler_count); + return 1; + } + printf("RECOVERY_PARENT: child=%d\n", (int)recovery_child); + + if (release_fillers(filler_count)) { + printf("FAIL: filler cleanup errno=%d\n", errno); + return 1; + } + printf("PASS: P-11\n"); + return 0; +} diff --git a/run.sh b/run.sh index 8d1b45b441..1cb31746e5 100755 --- a/run.sh +++ b/run.sh @@ -179,15 +179,31 @@ KERNEL_REQUIRED_EXPORTS=( 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 ) diff --git a/scripts/build-fork-instrumented-test-fixture.sh b/scripts/build-fork-instrumented-test-fixture.sh new file mode 100755 index 0000000000..98bb7942b9 --- /dev/null +++ b/scripts/build-fork-instrumented-test-fixture.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +ARCH="" +OUTPUT="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --arch) + ARCH="${2:-}" + shift 2 + ;; + --output) + OUTPUT="${2:-}" + shift 2 + ;; + *) + echo "build-fork-instrumented-test-fixture.sh: unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +case "$ARCH" in + wasm32|wasm64) ;; + *) + echo "build-fork-instrumented-test-fixture.sh: --arch must be wasm32 or wasm64" >&2 + exit 2 + ;; +esac + +if [ -z "$OUTPUT" ] || [ ! -d "$(dirname "$OUTPUT")" ]; then + echo "build-fork-instrumented-test-fixture.sh: --output must have an existing parent directory" >&2 + exit 2 +fi + +ABI_VERSION="$(sed -nE 's/^pub const ABI_VERSION: u32 = ([0-9]+);$/\1/p' \ + "$REPO_ROOT/crates/shared/src/lib.rs" | head -n1)" +if [ -z "$ABI_VERSION" ]; then + echo "build-fork-instrumented-test-fixture.sh: cannot read the current ABI version" >&2 + exit 2 +fi + +WORK_ROOT="$(mktemp -d)" +trap 'rm -rf "$WORK_ROOT"' EXIT +INPUT_WAT="$WORK_ROOT/input.wat" +INPUT_WASM="$WORK_ROOT/input.wasm" + +# WHY: generate valid fixtures through the production transform so an ABI +# change cannot leave tests hand-carrying an obsolete fork metadata shape. +if [ "$ARCH" = "wasm64" ]; then + cat >"$INPUT_WAT" <"$INPUT_WAT" < "false", "KANDELO_REF" => kandelo_sha, }) - check(write_sha["status"] == 2 && - write_sha["stdout"].include?("write publication requires Kandelo main"), - "publisher write path accepts a non-main Kandelo ref") + check(write_sha["status"] == 0 && write_sha["outputs"] == + "kandelo-ref=#{kandelo_sha}\ntap-ref=refs/heads/main\n", + "publisher write path does not accept an exact reviewed Kandelo commit") + + write_branch = caller_validation_result(source, { + "CALLER_WORKFLOW_REF" => + "kandelo-dev/homebrew-tap-core/.github/workflows/publish-bottles.yml@refs/heads/main", + "DRY_RUN" => "false", + "KANDELO_REF" => "review/homebrew", + }) + check(write_branch["status"] == 2 && + write_branch["stderr"].include?( + "write publication requires Kandelo main or an exact reviewed lowercase" + ), + "publisher write path accepts a mutable non-main Kandelo ref") + + { + "fully qualified main" => "refs/heads/main", + "uppercase commit" => "A" * 40, + "short commit" => "a" * 39, + "long commit" => "a" * 41, + }.each do |label, ref| + rejected = caller_validation_result(source, { + "CALLER_WORKFLOW_REF" => + "kandelo-dev/homebrew-tap-core/.github/workflows/publish-bottles.yml@refs/heads/main", + "DRY_RUN" => "false", + "KANDELO_REF" => ref, + }) + check(rejected["status"] == 2 && + rejected["stderr"].include?( + "write publication requires Kandelo main or an exact reviewed lowercase" + ), + "publisher write path accepts #{label}") + end { "fully qualified ref" => "refs/heads/review/homebrew", @@ -660,7 +691,6 @@ def check_publisher(workflow) '"$CALLER_REPOSITORY/.github/workflows/publish-bottles.yml@refs/heads/main"', '"$CALLER_REPOSITORY/.github/workflows/maintain-bottles.yml@refs/heads/main"', '[ "$KANDELO_REPOSITORY" = "Automattic/kandelo" ]', - '[ "$KANDELO_REF" = "main" ]', '[[ "$normalized_tap_repository" =~ ^[a-z0-9_.-]+/homebrew-[a-z0-9_.-]+$ ]]', 'tap_short_name="${normalized_tap_repository#*/homebrew-}"', '[ "$normalized_tap_name" = "${tap_owner}/${tap_short_name}" ]', @@ -672,8 +702,12 @@ def check_publisher(workflow) '[[ "$ref" != refs/* ]]', '[[ "$ref" != -* ]]', 'git check-ref-format "refs/heads/$ref"', + 'normalize_write_kandelo_ref()', + '[ "$ref" = "main" ]', + 'write publication requires Kandelo main or an exact reviewed lowercase 40-character commit SHA', 'validated_kandelo_ref="$(normalize_dry_run_source_ref "Kandelo" "$KANDELO_REF")"', 'validated_tap_ref="$(normalize_dry_run_source_ref "tap" "$TAP_REF")"', + 'validated_kandelo_ref="$(normalize_write_kandelo_ref "$KANDELO_REF")"', 'echo "kandelo-ref=$validated_kandelo_ref"', 'echo "tap-ref=$validated_tap_ref"', ].each do |predicate| @@ -690,7 +724,9 @@ def check_publisher(workflow) dry_kandelo_ref_index = validation_run.index( 'validated_kandelo_ref="$(normalize_dry_run_source_ref "Kandelo" "$KANDELO_REF")"' ) - write_kandelo_ref_index = validation_run.index('[ "$KANDELO_REF" = "main" ]') + write_kandelo_ref_index = validation_run.index( + 'validated_kandelo_ref="$(normalize_write_kandelo_ref "$KANDELO_REF")"' + ) write_tap_ref_index = validation_run.index('[ "$TAP_REF" = "main" ]') check(dry_index && caller_index && kandelo_index && tap_name_index && caller_index < dry_index && kandelo_index < dry_index && tap_name_index < dry_index, @@ -698,7 +734,7 @@ def check_publisher(workflow) check(dry_kandelo_ref_index && write_kandelo_ref_index && write_tap_ref_index && dry_index < dry_kandelo_ref_index && dry_kandelo_ref_index < write_kandelo_ref_index && dry_kandelo_ref_index < write_tap_ref_index, - "publisher does not separate selectable dry-run refs from write-only main refs") + "publisher does not separate selectable dry-run refs from reviewed write refs") vfs_selection = named_step( plan_steps, "Validate dependency-bearing VFS acceptance selection" @@ -1472,6 +1508,27 @@ def check_publisher(workflow) check(flake.scan("pkgs.gnutar".b).length == 1, "dev shell does not declare exactly one GNU tar publisher input") bottle_builder = File.read(File.join(REPO_ROOT, "scripts/homebrew-bottle-build.sh")) + host_dependency_validator = File.read( + File.join(REPO_ROOT, "scripts/homebrew-validate-host-dependency-plan.sh") + ) + [ + 'keys == ["build", "build_and_test", "formula", "full_name", "native_requirements", "runtime_and_test", "schema", "tap", "target_taps"]', + '.schema == 4', + '(.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)', + '(.native_requirements == (.native_requirements | sort_by(.class)))', + '((.native_requirements | map(.class)) == (.native_requirements | map(.class) | unique))', + '(.tags == ["build"] or .tags == ["build", "test"])', + '($plan.build | index($native.formula) != null)', + '($plan.runtime_and_test | index($native.formula) == null)', + ].each do |fragment| + check(host_dependency_validator.include?(fragment), + "host dependency plan validator lacks #{fragment}") + end formula_support_inputs = File.read( File.join(REPO_ROOT, "scripts/homebrew-formula-support-inputs.sh") ) @@ -1511,6 +1568,9 @@ def check_publisher(workflow) 'JSON.generate(support_runtime_files)', 'support_copies.values.uniq.length > 1', 'Kandelo Formula support API or runtime-tree bytes differ across the immutable tap closure', + 'KANDELO_NATIVE_FORMULA', + 'KANDELO_NATIVE_SENTINEL', + '"native_requirements" => native_requirements.sort_by { |entry| entry.fetch("class") }', ].each do |fragment| check(formula_closure.include?(fragment), "static Formula closure lacks immutable tap identity binding: #{fragment}") @@ -1519,6 +1579,10 @@ def check_publisher(workflow) check(tier2_plan_output&.include?('"schema" => 2') && !tier2_plan_output&.include?('"schema" => 1'), "static Formula closure does not emit the exact Tier-2 schema-2 plan") + host_dependency_plan_output = formula_closure[/elsif host_dependencies_only(.*?)elsif direct_only/m, 1] + check(host_dependency_plan_output&.include?('"schema" => 4') && + host_dependency_plan_output&.include?('"native_requirements" => native_requirements'), + "static Formula closure does not emit the sealed schema-4 native Requirement plan") check(!formula_closure.include?("legacy_requires") && formula_closure.include?( "if runtime_initializer_index.nil? || runtime_assignment_index != runtime_initializer_index + 1" @@ -1562,13 +1626,10 @@ def check_publisher(workflow) 'KANDELO_HOMEBREW_BOTTLE_TAG="$BOTTLE_TAG"', 'run_brew_logged run_brew_for_kandelo_bottles "$BREW_BIN" install', "--include-build --include-test", + 'bash "$KANDELO_ROOT/scripts/homebrew-validate-host-dependency-plan.sh"', 'jq -r \'.build_and_test[]\' "$HOST_DEPENDENCY_PLAN" >"$HOST_DEPENDENCY_LIST"', - 'keys == ["build", "build_and_test", "formula", "full_name", "runtime_and_test", "schema", "tap", "target_taps"]', - '.schema == 3', 'TIER2_ATTESTATION="$CONTROL_DIR/tier2-attestation.json"', '.schema == 2 and .tap == $tap and .formula == $formula and .arch == $arch', - '--slurpfile resolved "$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE"', - 'map({tap_name, tap_repository, tap_commit}) | sort_by(.tap_name)', 'DEPENDENCY_TAP_ROOTS=()', 'export HOMEBREW_KANDELO_PRIMARY_TAP_ROOT="$TAPPED_TAP_ROOT"', '"$BREW_BIN" tap "$dependency_tap" "$dependency_root"', @@ -1749,8 +1810,7 @@ def check_publisher(workflow) 'EXPECTED_PLAN_TAP="$TAP_NAME"', '"$TAP_ROOT" "$TAP_NAME" "$FORMULA" --host-dependencies-json', 'immutable resolved tap map is required', - '--slurpfile resolved "$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE"', - 'map({tap_name, tap_repository, tap_commit}) | sort_by(.tap_name)', + 'bash "$KANDELO_ROOT/scripts/homebrew-validate-host-dependency-plan.sh"', '"homebrew/core/$dependency"', "run_native_brew_logged install --as-dependency --formula", 'homebrew_patched_launcher_run_native info --json=v2', @@ -2032,7 +2092,19 @@ def check_publisher(workflow) "plan = KandeloPublisher.dependency_plan(formula)", "@deps = publisher_build_dependencies if args.build_bottle?", "dependency.build? && !dependency.implicit?", + "def self.evaluated_native_requirements(formula, plan = dependency_plan(formula))", + 'NATIVE_FORMULA_CONSTANT = :KANDELO_NATIVE_FORMULA', + 'NATIVE_SENTINEL_CONSTANT = :KANDELO_NATIVE_SENTINEL', + 'Dependency.new(requirement.fetch("formula"), [:build])', + 'actual == expected', + 'plan["schema"] == 4', + "MAX_DEPENDENCIES = 128", + "value.length <= MAX_DEPENDENCIES", "direct_native_build_dependencies.sort_by(&:name)", + "def self.activate_native_test_requirements!(formula, env)", + "Kandelo publisher native test Requirement sentinel is unavailable", + "diff --git a/Library/Homebrew/test.rb b/Library/Homebrew/test.rb", + "KandeloPublisher.activate_native_test_requirements!(formula, ENV)", "diff --git a/Library/Homebrew/extend/os/linux/formula.rb b/Library/Homebrew/extend/os/linux/formula.rb", "return if KandeloPublisher.selected_tap_formula?(self)", "diff --git a/Library/Homebrew/extend/os/linux/sandbox.rb b/Library/Homebrew/extend/os/linux/sandbox.rb", @@ -2069,10 +2141,48 @@ def check_publisher(workflow) "protected publisher plan changed native Homebrew global dependencies", "mutable target tap revision suppressed Linux global dependencies", "mismatched target tap repository suppressed Linux global dependencies", + "publisher native Requirement inputs did not populate the build-only Superenv dependency path", + "publisher accepted a missing evaluated native Requirement", + "publisher accepted a forged evaluated native Requirement class", + "publisher accepted altered evaluated native Requirement metadata", + "publisher accepted ambiguous schema-3 native dependency data", + "publisher accepted oversized host dependency arrays", + "publisher test environment did not execute the sealed Requirement sentinel by name", + "ordinary Homebrew test environment changed without a protected publisher plan", ].each do |fragment| check(publisher_patch_test.include?(fragment), "publisher overlay regression test lacks #{fragment}") end + publisher_real_lifecycle_test = File.read( + File.join(REPO_ROOT, "scripts/test-homebrew-publisher-real-lifecycle.sh") + ) + [ + 'BREW_COMMIT="34c40c18ffa2029b611b61c73273e32c003d0842"', + 'EXPECTED_BUILD_BLOB="be833176c02f78cd5b3502aac968b5a733cb7af8"', + 'worktree add --detach "$BREW_ROOT" "$BREW_COMMIT"', + '0001-add-kandelo-wasm-bottle-tags.patch', + '0002-support-isolated-publisher.patch', + 'HOMEBREW_KANDELO_HERMETIC_LIFECYCLE_TEST', + 'PATH="$PATH"', + 'install-bundler-gems --groups=formula_test', + '(deny network*)', + '/usr/bin/unshare --user --map-current-user --net', + '/usr/bin/sudo -n /usr/bin/unshare --net', + 'the network-isolation boundary allowed a reachable socket', + 'the real publisher lifecycle changed the sealed Bundler vendor tree', + 'depends_on KandeloFormulaSupport::WabtRequirement => [:build, :test]', + 'KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$RESOLVED_TAPS"', + 'homebrew-formula-runtime-closure.rb', + 'homebrew-validate-host-dependency-plan.sh', + 'install --build-bottle', + '--ignore-dependencies kandelo-dev/tap-core/fixture', + 'test kandelo-dev/tap-core/fixture', + 'the real Build/Superenv lifecycle did not execute the native Requirement tool', + 'the real Formula test lifecycle did not execute the sealed native Requirement tool', + ].each do |fragment| + check(publisher_real_lifecycle_test.include?(fragment), + "real pinned Homebrew lifecycle test lacks #{fragment}") + end check(!platform_patch.include?("dir == HOMEBREW_REPOSITORY"), "guest Homebrew platform patch skips repository writability") check(!platform_patch.include?("trusted_tap?(tap)"), @@ -2106,6 +2216,7 @@ def check_publisher(workflow) 'HOST_DEPENDENCY_PLAN="$CONTROL_DIR/host-dependencies.json"', 'NATIVE_INSTALL_LOG="$CONTROL_DIR/native-install.log"', 'DEPENDENCY_POUR_LIST="$CONTROL_DIR/pour-dependencies.txt"', + 'bash "$KANDELO_ROOT/scripts/homebrew-validate-host-dependency-plan.sh"', "--include-test", 'validate_dependency_list "$DEPENDENCY_LIST"', '"$SAME_TAP_TEST_DEPENDENCY_LIST" "test dependency list"', @@ -4048,6 +4159,8 @@ def check_publisher(workflow) 'under-lock publisher accepted concurrent dependency-edge drift', 'Formula differs from the planned tap outside canonical bottle metadata', 'bash "$REPO_ROOT/scripts/test-install-local-binary-sealed.sh"', + 'bash "$REPO_ROOT/scripts/test-homebrew-publisher-real-lifecycle.sh"', + 'bash "$REPO_ROOT/scripts/test-homebrew-validate-host-dependency-plan.sh"', 'assert_atomic_publication_batch_closes_formula_metadata_wave', 'KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$(make_primary_resolved_tap_map "$tap_root")"', 'export KANDELO_HOMEBREW_RESOLVED_TAPS_FILE', diff --git a/scripts/ci-run-test-suite.sh b/scripts/ci-run-test-suite.sh index 172f2f314c..32f804dc10 100755 --- a/scripts/ci-run-test-suite.sh +++ b/scripts/ci-run-test-suite.sh @@ -118,7 +118,7 @@ case "$suite" in run_timed 10m "Run cross-browser contract smoke suite" \ npx playwright test \ test/coi.spec.ts \ - test/browser-kernel-lazy-registration.spec.ts \ + test/package-deferred-tree-browser.spec.ts \ test/wasm-trap-signal.spec.ts \ --project=chromium --project=firefox --project=webkit ) diff --git a/scripts/homebrew-bottle-build.sh b/scripts/homebrew-bottle-build.sh index 2d8d9f1468..650cb9bbaa 100755 --- a/scripts/homebrew-bottle-build.sh +++ b/scripts/homebrew-bottle-build.sh @@ -361,36 +361,9 @@ ruby "$KANDELO_ROOT/scripts/homebrew-formula-runtime-closure.rb" \ echo "homebrew-bottle-build.sh: host dependency plan exceeds the size limit" >&2 exit 2 } -jq -e --arg tap "$EXPECTED_PLAN_TAP" --arg formula "$FORMULA" \ - --slurpfile resolved "$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" ' - keys == ["build", "build_and_test", "formula", "full_name", "runtime_and_test", "schema", "tap", "target_taps"] and - .schema == 3 and - .tap == $tap and - .formula == $formula and - .full_name == ($tap + "/" + $formula) and - (.build | type == "array") and - (.build_and_test | type == "array") and - (.runtime_and_test | type == "array") and - (.build == (.build | sort | unique)) and - (.build_and_test == (.build_and_test | sort | unique)) and - (.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) - )) and - (.target_taps | all(.[]; - keys == ["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}$")) - )) and - (.target_taps | map(.tap_name) | index($tap) != null) and - ((.build - .build_and_test) | length) == 0 and - ((.runtime_and_test - .build_and_test) | length) == 0 and - all(.build[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and - all(.build_and_test[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and - all(.runtime_and_test[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) -' "$HOST_DEPENDENCY_PLAN" >/dev/null || { +bash "$KANDELO_ROOT/scripts/homebrew-validate-host-dependency-plan.sh" \ + "$HOST_DEPENDENCY_PLAN" "$EXPECTED_PLAN_TAP" "$FORMULA" \ + "$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" || { echo "homebrew-bottle-build.sh: invalid static host dependency plan" >&2 exit 2 } diff --git a/scripts/homebrew-formula-runtime-closure.rb b/scripts/homebrew-formula-runtime-closure.rb index ff9c0cb8be..ceedce667c 100755 --- a/scripts/homebrew-formula-runtime-closure.rb +++ b/scripts/homebrew-formula-runtime-closure.rb @@ -21,6 +21,7 @@ HOST_FORMULA_NAME = /\A[a-z0-9][a-z0-9@+_.-]*\z/ TAP_NAME = /\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/ DEPENDENCY_LINE = /\A depends_on "([^"]+)"(?: => (:[a-z]+|\[(?::[a-z]+)(?:, :[a-z]+)*\]))?\n\z/ +NATIVE_REQUIREMENT_LINE = /\A depends_on KandeloFormulaSupport::([A-Z][A-Za-z0-9]*Requirement) => (:[a-z]+|\[(?::[a-z]+)(?:, :[a-z]+)*\])\n\z/ ALLOWED_TAGS = Set[:build, :test, :optional, :recommended].freeze ALLOWED_CLASS_COMMANDS = Set[ "depends_on", "desc", "homepage", "include", "keg_only", "license", @@ -83,6 +84,24 @@ Set[:optional], Set[:build, :test], ].freeze +NATIVE_REQUIREMENTS = { + "BinaryenRequirement" => { + "executable" => "wasm-opt", + "formula" => "binaryen", + }, + "PkgconfRequirement" => { + "executable" => "pkg-config", + "formula" => "pkgconf", + }, + "WabtRequirement" => { + "executable" => "wasm-validate", + "formula" => "wabt", + }, +}.freeze +NATIVE_REQUIREMENT_TAG_SETS = Set[ + Set[:build], + Set[:build, :test], +].freeze tap_input, requested_tap_name, target, output_mode = ARGV abort "invalid tap name: #{requested_tap_name}" unless TAP_NAME.match?(requested_tap_name) @@ -703,12 +722,111 @@ " #{TIER2_RUNTIME_CONSTANT} = #{TIER2_RUNTIME_INITIALIZER_METHOD}\n" end +canonical_native_requirement = lambda do |statement, lines| + next nil unless statement.is_a?(Array) && statement.first == :class + + class_token = statement.dig(1, 1) + superclass = statement[2] + superclass_token = superclass[1] if superclass.is_a?(Array) && superclass.first == :var_ref + body = statement[3] + next nil unless class_token.is_a?(Array) && class_token.first == :@const && + superclass_token.is_a?(Array) && superclass_token.first == :@const && + superclass_token[1] == "Requirement" && + body.is_a?(Array) && body.first == :bodystmt && + body.drop(2).all?(&:nil?) && body[1].is_a?(Array) && + body[1].length == 4 + + class_name = class_token[1] + identity = NATIVE_REQUIREMENTS[class_name] + next nil if identity.nil? + + line_number = class_token.dig(2, 0) + expected_lines = [ + " class #{class_name} < Requirement\n", + %( KANDELO_NATIVE_FORMULA = "#{identity.fetch("formula")}"\n), + %( KANDELO_NATIVE_SENTINEL = "#{identity.fetch("executable")}"\n), + " fatal true\n", + %( satisfy(build_env: false) { which("#{identity.fetch("executable")}") }\n), + " end\n", + ] + next nil unless line_number.is_a?(Integer) && + lines.slice(line_number - 1, expected_lines.length) == expected_lines + + formula_statement, sentinel_statement, fatal_statement, satisfy_statement = body[1] + canonical_metadata_assignment = lambda do |assignment, constant_name, value| + left = assignment[1] if assignment.is_a?(Array) && assignment.first == :assign + constant = left[1] if left.is_a?(Array) && left.first == :var_field + constant.is_a?(Array) && constant.first == :@const && constant[1] == constant_name && + literal_string.call(assignment[2], value) + end + fatal_arguments = canonical_command_arguments.call(fatal_statement, "fatal") + fatal_value = fatal_arguments&.first + satisfy_call = satisfy_statement[1] if satisfy_statement.is_a?(Array) && + satisfy_statement.first == :method_add_block + satisfy_arguments = satisfy_call[2] if satisfy_call.is_a?(Array) && + satisfy_call.first == :method_add_arg && + satisfy_call.dig(1, 0) == :fcall && + satisfy_call.dig(1, 1, 0) == :@ident && + satisfy_call.dig(1, 1, 1) == "satisfy" + satisfy_argument_list = satisfy_arguments[1] if satisfy_arguments.is_a?(Array) && + satisfy_arguments.first == :arg_paren + satisfy_hash = satisfy_argument_list[1]&.first if satisfy_argument_list.is_a?(Array) && + satisfy_argument_list.first == :args_add_block && + satisfy_argument_list[1].is_a?(Array) && + satisfy_argument_list[1].length == 1 && + satisfy_argument_list[2] == false + satisfy_assoc = satisfy_hash[1]&.first if satisfy_hash.is_a?(Array) && + satisfy_hash.first == :bare_assoc_hash && + satisfy_hash[1].is_a?(Array) && + satisfy_hash[1].length == 1 + satisfy_block = satisfy_statement[2] if satisfy_statement.is_a?(Array) + which_call = satisfy_block[2]&.first if satisfy_block.is_a?(Array) && + satisfy_block.first == :brace_block && + satisfy_block[1].nil? && + satisfy_block[2].is_a?(Array) && + satisfy_block[2].length == 1 + which_arguments = which_call[2] if which_call.is_a?(Array) && + which_call.first == :method_add_arg && + which_call.dig(1, 0) == :fcall && + which_call.dig(1, 1, 0) == :@ident && + which_call.dig(1, 1, 1) == "which" + which_argument_list = which_arguments[1] if which_arguments.is_a?(Array) && + which_arguments.first == :arg_paren + which_literal = which_argument_list[1]&.first if which_argument_list.is_a?(Array) && + which_argument_list.first == :args_add_block && + which_argument_list[1].is_a?(Array) && + which_argument_list[1].length == 1 && + which_argument_list[2] == false + next nil unless canonical_metadata_assignment.call( + formula_statement, + "KANDELO_NATIVE_FORMULA", + identity.fetch("formula"), + ) && + canonical_metadata_assignment.call( + sentinel_statement, + "KANDELO_NATIVE_SENTINEL", + identity.fetch("executable"), + ) && + fatal_arguments&.length == 1 && fatal_value&.first == :var_ref && + fatal_value.dig(1, 0) == :@kw && fatal_value.dig(1, 1) == "true" && + satisfy_assoc.is_a?(Array) && satisfy_assoc.first == :assoc_new && + satisfy_assoc.dig(1, 0) == :@label && + satisfy_assoc.dig(1, 1) == "build_env:" && + satisfy_assoc.dig(2, 0) == :var_ref && + satisfy_assoc.dig(2, 1, 0) == :@kw && + satisfy_assoc.dig(2, 1, 1) == "false" && + literal_string.call(which_literal, identity.fetch("executable")) + + [class_name, superclass] +end + support_validated = Set.new support_methods_by_tap = {} support_sha256_by_tap = {} support_runtime_sha256_by_tap = {} support_api_version_by_tap = {} support_tier2_package_keyword_by_tap = {} +support_native_requirements_by_tap = {} validate_support = lambda do |context| context_tap_name = context.fetch("tap_name") next if support_validated.include?(context_tap_name) @@ -835,6 +953,7 @@ runtime_assignment_index = nil support_api_version = nil tier2_package_keyword = false + native_requirements = Set.new module_body.each_with_index do |statement, statement_index| next if statement.is_a?(Array) && statement.first == :void_stmt @@ -993,6 +1112,24 @@ "#{support_path}" end end + when :class + native_requirement = canonical_native_requirement.call(statement, support_lines) + if native_requirement.nil? + abort "Kandelo Formula support contains an unsupported native Requirement class: #{support_path}" + end + class_name, allowed_superclass = native_requirement + unless native_requirements.add?(class_name) + abort "Kandelo Formula support repeats native Requirement #{class_name}: #{support_path}" + end + forbidden = find_forbidden_support_token.call( + statement, + Set[allowed_superclass.object_id], + ) + unless forbidden.nil? + token, position = forbidden + abort "Kandelo Formula support native Requirement uses forbidden operation " \ + "#{token.inspect} at #{support_path}:#{position.first}" + end when :assign left = statement[1] constant = left.dig(1) if left.is_a?(Array) && left.first == :var_field @@ -1038,6 +1175,7 @@ ) support_api_version_by_tap[context_tap_name] = support_api_version support_tier2_package_keyword_by_tap[context_tap_name] = tier2_package_keyword + support_native_requirements_by_tap[context_tap_name] = native_requirements.freeze support_validated.add(context_tap_name) end @@ -1154,7 +1292,12 @@ when :command method = call_name.call(statement) abort "Formula class uses unsupported DSL call #{method.inspect}: #{path}" unless ALLOWED_CLASS_COMMANDS.include?(method) - abort "Formula class DSL arguments must be static: #{path}" unless static_expression.call(statement[2]) + # Every depends_on call is independently matched against one canonical + # literal Formula or allowlisted Requirement line below. Do not make the + # generic static-expression walker understand arbitrary constant paths. + unless method == "depends_on" || static_expression.call(statement[2]) + abort "Formula class DSL arguments must be static: #{path}" + end if method == "include" line_number = statement.dig(1, 2, 0) unless line_number.is_a?(Integer) && lines.fetch(line_number - 1) == " include KandeloFormulaSupport\n" @@ -1380,18 +1523,57 @@ declarations = direct_positions.map do |line_number, _column| line = lines.fetch(line_number - 1) - match = DEPENDENCY_LINE.match(line) - abort "depends_on must use canonical literal syntax at #{path}:#{line_number}" if match.nil? - [line_number, match[1], parse_tags.call(match[2], path, line_number)] + dependency_match = DEPENDENCY_LINE.match(line) + requirement_match = NATIVE_REQUIREMENT_LINE.match(line) + if !dependency_match.nil? + { + "line" => line_number, + "name" => dependency_match[1], + "requirement_class" => nil, + "tags" => parse_tags.call(dependency_match[2], path, line_number), + } + elsif !requirement_match.nil? + class_name = requirement_match[1] + identity = NATIVE_REQUIREMENTS[class_name] + if identity.nil? + abort "depends_on uses unknown native Requirement #{class_name}:#{path}:#{line_number}" + end + tags = parse_tags.call(requirement_match[2], path, line_number) + unless NATIVE_REQUIREMENT_TAG_SETS.include?(tags) + abort "native Requirement must include :build and may also include :test at " \ + "#{path}:#{line_number}" + end + unless seen_requires.include?(support_require_line) + abort "native Requirement requires the canonical tap-local Formula support require: " \ + "#{path}:#{line_number}" + end + validate_support.call(context) + unless support_native_requirements_by_tap.fetch(formula_tap_name).include?(class_name) + abort "native Requirement #{class_name} is not canonically defined by Formula support: " \ + "#{path}:#{line_number}" + end + { + "line" => line_number, + "name" => identity.fetch("formula"), + "requirement_class" => "KandeloFormulaSupport::#{class_name}", + "tags" => tags, + } + else + abort "depends_on must use canonical literal Formula or native Requirement syntax at " \ + "#{path}:#{line_number}" + end end - line_positions = declarations.map { |line_number, _dependency, _tags| [line_number, 2] }.sort + line_positions = declarations.map { |declaration| [declaration.fetch("line"), 2] }.sort unless line_positions == direct_positions abort "depends_on syntax does not match the parsed direct calls: #{path}" end seen = Set.new runtime_declarations = [] - dependencies = declarations.each_with_object([]) do |(line_number, dependency, tags), selected| + dependencies = declarations.each_with_object([]) do |declaration, selected| + line_number = declaration.fetch("line") + dependency = declaration.fetch("name") + tags = declaration.fetch("tags") abort "duplicate dependency #{dependency.inspect} at #{path}:#{line_number}" unless seen.add?(dependency) next if [Set[:build], Set[:test], Set[:build, :test]].include?(tags) @@ -1444,8 +1626,12 @@ end formula_bottles[full_name] = bottle formula_runtime_declarations[full_name] = runtime_declarations - formula_dependency_declarations[full_name] = declarations.map do |_line_number, dependency, tags| - {"name" => dependency, "tags" => tags} + formula_dependency_declarations[full_name] = declarations.map do |declaration| + { + "name" => declaration.fetch("name"), + "requirement_class" => declaration.fetch("requirement_class"), + "tags" => declaration.fetch("tags"), + } end formula_tier2_bridges[full_name] = { "formula_sha256" => Digest::SHA256.hexdigest(source), @@ -1569,6 +1755,7 @@ build = Set.new build_and_test = Set.new runtime_and_test = Set.new + native_requirements = [] prefix = "#{tap_name}/" formula_dependency_declarations.fetch(target_full_name).each do |declaration| dependency = declaration.fetch("name") @@ -1604,6 +1791,17 @@ build.add(dependency) if tags.include?(:build) build_and_test.add(dependency) runtime_and_test.add(dependency) unless tags == Set[:build] + requirement_class = declaration.fetch("requirement_class") + unless requirement_class.nil? + short_class = requirement_class.delete_prefix("KandeloFormulaSupport::") + identity = NATIVE_REQUIREMENTS.fetch(short_class) + native_requirements << { + "class" => requirement_class, + "formula" => identity.fetch("formula"), + "sentinel" => identity.fetch("executable"), + "tags" => tags.to_a.map(&:to_s).sort, + } + end if build_and_test.length > MAX_DEPENDENCIES abort "host Formula dependency plan exceeds #{MAX_DEPENDENCIES} entries" end @@ -1620,13 +1818,14 @@ } end puts JSON.generate({ - "schema" => 3, + "schema" => 4, "tap" => tap_name, "formula" => target, "full_name" => "#{tap_name}/#{target}", "target_taps" => immutable_target_taps, "build" => build.sort, "build_and_test" => build_and_test.sort, + "native_requirements" => native_requirements.sort_by { |entry| entry.fetch("class") }, "runtime_and_test" => runtime_and_test.sort, }) elsif direct_only diff --git a/scripts/homebrew-validate-host-dependency-plan.sh b/scripts/homebrew-validate-host-dependency-plan.sh new file mode 100755 index 0000000000..21aafb16b5 --- /dev/null +++ b/scripts/homebrew-validate-host-dependency-plan.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 4 ]; then + echo "usage: homebrew-validate-host-dependency-plan.sh PLAN TAP FORMULA RESOLVED_TAPS" >&2 + exit 2 +fi + +PLAN="$1" +EXPECTED_TAP="$2" +FORMULA="$3" +RESOLVED_TAPS="$4" + +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 + .tap == $tap and + .formula == $formula and + .full_name == ($tap + "/" + $formula) and + (.build | type == "array" and length <= 128) and + (.build_and_test | type == "array" and length <= 128) and + (.native_requirements | type == "array" and length <= 128) and + (.runtime_and_test | type == "array" and length <= 128) and + (.build == (.build | sort | unique)) and + (.build_and_test == (.build_and_test | sort | unique)) and + (.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) + )) and + (.target_taps | all(.[]; + keys == ["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}$")) + )) and + (.target_taps | map(.tap_name) | index($tap) != null) and + ((.build - .build_and_test) | length) == 0 and + ((.runtime_and_test - .build_and_test) | length) == 0 and + all(.build[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and + all(.build_and_test[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and + all(.runtime_and_test[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and + (.native_requirements == (.native_requirements | sort_by(.class))) and + ((.native_requirements | map(.class)) == (.native_requirements | map(.class) | unique)) and + ((.native_requirements | map(.formula) | length) == + (.native_requirements | map(.formula) | unique | length)) and + all(.native_requirements[]; + . as $native | + keys == ["class", "formula", "sentinel", "tags"] and + (.class | type == "string" and test("^KandeloFormulaSupport::[A-Z][A-Za-z0-9]*Requirement$")) and + (.formula | type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and + (.sentinel | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._+-]*$")) and + (.tags == ["build"] or .tags == ["build", "test"]) and + ($plan.build | index($native.formula) != null) and + ($plan.build_and_test | index($native.formula) != null) and + (if $native.tags == ["build", "test"] then + ($plan.runtime_and_test | index($native.formula) != null) + else + ($plan.runtime_and_test | index($native.formula) == null) + end) + ) +' "$PLAN" >/dev/null diff --git a/scripts/homebrew-verify-poured-bottle.sh b/scripts/homebrew-verify-poured-bottle.sh index 12f9e2f392..d94aa2f6e9 100755 --- a/scripts/homebrew-verify-poured-bottle.sh +++ b/scripts/homebrew-verify-poured-bottle.sh @@ -370,36 +370,9 @@ ruby "$KANDELO_ROOT/scripts/homebrew-formula-runtime-closure.rb" \ echo "homebrew-verify-poured-bottle.sh: host dependency plan exceeds the size limit" >&2 exit 2 } -jq -e --arg tap "$EXPECTED_PLAN_TAP" --arg formula "$FORMULA" \ - --slurpfile resolved "$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" ' - keys == ["build", "build_and_test", "formula", "full_name", "runtime_and_test", "schema", "tap", "target_taps"] and - .schema == 3 and - .tap == $tap and - .formula == $formula and - .full_name == ($tap + "/" + $formula) and - (.build | type == "array") and - (.build_and_test | type == "array") and - (.runtime_and_test | type == "array") and - (.build == (.build | sort | unique)) and - (.build_and_test == (.build_and_test | sort | unique)) and - (.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) - )) and - (.target_taps | all(.[]; - keys == ["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}$")) - )) and - (.target_taps | map(.tap_name) | index($tap) != null) and - ((.build - .build_and_test) | length) == 0 and - ((.runtime_and_test - .build_and_test) | length) == 0 and - all(.build[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and - all(.build_and_test[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) and - all(.runtime_and_test[]; type == "string" and test("^[a-z0-9][a-z0-9@+_.-]*$")) -' "$HOST_DEPENDENCY_PLAN" >/dev/null || { +bash "$KANDELO_ROOT/scripts/homebrew-validate-host-dependency-plan.sh" \ + "$HOST_DEPENDENCY_PLAN" "$EXPECTED_PLAN_TAP" "$FORMULA" \ + "$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" || { echo "homebrew-verify-poured-bottle.sh: invalid static host dependency plan" >&2 exit 2 } diff --git a/scripts/install-local-binary.sh b/scripts/install-local-binary.sh index f8bcbc0a6e..60f7e961c0 100755 --- a/scripts/install-local-binary.sh +++ b/scripts/install-local-binary.sh @@ -464,7 +464,10 @@ install_local_binary() { case "$declared_policy" in auto) if wasm_imports_kernel_fork "$src" && ! wasm_has_complete_fork_instrumentation "$src"; then - if wasm_has_any_wpk_fork_export "$src"; then + # WHY reject every partial ABI marker before reinstrumenting: + # adding a second copy can turn one stale descriptor or frame + # hook into an artifact that instantiates but corrupts replay. + if wasm_has_any_fork_instrumentation "$src"; then wasm_require_fork_instrumentation_if_needed "$src" return 1 fi diff --git a/scripts/prepare-homebrew-bootstrap-source.sh b/scripts/prepare-homebrew-bootstrap-source.sh index 88fe0d7429..1a480813e4 100755 --- a/scripts/prepare-homebrew-bootstrap-source.sh +++ b/scripts/prepare-homebrew-bootstrap-source.sh @@ -3,6 +3,7 @@ set -euo pipefail REPOSITORY="" REVISION="" +SOURCE_CHECKOUT="" PATCH_FILE="" EXPECTED_PATCH_SHA256="" ARCH="" @@ -21,6 +22,7 @@ patch to a temporary Git index, and write deterministic bootstrap inputs. Options: --repository upstream Homebrew Git repository --revision exact 40-character upstream commit + --source-checkout optional exact resolver-owned checkout --patch Kandelo Homebrew patch --expected-patch-sha256 reviewed patch digest --arch guest Homebrew userland architecture @@ -38,6 +40,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --repository) REPOSITORY="${2:-}"; shift 2 ;; --revision) REVISION="${2:-}"; shift 2 ;; + --source-checkout) SOURCE_CHECKOUT="${2:-}"; shift 2 ;; --patch) PATCH_FILE="${2:-}"; shift 2 ;; --expected-patch-sha256) EXPECTED_PATCH_SHA256="${2:-}"; shift 2 ;; --arch) ARCH="${2:-}"; shift 2 ;; @@ -93,70 +96,204 @@ if [ ! -f "$PATCH_FILE" ]; then exit 2 fi -for tool in git node sha256sum; do +for tool in git node; do command -v "$tool" >/dev/null 2>&1 || { echo "prepare-homebrew-bootstrap-source: $tool not found; run through scripts/dev-shell.sh" >&2 exit 2 } done +# Git is a source parser here, not an ambient developer tool. Remove repository +# redirection, injected `-c` entries, executable lookup, tracing, templates, +# hooks, credential helpers, and worktree state before inspecting either the +# sealed checkout or our object store. Clearing every caller-provided `GIT_*` +# variable also fails closed for variables introduced by future Git versions. +# Exact command-line overrides below neutralize repository-local hook, +# fsmonitor, attribute, exclude, and credential configuration. +REQUESTED_GIT_DIR="$GIT_DIR" +while IFS= read -r git_variable; do + case "$git_variable" in + GIT_*) + unset "$git_variable" + ;; + esac +done < <(compgen -A variable) +unset SSH_ASKPASS +unset GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ + HOMEBREW_GITHUB_PACKAGES_TOKEN HOMEBREW_DOCKER_REGISTRY_TOKEN +GIT_DIR="$REQUESTED_GIT_DIR" +export GIT_ATTR_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_NOSYSTEM=1 +export GIT_OPTIONAL_LOCKS=0 +export GIT_PAGER=cat +export GIT_TERMINAL_PROMPT=0 + +sha256_file() { + node --input-type=module -e ' +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +process.stdout.write(createHash("sha256").update(readFileSync(process.argv[1])).digest("hex")); +' "$1" +} + +sha256_stdin() { + node --input-type=module -e ' +import { createHash } from "node:crypto"; +const hash = createHash("sha256"); +for await (const chunk of process.stdin) hash.update(chunk); +process.stdout.write(hash.digest("hex")); +' +} + PATCH_FILE="$(cd "$(dirname "$PATCH_FILE")" && pwd)/$(basename "$PATCH_FILE")" GIT_DIR="$(mkdir -p "$(dirname "$GIT_DIR")" && cd "$(dirname "$GIT_DIR")" && pwd)/$(basename "$GIT_DIR")" for output in "$ARCHIVE" "$ENV_FILE" "$PROVENANCE"; do mkdir -p "$(dirname "$output")" done -ACTUAL_PATCH_SHA256="$(sha256sum "$PATCH_FILE" | awk '{print $1}')" +GIT_ISOLATION_ROOT="$( + mktemp -d "$(dirname "$GIT_DIR")/.kandelo-homebrew-git.XXXXXX" +)" +GIT_HOOKS_DIR="$GIT_ISOLATION_ROOT/hooks" +GIT_TEMPLATE_DIR_PRIVATE="$GIT_ISOLATION_ROOT/template" +mkdir -m 0700 "$GIT_HOOKS_DIR" "$GIT_TEMPLATE_DIR_PRIVATE" +INDEX_TMP="$GIT_ISOLATION_ROOT/index" +ARCHIVE_TMP="$ARCHIVE.tmp.$$" +ENV_TMP="$ENV_FILE.tmp.$$" +PROVENANCE_TMP="$PROVENANCE.tmp.$$" +cleanup() { + rm -f -- "$INDEX_TMP" "$ARCHIVE_TMP" "$ENV_TMP" "$PROVENANCE_TMP" + rm -rf -- "$GIT_ISOLATION_ROOT" +} +trap cleanup EXIT + +GIT_ISOLATION_ARGS=( + -c "core.hooksPath=$GIT_HOOKS_DIR" + -c core.fsmonitor=false + -c core.untrackedCache=false + -c core.attributesFile=/dev/null + -c core.excludesFile=/dev/null + -c credential.helper= + -c credential.interactive=false + -c http.extraHeader= +) +isolated_git() { + command git "${GIT_ISOLATION_ARGS[@]}" "$@" +} + +verify_local_git_config() { + local label="$1" + shift + local config_keys + local key + if ! config_keys="$( + isolated_git "$@" config --local --no-includes --name-only --list + )"; then + echo "prepare-homebrew-bootstrap-source: cannot inspect $label Git configuration" >&2 + exit 2 + fi + if [ -n "$config_keys" ]; then + while IFS= read -r key; do + case "$key" in + core.repositoryformatversion|core.filemode|core.bare|\ + core.logallrefupdates|core.ignorecase|core.precomposeunicode|\ + remote.origin.url|remote.origin.fetch) + ;; + *) + echo "prepare-homebrew-bootstrap-source: $label has unsupported local Git configuration: $key" >&2 + exit 2 + ;; + esac + done <<<"$config_keys" + fi +} + +if [ -n "$SOURCE_CHECKOUT" ]; then + if [ ! -d "$SOURCE_CHECKOUT" ] || [ -L "$SOURCE_CHECKOUT" ]; then + echo "prepare-homebrew-bootstrap-source: --source-checkout is not a real Git worktree: $SOURCE_CHECKOUT" >&2 + exit 2 + fi + SOURCE_CHECKOUT="$(cd "$SOURCE_CHECKOUT" && pwd -P)" + if [ "$(isolated_git -C "$SOURCE_CHECKOUT" rev-parse --is-inside-work-tree 2>/dev/null || true)" != "true" ]; then + echo "prepare-homebrew-bootstrap-source: --source-checkout is not a Git worktree: $SOURCE_CHECKOUT" >&2 + exit 2 + fi + verify_local_git_config "source checkout" -C "$SOURCE_CHECKOUT" + SOURCE_REVISION="$(isolated_git -C "$SOURCE_CHECKOUT" rev-parse 'HEAD^{commit}')" + if [ "$SOURCE_REVISION" != "$REVISION" ]; then + echo "prepare-homebrew-bootstrap-source: source checkout HEAD $SOURCE_REVISION does not match $REVISION" >&2 + exit 1 + fi + SOURCE_STATUS="$( + isolated_git -C "$SOURCE_CHECKOUT" status \ + --porcelain=v1 --untracked-files=all --ignored=matching + )" + if [ -n "$SOURCE_STATUS" ]; then + echo "prepare-homebrew-bootstrap-source: source checkout is dirty" >&2 + printf '%s\n' "$SOURCE_STATUS" >&2 + exit 1 + fi +fi + +ACTUAL_PATCH_SHA256="$(sha256_file "$PATCH_FILE")" if [ "$ACTUAL_PATCH_SHA256" != "$EXPECTED_PATCH_SHA256" ]; then echo "prepare-homebrew-bootstrap-source: patch sha256 $ACTUAL_PATCH_SHA256 does not match reviewed $EXPECTED_PATCH_SHA256" >&2 exit 1 fi if [ ! -d "$GIT_DIR" ]; then - git init --bare -q "$GIT_DIR" + isolated_git init --bare -q --template="$GIT_TEMPLATE_DIR_PRIVATE" "$GIT_DIR" fi -if ! IS_BARE_REPOSITORY="$(git --git-dir="$GIT_DIR" rev-parse --is-bare-repository 2>/dev/null)"; then +verify_local_git_config "bare object store" --git-dir="$GIT_DIR" +if ! IS_BARE_REPOSITORY="$(isolated_git --git-dir="$GIT_DIR" rev-parse --is-bare-repository 2>/dev/null)"; then IS_BARE_REPOSITORY="" fi if [ "$IS_BARE_REPOSITORY" != "true" ]; then echo "prepare-homebrew-bootstrap-source: --git-dir is not a bare Git repository: $GIT_DIR" >&2 exit 2 fi -if git --git-dir="$GIT_DIR" remote get-url origin >/dev/null 2>&1; then - git --git-dir="$GIT_DIR" remote set-url origin "$REPOSITORY" + +git_store() { + command git "${GIT_ISOLATION_ARGS[@]}" --git-dir="$GIT_DIR" "$@" +} + +if [ -z "$SOURCE_CHECKOUT" ]; then + if git_store remote get-url origin >/dev/null 2>&1; then + git_store remote set-url origin "$REPOSITORY" + else + git_store remote add origin "$REPOSITORY" + fi + echo "==> Fetching Homebrew $REVISION" + git_store fetch -q --no-tags --depth=1 origin "$REVISION" + RESOLVED_REVISION="$(git_store rev-parse 'FETCH_HEAD^{commit}')" else - git --git-dir="$GIT_DIR" remote add origin "$REPOSITORY" + # Package builds import the exact revision from the resolver's sealed local + # checkout without reaching the network or mutating that checkout. Before + # this local upload-pack runs, global/system/injected configuration is + # disabled and every source-local key outside the inert structural + # allowlist above is rejected, including hooks, fsmonitor, credentials, + # upload-pack hooks, URL rewrites, and aliases. + echo "==> Importing Homebrew $REVISION from exact source checkout" + git_store fetch -q --no-tags --depth=1 "$SOURCE_CHECKOUT" "$REVISION" + RESOLVED_REVISION="$(git_store rev-parse 'FETCH_HEAD^{commit}')" fi -echo "==> Fetching Homebrew $REVISION" -git --git-dir="$GIT_DIR" fetch -q --depth=1 origin "$REVISION" -RESOLVED_REVISION="$(git --git-dir="$GIT_DIR" rev-parse 'FETCH_HEAD^{commit}')" if [ "$RESOLVED_REVISION" != "$REVISION" ]; then - echo "prepare-homebrew-bootstrap-source: fetched $RESOLVED_REVISION, expected $REVISION" >&2 + echo "prepare-homebrew-bootstrap-source: resolved $RESOLVED_REVISION, expected $REVISION" >&2 exit 1 fi -INDEX_TMP="$GIT_DIR/kandelo-bootstrap-index.$$" -ARCHIVE_TMP="$ARCHIVE.tmp.$$" -ENV_TMP="$ENV_FILE.tmp.$$" -PROVENANCE_TMP="$PROVENANCE.tmp.$$" -cleanup() { - rm -f "$INDEX_TMP" "$ARCHIVE_TMP" "$ENV_TMP" "$PROVENANCE_TMP" -} -trap cleanup EXIT - -GIT_INDEX_FILE="$INDEX_TMP" git --git-dir="$GIT_DIR" read-tree "$REVISION" -if ! GIT_INDEX_FILE="$INDEX_TMP" git --git-dir="$GIT_DIR" \ - apply --cached --check --whitespace=nowarn "$PATCH_FILE"; then +export GIT_INDEX_FILE="$INDEX_TMP" +git_store read-tree "$REVISION" +if ! git_store apply --cached --check --whitespace=nowarn "$PATCH_FILE"; then echo "prepare-homebrew-bootstrap-source: Kandelo patch does not apply to pinned Homebrew $REVISION" >&2 exit 1 fi -GIT_INDEX_FILE="$INDEX_TMP" git --git-dir="$GIT_DIR" \ - apply --cached --whitespace=nowarn "$PATCH_FILE" +git_store apply --cached --whitespace=nowarn "$PATCH_FILE" mapfile -t CHANGED_PATHS < <( - GIT_INDEX_FILE="$INDEX_TMP" git --git-dir="$GIT_DIR" \ - diff --cached --name-only "$REVISION" -- | LC_ALL=C sort + git_store diff --cached --name-only "$REVISION" -- | LC_ALL=C sort ) EXPECTED_PATHS=( "Library/Homebrew/extend/os/mac/utils/bottles.rb" @@ -171,14 +308,15 @@ if [ "${CHANGED_PATHS[*]}" != "${EXPECTED_PATHS[*]}" ]; then exit 1 fi -UPSTREAM_TREE="$(git --git-dir="$GIT_DIR" rev-parse "$REVISION^{tree}")" -PATCHED_TREE="$(GIT_INDEX_FILE="$INDEX_TMP" git --git-dir="$GIT_DIR" write-tree)" +UPSTREAM_TREE="$(git_store rev-parse "$REVISION^{tree}")" +PATCHED_TREE="$(git_store write-tree)" +unset GIT_INDEX_FILE if [ "$PATCHED_TREE" = "$UPSTREAM_TREE" ]; then echo "prepare-homebrew-bootstrap-source: patch produced the unmodified upstream tree" >&2 exit 1 fi -UPSTREAM_COMMIT_TIME="$(git --git-dir="$GIT_DIR" show -s --format=%ct "$REVISION")" +UPSTREAM_COMMIT_TIME="$(git_store show -s --format=%ct "$REVISION")" if ! [[ "$UPSTREAM_COMMIT_TIME" =~ ^[1-9][0-9]*$ ]]; then echo "prepare-homebrew-bootstrap-source: upstream commit has an invalid timestamp" >&2 exit 1 @@ -187,11 +325,11 @@ fi # A fixed mtime makes both serializations reproducible. The normalized tar # digest is a second provenance identity for the patched Git tree used by the ZIP. PATCHED_TREE_SHA256="$({ - TZ=UTC git --git-dir="$GIT_DIR" archive --format=tar --mtime="@$UPSTREAM_COMMIT_TIME" "$PATCHED_TREE" -} | sha256sum | awk '{print $1}')" -TZ=UTC git --git-dir="$GIT_DIR" archive --format=zip --mtime="@$UPSTREAM_COMMIT_TIME" \ + TZ=UTC git_store archive --format=tar --mtime="@$UPSTREAM_COMMIT_TIME" "$PATCHED_TREE" +} | sha256_stdin)" +TZ=UTC git_store archive --format=zip --mtime="@$UPSTREAM_COMMIT_TIME" \ -o "$ARCHIVE_TMP" "$PATCHED_TREE" -ARCHIVE_SHA256="$(sha256sum "$ARCHIVE_TMP" | awk '{print $1}')" +ARCHIVE_SHA256="$(sha256_file "$ARCHIVE_TMP")" BOTTLE_TAG="${ARCH}_kandelo" cat >"$ENV_TMP" <()=>{if(t)throw t[0];try{return r&&(e=r(r=0)),e}catch(n){throw t=[n],n}};var mr=(r,e)=>{for(var t in e)Zi(r,t,{get:e[t],enumerable:!0})};import{createRequire as Ds}from"module";function Qr(r,e){return Jr(r,{i:2},e&&e.out,e&&e.dictionary)}var Us,ot,Gs,Ks,J,it,Zs,Wr,Hr,Ws,Vr,ot,qr,Hs,jr,Vs,Ga,$n,ze,M,At,Lt,M,M,M,M,Xr,M,qs,js,Nn,ye,Mn,Yr,jt,Xs,le,Jr,Ys,Js,st,ei,Qs,eo,Fn=pn(()=>{Us=Ds("/");try{ot=Us("worker_threads"),Gs=ot.Worker,Ks=ot.isMarkedAsUntransferable}catch{}J=Uint8Array,it=Uint16Array,Zs=Int32Array,Wr=new J([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]),Hr=new J([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]),Ws=new J([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),Vr=function(r,e){for(var t=new it(31),n=0;n<31;++n)t[n]=e+=1<>1|(M&21845)<<1,ze=(ze&52428)>>2|(ze&13107)<<2,ze=(ze&61680)>>4|(ze&3855)<<4,$n[M]=((ze&65280)>>8|(ze&255)<<8)>>1;At=(function(r,e,t){for(var n=r.length,i=0,o=new it(e);i>c]=l}else for(a=new it(n),i=0;i>15-r[i]);return a}),Lt=new J(288);for(M=0;M<144;++M)Lt[M]=8;for(M=144;M<256;++M)Lt[M]=9;for(M=256;M<280;++M)Lt[M]=7;for(M=280;M<288;++M)Lt[M]=8;Xr=new J(32);for(M=0;M<32;++M)Xr[M]=5;qs=At(Lt,9,1),js=At(Xr,5,1),Nn=function(r){for(var e=r[0],t=1;te&&(e=r[t]);return e},ye=function(r,e,t){var n=e/8|0;return(r[n]|r[n+1]<<8)>>(e&7)&t},Mn=function(r,e){var t=e/8|0;return(r[t]|r[t+1]<<8|r[t+2]<<16)>>(e&7)},Yr=function(r){return(r+7)/8|0},jt=function(r,e,t){return(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length),new J(r.subarray(e,t))},Xs=["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"],le=function(r,e,t){var n=new Error(e||Xs[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,le),!t)throw n;return n},Jr=function(r,e,t,n){var i=r.length,o=n?n.length:0;if(!i||e.f&&!e.l)return t||new J(0);var s=!t,a=s||e.i!=2,c=e.i;s&&(t=new J(i*3));var l=function(Ae){var Le=t.length;if(Ae>Le){var Mt=new J(Math.max(Le*2,Ae));Mt.set(t),t=Mt}},u=e.f||0,f=e.p||0,h=e.b||0,y=e.l,g=e.d,d=e.m,m=e.n,p=i*8;do{if(!y){u=ye(r,f,1);var w=ye(r,f+1,3);if(f+=3,w)if(w==1)y=qs,g=js,d=9,m=5;else if(w==2){var E=ye(r,f,31)+257,k=ye(r,f+10,15)+4,x=E+ye(r,f+5,31)+1;f+=14;for(var I=new J(x),B=new J(19),C=0;C>4;if(v<16)I[C++]=v;else{var L=0,W=0;for(v==16?(W=3+ye(r,f,3),f+=2,L=I[C-1]):v==17?(W=3+ye(r,f,7),f+=3):v==18&&(W=11+ye(r,f,127),f+=7);W--;)I[C++]=L}}var Ce=I.subarray(0,E),te=I.subarray(E);d=Nn(Ce),m=Nn(te),y=At(Ce,d,1),g=At(te,m,1)}else le(1);else{var v=Yr(f)+4,S=r[v-4]|r[v-3]<<8,b=v+S;if(b>i){c&&le(0);break}a&&l(h+S),t.set(r.subarray(v,b),h),e.b=h+=S,e.p=f=b*8,e.f=u;continue}if(f>p){c&&le(0);break}}a&&l(h+131072);for(var ut=(1<>4;if(f+=L&15,f>p){c&&le(0);break}if(L||le(2),ve<256)t[h++]=ve;else if(ve==256){Ne=f,y=null;break}else{var ht=ve-254;if(ve>264){var C=ve-257,xe=Wr[C];ht=ye(r,f,(1<>4;He||le(3),f+=He&15;var te=Vs[ue];if(ue>3){var xe=Hr[ue];te+=Mn(r,f)&(1<p){c&&le(0);break}a&&l(h+131072);var Ie=h+ht;if(h>3&1)+(e>>4&1);n>0;n-=!r[t++]);return t+(e&2)},st=(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 J(32768),this.p=new J(0),n&&this.o.set(n)}return r.prototype.e=function(e){if(this.ondata||le(5),this.d&&le(4),!this.p.length)this.p=e;else if(e.length){var t=new J(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=Jr(this.p,this.s,this.o);this.ondata(jt(n,t,this.s.b),this.d),this.o=jt(n,this.s.b-32768),this.s.b=this.o.length,this.p=jt(this.p,this.s.p/8|0),this.s.p&=7},r.prototype.push=function(e,t){this.e(e),this.c(t)},r})();ei=(function(){function r(e,t){this.v=1,this.r=0,st.call(this,e,t)}return r.prototype.push=function(e,t){if(st.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}st.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=Yr(this.s.p)+9,this.s={i:0},this.o=new J(0),this.push(new J(0),t)):t&&st.prototype.c.call(this,t)},r})(),Qs=typeof TextDecoder<"u"&&new TextDecoder,eo=0;try{Qs.decode(Ys,{stream:!0}),eo=1}catch{}});var Gn={};mr(Gn,{extractZipEntry:()=>co,extractZipEntryBounded:()=>lo,fetchZipCentralDirectory:()=>uo,parseZipCentralDirectory:()=>_t});function oi(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=Math.max(0,r.length-ri);for(let n=r.length-ro;n>=t;n--)if(e.getUint32(n,!0)===to)return n;throw new Error("Zip EOCD record not found")}function _t(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=oi(r),n=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let a=0;a>8,S;v===ti?S=d>>16&65535:w.startsWith("bin/")||w.startsWith("sbin/")||w.includes("/bin/")||w.includes("/sbin/")?S=493:S=420;let b=w.endsWith("/"),E=v===ti&&(S&so)===io;o.push({fileName:w,fileNameBytes:p,compressedSize:u,uncompressedSize:f,compressionMethod:l,localHeaderOffset:m,mode:S,isDirectory:b,isSymlink:E,externalAttrs:d,creatorOS:v}),s+=Dn+h+y+g}return o}function ai(r,e){if(r.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(n,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function fo(r,e){let t=new DataView(r.buffer,r.byteOffset,r.byteLength),n=e.localHeaderOffset;if(n<0||n>r.byteLength-Un||t.getUint32(n,!0)!==ni)throw new Error(`Invalid local file header signature at offset ${n}`);let i=t.getUint16(n+8,!0),o=t.getUint16(n+26,!0),s=t.getUint16(n+28,!0),a=n+Un,c=a+o+s,l=c+e.compressedSize;if(i!==e.compressionMethod||cr.byteLength||!ai(r.subarray(a,a+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return r.subarray(c,l)}async function uo(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 p=await fetch(r);if(!p.ok)throw new Error(`Fetch failed: ${p.status} ${p.statusText}`);let w=new Uint8Array(await p.arrayBuffer());return{entries:_t(w),totalSize:w.length}}let i=Math.min(t,ri),o=t-i,s=await fetch(r,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let p=await fetch(r);if(!p.ok)throw new Error(`Fetch failed: ${p.status} ${p.statusText}`);let w=new Uint8Array(await p.arrayBuffer());return{entries:_t(w),totalSize:w.length}}let a=new Uint8Array(await s.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),l=oi(a),u=c.getUint32(l+12,!0),f=c.getUint32(l+16,!0);if(f>=o){let p=t,w=new Uint8Array(p);return w.set(a,o),{entries:_t(w),totalSize:p}}let h=f+u-1,y=await fetch(r,{headers:{Range:`bytes=${f}-${h}`}});if(y.status!==206)throw new Error(`Range request for CD failed: ${y.status}`);let g=new Uint8Array(await y.arrayBuffer()),d=t,m=new Uint8Array(d);return m.set(g,f),m.set(a,o),{entries:_t(m),totalSize:d}}var to,no,ni,ri,ro,Dn,Un,ii,si,ti,io,so,oo,ao,Kn=pn(()=>{"use strict";Fn();to=101010256,no=33639248,ni=67324752,ri=65557,ro=22,Dn=46,Un=30,ii=0,si=8,ti=3,io=40960,so=61440,oo=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),ao=new TextEncoder});var yi={};mr(yi,{DEFAULT_TAR_GZIP_LIMITS:()=>hi,TarParseError:()=>_,parseTarGzip:()=>po});function po(r,e={}){let t=e.label??"TAR gzip archive",n=wo(e.limits,t);if(r.byteLength===0||r.byteLength>n.maxCompressedBytes)throw new _(`${t}: compressed byte count ${r.byteLength} is outside 1..${n.maxCompressedBytes}`);let i=vo(r,t);if(i===0||i>n.maxUncompressedBytes)throw new _(`${t}: declared uncompressed byte count ${i} is outside 1..${n.maxUncompressedBytes}`);let o=Eo(r,t,i);if(o.byteLength!==i)throw new _(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-8,!0);if(So(o)!==s)throw new _(`${t}: gzip CRC32 mismatch`);return mo(o,t,n)}function mo(r,e,t){if(r.byteLength%be!==0)throw new _(`${e}: TAR byte count is not block-aligned`);let n=[],i=0,o=0,s=0,a=null,c={},l=!1;for(;i+be<=r.byteLength;){let u=r.subarray(i,i+be);if(i+=be,Wn(u)){if(i+be>r.byteLength)throw new _(`${e}: TAR end marker is truncated`);let b=r.subarray(i,i+be);if(!Wn(b))throw new _(`${e}: TAR has only one zero end block`);if(i+=be,!Wn(r.subarray(i)))throw new _(`${e}: TAR has nonzero data after its end marker`);l=!0;break}xo(u,e);let f=Pt(u,156,1,e)||"0",h=Vn(u,124,12,`${e}: TAR entry size`),y=Vn(u,100,8,`${e}: TAR entry mode`)&ho,g=Io(u,e,t.maxPathBytes),d=Pt(u,157,100,e);if(f==="x"||f==="g"){if(s+=1,s>t.maxEntries+1)throw new _(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let b=li(r,i,h,e);i=fi(i,h,r.byteLength,e);let E=bo(b,e,t);f==="x"?a=E:c={...c,...E};continue}if(o+=1,o>t.maxEntries)throw new _(`${e}: TAR entry count exceeds ${t.maxEntries}`);let m={...c,...a??{}};a=null;let p=m.size===void 0?h:ko(m.size,`${e}: PAX entry size`),w=li(r,i,p,e);i=fi(i,p,r.byteLength,e);let v=Hn(m.path??g,e,t.maxPathBytes),S=m.linkpath??d;switch(f){case"0":case"\0":n.push({path:v,type:"file",mode:y,data:w});break;case"5":Zn(p,e,"directory",v),n.push({path:v,type:"directory",mode:y});break;case"2":Zn(p,e,"symlink",v),di(S,e,v,t.maxLinkBytes,!1),n.push({path:v,type:"symlink",mode:y,linkName:S});break;case"1":Zn(p,e,"hardlink",v),di(S,e,v,t.maxLinkBytes,!0),n.push({path:v,type:"hardlink",mode:y,linkName:Hn(S,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new _(`${e}: unsupported TAR device/FIFO entry ${v}`);default:throw new _(`${e}: unsupported TAR entry type ${JSON.stringify(f)} for ${v}`)}}if(!l)throw new _(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new _(`${e}: local PAX header has no following entry`);return n}function wo(r,e){let t={...hi,...r};for(let[n,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new _(`${e}: ${n} must be a positive safe integer`);return t}function vo(r,e){if(r.byteLength<18||r[0]!==31||r[1]!==139||r[2]!==8)throw new _(`${e}: invalid gzip header`);return new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-4,!0)}function Eo(r,e,t){let n=new Uint8Array(t),i=0,o=!1,s=new ei(a=>{if(a.byteLength>t-i)throw new _(`${e}: gzip expansion exceeds its declared ${t} bytes`);n.set(a,i),i+=a.byteLength});s.onmember=()=>{throw o=!0,new _(`${e}: concatenated gzip members are unsupported`)};try{s.push(r,!0)}catch(a){throw a instanceof _?a:new _(`${e}: cannot gunzip archive: ${Lo(a)}`)}if(o)throw new _(`${e}: concatenated gzip members are unsupported`);return n.subarray(0,i)}function So(r){let e=4294967295;for(let t of r)e=go[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function zo(){let r=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);r[e]=t>>>0}return r}function li(r,e,t,n){if(t>r.byteLength-e)throw new _(`${n}: TAR entry is truncated`);return r.subarray(e,e+t)}function fi(r,e,t,n){let o=Math.ceil(e/be)*be;if(!Number.isSafeInteger(o)||o>t-r)throw new _(`${n}: TAR entry padding is truncated`);return r+o}function bo(r,e,t){let n={},i=0;for(;i9)throw new _(`${e}: invalid PAX record length`);if(s=s*10+d,!Number.isSafeInteger(s))throw new _(`${e}: invalid PAX record length`)}let a=i+s;if(s<=o-i+2||a>r.byteLength||r[a-1]!==10)throw new _(`${e}: truncated PAX record`);let c=o+1;for(;c=a-1)throw new _(`${e}: invalid PAX record`);let l=r.subarray(o+1,c);if(l.byteLength>256)throw new _(`${e}: PAX record key is too long`);let u=qn(l,`${e}: PAX record key`),f=r.subarray(c+1,a-1),h=u==="path"?t.maxPathBytes:u==="linkpath"?t.maxLinkBytes:u==="size"?32:0;if(h===0){i=a;continue}if(f.byteLength>h)throw new _(`${e}: PAX ${u} value is too long`);let y=qn(f,`${e}: PAX record value`);n[u]=y,i=a}return n}function ko(r,e){if(!/^(0|[1-9][0-9]*)$/.test(r))throw new _(`${e} is invalid`);let t=Number(r);if(!Number.isSafeInteger(t)||t<0)throw new _(`${e} is invalid`);return t}function xo(r,e){let t=Vn(r,148,8,`${e}: TAR checksum`),n=0;for(let i=0;i=148&&i<156?32:r[i];if(t!==n)throw new _(`${e}: TAR checksum mismatch`)}function Io(r,e,t){let n=Pt(r,0,100,e),i=Pt(r,345,155,e);return Hn(i?`${i}/${n}`:n,e,t)}function Hn(r,e,t){let n=r;for(;n.startsWith("./");)n=n.slice(2);return n=n.replace(/\/+$/g,""),Ao(n,`${e}: TAR path`,t),n}function Pt(r,e,t,n){let i=e,o=e+t;for(;in||r.includes("\0"))throw new _(`${e}: link target for ${t} is invalid`);if(i&&r.includes("\\"))throw new _(`${e}: hardlink target for ${t} is invalid`)}function Ao(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\0")||r.includes("\\")||ui.encode(r).byteLength>t)throw new _(`${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 _(`${e} ${JSON.stringify(r)} contains an unsafe path segment`)}function Wn(r){for(let e of r)if(e!==0)return!1;return!0}function qn(r,e){try{return yo.decode(r)}catch{throw new _(`${e} contains non-UTF-8 text`)}}function Lo(r){return r instanceof Error?r.message:String(r)}var be,ho,ci,yo,ui,go,hi,_,gi=pn(()=>{"use strict";Fn();be=512,ho=4095,ci=1024*1024,yo=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),ui=new TextEncoder,go=zo(),hi=Object.freeze({maxCompressedBytes:256*ci,maxUncompressedBytes:512*ci,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),_=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as Rt,lstatSync as hn,readdirSync as Jo,readFileSync as Ge,realpathSync as pe,statSync as Ke}from"node:fs";import{createHash as Bi}from"node:crypto";import{spawnSync as fr}from"node:child_process";import{basename as Qo,dirname as Ct,isAbsolute as yn,join as F,relative as ea,resolve as ge,sep as ta}from"node:path";import{fileURLToPath as na}from"node:url";var wr=["__abi_version","kernel_alloc_scratch","kernel_create_process","kernel_create_process_with_stdio","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_mark_process_signaled","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_prepare_write_operation","kernel_reap_exited_child","kernel_remove_process","kernel_wait_child_poll"];var K={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 A(r,e){let t=0,n=0,i=e;for(;;){let o=r[i++];if(t|=(o&127)<=21&&n<=34?yt(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?yt(e,t):n===3?t:n>=16&&n<=79?yt(e,t):null:null}function qi(r,e,t){let[n,i]=A(r,e);e+=i+n;let[o,s]=A(r,e);e+=s+o;let a=r[e++];if(a===0){t.funcImports++;let[,c]=A(r,e);e+=c}else if(a===1){e++;let c=r[e++],[,l]=A(r,e);if(e+=l,c&1){let[,u]=A(r,e);e+=u}}else if(a===2){let c=r[e++],[,l]=A(r,e);if(e+=l,c&1){let[,u]=A(r,e);e+=u}}else a===3&&(t.globalImports++,e+=2);return e}function vn(r){return r.length>=8&&r[0]===0&&r[1]===97&&r[2]===115&&r[3]===109}function $t(r,e){let[t,n]=A(r,e);return e+=n,[new TextDecoder().decode(r.subarray(e,e+t)),e+t]}function ji(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;it.startsWith("reloc."))}function vr(r,e={}){let t=[];if(Qi(r)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null){let s=rs(r);s!==null&&s!==e.expectedAbi&&t.push(`ABI ${s}, expected ${e.expectedAbi}`)}let n=new Set(Yi(r));if(e.requiredExports){let s=e.requiredExports.filter(a=>!n.has(a));s.length>0&&t.push(`missing required exports: ${s.join(", ")}`)}let i=mn.filter(s=>n.has(s));if(e.forbidForkInstrumentation&&i.length>0&&t.push("contains wasm-fork-instrument exports"),e.requireForkInstrumentation??!ts(r)){let s=i.length===mn.length;if(i.length>0&&!s){let a=mn.filter(c=>!n.has(c));t.push(`incomplete wasm-fork-instrument exports; missing ${a.join(", ")}`)}es(r)&&!s&&t.push("imports kernel.kernel_fork without complete wasm-fork-instrument exports")}return t}function ns(r,e){let t=new Uint8Array(r);if(t.length<8)return null;let n=0,i=null,o=null,s=8;for(;s=c)return null;let d=a;for(let w=0;w=g)return null;let[d,m]=A(t,y);y+=m;for(let p=0;pg)return null}return y}function h(y,g=0){if(g>4)return null;let d=u(y);if(!d)return null;let m=f(d.start,d.end);if(m===null)return null;let p=m,w=d.end;for(;p=32&&v<=38||v===208){let[,S]=A(t,p);p+=S}else if(v>=40&&v<=62)p=yt(t,p);else if(v===63||v===64)p++;else if(v===66){let[,S]=Wi(t,p);p+=S}else if(v===67)p+=4;else if(v===68)p+=8;else if(v===252||v===253||v===254){let S=Vi(v,t,p);if(S===null)return null;p=S}}return null}return h(i)}function rs(r){return ns(r,"__abi_version")}var is=ArrayBuffer,H=Uint8Array,Ft=Uint16Array,ss=Int16Array;var Dt=Int32Array,En=function(r,e,t){if(H.prototype.slice)return H.prototype.slice.call(r,e,t);(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length);var n=new H(t-e);return n.set(r.subarray(e,t)),n},pt=function(r,e,t,n){if(H.prototype.fill)return H.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"],V=function(r,e,t){var n=new Error(e||as[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,V),!t)throw n;return n},Er=function(r,e,t){for(var n=0,i=0;n>>0},ls=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,o=n>>2&1,s=n&3,a=n>>6;n&8&&V(0);var c=6-i,l=s==3?4:s,u=Er(r,c,l);c+=l;var f=a?1<>3);y=g+(g>>3)*(r[5]&7)}y>2145386496&&V(1);var d=new H((e==1?h||y:e?0:y)+12);return d[0]=1,d[4]=4,d[8]=8,{b:c+f,y:0,l:0,d:u,w:e&&e!=1?e:d.subarray(12),e:y,o:new Dt(d.buffer,0,3),u:h,c:o,m:Math.min(131072,y)}}else if((t>>4|r[3]<<20)==25481893)return cs(r,4)+8;V(0)},$e=function(r){for(var e=0;1<t&&V(3);for(var o=1<0;){var w=$e(s+1),v=n>>3,S=(1<>(n&7)&S,E=(1<E&&(b-=k)),h[++a]=--b,b==-1?(s+=b,m[--u]=a):s-=b,!b)do{var I=n>>3;c=(r[I]|r[I+1]<<8)>>(n&7)&3,n+=2,a+=c}while(c==3)}(a>255||s)&&V(0);for(var B=0,C=(o>>1)+(o>>3)+3,ee=o-1,j=0;j<=a;++j){var R=h[j];if(R<1){y[j]=-R;continue}for(l=0;l=u)}}for(B&&V(0),l=0;l>3,{b:i,s:m,n:p,t:g}]},fs=function(r,e){var t=0,n=-1,i=new H(292),o=r[e],s=i.subarray(0,256),a=i.subarray(256,268),c=new Ft(i.buffer,268);if(o<128){var l=mt(r,e+1,6),u=l[0],f=l[1];e+=o;var h=u<<3,y=r[e];y||V(0);for(var g=0,d=0,m=f.b,p=m,w=(++e<<3)-8+$e(y);w-=m,!(w>3;if(g+=(r[v]|r[v+1]<<8)>>(w&7)&(1<>3,d+=(r[v]|r[v+1]<<8)>>(w&7)&(1<255&&V(0)}else{for(n=o-127;t>4,s[t+1]=S&15}++e}var b=0;for(t=0;t11&&V(0),b+=E&&1<0;--t){var j=c[t];pt(ee,t,j,c[t-1]=j+a[t]*(1<a&&f>3,y=(r[h]|r[h+1]<<8|r[h+2]<<16)>>(u&7);c=(c<>2,s=o<<1,a=o+s;gt(r.subarray(n,n+=r[0]|r[1]<<8),e.subarray(0,o),t),gt(r.subarray(n,n+=r[2]|r[3]<<8),e.subarray(o,s),t),gt(r.subarray(n,n+=r[4]|r[5]<<8),e.subarray(s,a),t),gt(r.subarray(n),e.subarray(a),t)},ms=function(r,e,t){var n,i=e.b,o=r[i],s=o>>1&3;e.l=o&1;var a=o>>3|r[i+1]<<5|r[i+2]<<13,c=(i+=3)+a;if(s==1)return i>=r.length?void 0:(e.b=i+1,t?(pt(t,r[i],e.y,e.y+=a),t):pt(new H(a),r[i]));if(!(c>r.length)){if(s==0)return e.b=c,t?(t.set(r.subarray(i,c),e.y),e.y+=a,t):En(r,i,c);if(s==2){var l=r[i],u=l&3,f=l>>2&3,h=l>>4,y=0,g=0;u<2?f&1?h|=r[++i]<<4|(f&2&&r[++i]<<12):h=l>>3:(g=f,f<2?(h|=(r[++i]&63)<<4,y=r[i]>>6|r[++i]<<2):f==2?(h|=r[++i]<<4|(r[++i]&3)<<12,y=r[i]>>2|r[++i]<<6):(h|=r[++i]<<4|(r[++i]&63)<<12,y=r[i]>>6|r[++i]<<2|r[++i]<<10)),++i;var d=t?t.subarray(e.y,e.y+e.m):new H(e.m),m=d.length-h;if(u==0)d.set(r.subarray(i,i+=h),m);else if(u==1)pt(d,r[i++],m);else{var p=e.h;if(u==2){var w=fs(r,i);y+=i-(i=w[0]),e.h=p=w[1]}else p||V(0);(g?ps:gt)(r.subarray(i,i+=y),d.subarray(m),p)}var v=r[i++];if(v){v==255?v=(r[i++]|r[i++]<<8)+32512:v>127&&(v=v-128<<8|r[i++]);var S=r[i++];S&3&&V(0);for(var b=[us,hs,ds],E=2;E>-1;--E){var k=S>>(E<<1)+2&3;if(k==1){var x=new H([0,0,r[i++]]);b[E]={s:x.subarray(2,3),n:x.subarray(0,1),t:new Ft(x.buffer,0,1),b:0}}else k==2?(n=mt(r,i,9-(E&1)),i=n[0],b[E]=n[1]):k==3&&(e.t||V(0),b[E]=e.t[E])}var I=e.t=b,B=I[0],C=I[1],ee=I[2],j=r[c-1];j||V(0);var R=(c<<3)-8+$e(j)-ee.b,P=R>>3,L=0,W=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var Ce=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var te=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var He=1<>>(R&7)&He-1);P=(R-=zn[Ne])>>3;var Ie=gs[Ne]+((r[P]|r[P+1]<<8|r[P+2]<<16)>>(R&7)&(1<>3;var Me=ys[ut]+((r[P]|r[P+1]<<8|r[P+2]<<16)>>(R&7)&(1<>3,W=ee.t[W]+((r[P]|r[P+1]<<8)>>(R&7)&(1<>3,te=B.t[te]+((r[P]|r[P+1]<<8)>>(R&7)&(1<>3,Ce=C.t[Ce]+((r[P]|r[P+1]<<8)>>(R&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=ue-=3;else{var Ve=ue-(Me!=0);Ve?(ue=Ve==3?e.o[0]-1:e.o[Ve],Ve>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=ue):ue=e.o[0]}for(var E=0;EIe&&(Le=Ie);for(var E=0;E=i){let x=(y+1)*4096;try{e.grow(x)}catch{throw new z(Y)}if(i=Math.floor(e.byteLength/4096),y>=i)throw new z(Y)}new Uint8Array(e).fill(0);let g=new r(e);g.w32(An,xn),g.w32(Ln,In),g.w32(Kt,4096),g.w32(je,i),g.w32(_e,s),g.w32(Fe,u),g.w32(Wt,f),g.w32(xr,h),g.w32(Ht,y),g.w32(Ls,a),g.w32(_s,c),g.w32(Ps,l),g.w32(Et,o),g.w32(Ir,256);let d=f*4096;for(let x=0;x>2)+(x>>5);g.i32[I]|=1<<(x&31)}let m=i-y;Atomics.store(g.i32,Xe>>2,m),g.blockAllocHint=y;let p=u*4096;g.i32[p>>2]|=3,Atomics.store(g.i32,Zt>>2,s-2),g.inodeAllocHint=2;let w=g.inodeOffset(1);g.w32(w+N,U|493),g.w32(w+D,2),g.w64(w+se,1);let v=g.blockAlloc();if(v<0)throw new z(Y);g.w32(w+X,v);let S=v*4096,b=Oe(O+1),E=Oe(O+2);g.w32(S,1),g.view.setUint16(S+4,b,!0),g.view.setUint16(S+6,1,!0),g.u8[S+O]=46;let k=S+b;return g.w32(k,1),g.view.setUint16(k+4,E,!0),g.view.setUint16(k+6,2,!0),g.u8[k+O]=46,g.u8[k+O+1]=46,g.w64(w+T,b+E),Atomics.store(g.i32,_n>>2,1),g}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 z(Z,"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 z(On,"Cannot save a VFS image with open descriptors")}let i=this.r32(_e);for(let a=0;a=1&&this.inodeIsAllocated(a)?n:0n;s.setBigUint64(c+St,l,!0),s.setBigUint64(c+ne,l,!0),s.setBigUint64(c+q,l,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=[{ino:1,path:"/"}],n=new Set;for(;t.length>0;){let i=t.pop();if(n.has(i.ino))throw new z($);n.add(i.ino);let o=this.inodeOffset(i.ino);if((this.r32(o+N)&G)!==U)throw new z($);let s=this.r64(o+T),a=0;for(;a>2)>>>0,paths:[]},e.set(E,k)),k.paths.push(v),(this.r32(S+N)&G)===U&&t.push({ino:d,path:v})}}y+=m}a+=h}}return e}statfs(){let e=this.r32(Kt),t=this.r32(je),n=this.r32(Et),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(n,o)),a=Atomics.load(this.i32,Xe>>2),c=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:a+c,totalInodes:this.r32(_e),freeInodes:Atomics.load(this.i32,Zt>>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(je),t=this.r32(Ht),n=this.r32(Wt)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(n>>5),o=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=Vt>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=qt>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=qt>>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,Vt>>2,0),Atomics.store(this.i32,qt>>2,0),this.u8.fill(0,256,4096);let e=this.r32(_e),t=this.r32(Fe)*4096;for(let n=0;n>5)*4)&1<<(n&31))===0||this.r32(i+D)!==0)continue;let s=this.r32(i+N),a=this.r64(i+T);(s&G)===vt&&a<=40?(this.u8.fill(0,i+X,i+X+40),this.w64(i+T,0)):this.inodeTruncate(n,0),this.inodeFree(n)}}blockAlloc(){let e=this.r32(je),t=this.r32(Wt)*4096,n=this.r32(Ht),i=this.blockAllocHint>=n&&this.blockAllocHint>2)+(a>>5),l=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 o=Atomics.load(this.i32,n),s=o&~(1<>2,1),e>=this.r32(Ht)&&e>2)>0)return 0;let e=this.r32(je),t=this.r32(Et),n=this.r32(Ir),i=e+n;if(i>t&&(i=t,n=i-e,n===0))return Y;let o=i*4096;if(this.buffer.byteLength>2,n),Atomics.add(this.i32,_n>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let n=this.r32(xr)+Math.floor(e/32),i=e%32*128;return n*4096+i}inodeAlloc(){let e=this.r32(_e),t=this.r32(Fe)*4096,n=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),c=s&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let n=(this.r32(Fe)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,n);if((o&1<>2,1),e>=2&&e0&&this.w32(n+Pe,i-1),i<=1&&this.r32(n+D)===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+D);return n>1?(this.w32(t+D,n-1),this.w64(t+q,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+D,0),this.w64(t+q,Date.now()),this.r32(t+Pe)>0)return!1;let n=this.r32(t+N),i=this.r64(t+T);return(n&G)===vt&&i<=40?(this.u8.fill(0,t+X,t+X+40),this.w64(t+T,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+Ye>>2;for(;;){let n=Atomics.load(this.i32,t);if(n&Rr){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,n,n+1)===n)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+Ye>>2;(Atomics.sub(this.i32,t,1)&Os)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+Ye>>2;for(;;){let n=Atomics.load(this.i32,t);if(n!==0){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,0,Rr)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+Ye>>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 o=this.r32(i+X+t*4);if(o!==0)return o;if(!n)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+X+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+zt),s=!1;if(o===0){if(!n)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+zt,o),s=!0}let a=o*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!n)return 0;let l=this.blockAllocWithGrow();return l<0?(s&&(this.w32(i+zt,0),this.blockFree(o)),l):(this.w32(a,l),l)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,a=this.r32(i+Je),c=!1;if(a===0){if(!n)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+Je,a),c=!0}let l=a*4096+o*4,u=this.r32(l),f=!1;if(u===0){if(!n)return 0;if(u=this.blockAllocWithGrow(),u<0)return c&&(this.w32(i+Je,0),this.blockFree(a)),u;this.w32(l,u),f=!0}let h=u*4096+s*4,y=this.r32(h);if(y!==0)return y;if(!n)return 0;let g=this.blockAllocWithGrow();return g<0?(f&&(this.w32(l,0),this.blockFree(u)),c&&(this.w32(i+Je,0),this.blockFree(a)),g):(this.w32(h,g),g)}return Z}inodeReadData(e,t,n,i){let o=this.inodeOffset(e),s=this.r64(o+T);if(t>=s)return 0;t+i>s&&(i=s-t);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),u=t%4096,f=4096-u;f>i&&(f=i);let h=this.inodeBlockMap(e,l,!1);if(h<=0)n.fill(0,c,c+f);else{let y=h*4096+u;n.set(this.u8.subarray(y,y+f),c)}c+=f,t+=f,i-=f,a+=f}return a}inodeWriteData(e,t,n,i){let o=this.inodeOffset(e),s=this.r64(o+T);t>s&&this.zeroOldEofTail(e,s);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),u=t%4096,f=4096-u;f>i&&(f=i);let h=this.inodeBlockMap(e,l,!0);if(h<0){if(a===0)return h;break}let y=h*4096+u;this.u8.set(n.subarray(c,c+f),y),c+=f,t+=f,i-=f,a+=f}if(a>0&&t>this.r64(o+T)&&this.w64(o+T,t),a>0){let l=Date.now();this.w64(o+ne,l),this.w64(o+q,l),Atomics.add(this.i32,o+oe>>2,1)}return a}zeroInodeRange(e,t,n){for(;t0){let c=a*4096+o;this.u8.fill(0,c,c+s)}t+=s}}zeroOldEofTail(e,t){let n=t%4096;if(n===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+n;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let n=this.inodeOffset(e);for(let s=t;s<10;s++){let a=this.r32(n+X+s*4);a&&(this.blockFree(a),this.w32(n+X+s*4,0))}let i=this.r32(n+zt);if(i){let s=t>10?t-10:0;for(let a=s;a<1024;a++){let c=i*4096+a*4,l=this.r32(c);l&&(this.blockFree(l),this.w32(c,0))}s===0&&(this.blockFree(i),this.w32(n+zt,0))}let o=this.r32(n+Je);if(o){let s=t>1034?t-10-1024:0,a=Math.floor(s/1024);for(let c=a;c<1024;c++){let l=o*4096+c*4,u=this.r32(l);if(!u)continue;let f=c===a?s%1024:0;for(let h=f;h<1024;h++){let y=u*4096+h*4,g=this.r32(y);g&&(this.blockFree(g),this.w32(y,0))}f===0&&(this.blockFree(u),this.w32(l,0))}a===0&&(this.blockFree(o),this.w32(n+Je,0))}}inodeTruncate(e,t,n=!1){let i=this.inodeOffset(e),o=this.r64(i+T),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+T,t),s||n){let c=Date.now();this.w64(i+ne,c),this.w64(i+q,c),Atomics.add(this.i32,i+oe>>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),s||n){let c=Date.now();this.w64(i+ne,c),this.w64(i+q,c),Atomics.add(this.i32,i+oe>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new z(Z);if(e>Qe)throw new z(bt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new z(Mr);if(e<0)throw new z(Z);if(e>Qe)throw new z(bt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),n=Date.now();this.w64(t+ne,n),this.w64(t+q,n);let i=Atomics.add(this.i32,t+_r>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+T))}dirNameKey(e){return et(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=O&&n%4===0&&e+n<=t&&i<=n-O}inodeIsAllocated(e){let t=this.r32(_e);if(e<=0||e>=t)return!1;let n=this.r32(Fe)*4096;return(Atomics.load(this.i32,(n>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,n,i){let o=new Map,s=[],a=0;for(;a4096-u&&(y=4096-u);let g=u;for(;g=O&&s.push({abs:d,recLen:p});g+=p}a+=y}let c={generation:t,mutationSequence:n,size:i,entries:o,free:s};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),n=this.r64(t+T),i=this.r64(t+se),o=Atomics.load(this.i32,t+_r>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===n?s:(s&&this.dirIndexes.delete(e),n=0;s--){let a=e.free[s];if(!(a.recLen4096-c&&(f=4096-c);let h=c;for(;hn)return-1;a=c,s+=l}return s===n?a:-1}dirAppendEntry(e,t,n,i=-1){let o=this.inodeOffset(e),s=this.r64(o+T),a=Oe(O+t.length),c=s,l=Math.floor(c/4096),u=c%4096,f=0;if(u!==0&&u+a>4096){let g=4096-u,d=0;if(g>=O){if(d=this.inodeBlockMap(e,l,!1),d<=0)return $}else if(i<0&&(i=this.findLastDirEntryInBlock(e,l,u)),i<0)return $;if(f=this.inodeBlockMap(e,l+1,!0),f<0)return f;if(g>=O){let m=d*4096+u;this.w32(m,0),this.view.setUint16(m+4,g,!0),this.view.setUint16(m+6,0,!0)}else{let p=this.view.getUint16(i+4,!0)+g;this.view.setUint16(i+4,p,!0),this.updateDirIndexRecLen(e,i,p)}c=(l+1)*4096,l++,u=0}let h;if(u===0){if(h=f||this.inodeBlockMap(e,l,!0),h<0)return h}else if(h=this.inodeBlockMap(e,l,!1),h<=0)return $;let y=h*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+O),this.w64(o+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 o=this.inodeOffset(e),s=this.r64(o+T),a=Oe(O+t.length),c=-1,l=0;for(;l4096-f&&(g=4096-f);let d=f;for(;df+g||v>w-O)return $;if(p===0&&w>=a)return this.w32(m,n),this.view.setUint16(m+6,t.length,!0),this.u8.set(t,m+O),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,m,w),0;let S=Oe(O+v),b=w-S;if(p!==0&&b>=a){this.view.setUint16(m+4,S,!0);let E=m+S;return this.w32(E,n),this.view.setUint16(E+4,b,!0),this.view.setUint16(E+6,t.length,!0),this.u8.set(t,E+O),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,E,b),0}c=m,d+=w}l+=g}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 he;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),o=this.r64(i+T),s=0;for(;s4096-c&&(f=4096-c);let h=c;for(;h4096-l&&(h=4096-l);let y=l;for(;y4096-s&&(l=4096-s);let u=s;for(;us+l||g>y-O)throw new z($);if(h!==0){if(g===1&&this.u8[f+O]===46){u+=y;continue}if(g===2&&this.u8[f+O]===46&&this.u8[f+O+1]===46){u+=y;continue}return!1}u+=y}i+=l}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 o=this.dirLookup(n,Br);if(o<0||o===n)throw new z($);n=o}throw new z($)}pathResolve(e,t){if(!e.startsWith("/"))return he;let n=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return Tn;let c=ae.encode(a),l;this.inodeReadLock(n);try{let h=this.inodeOffset(n);if((this.r32(h+N)&G)!==U)return Ee;l=this.dirLookup(n,c)}finally{this.inodeReadUnlock(n)}if(l<0)return l;let u=this.inodeOffset(l);if((this.r32(u+N)&G)===vt&&(!(s===i.length-1)||t)){if(++o>8)return Nr;let y=this.r64(u+T),g;if(y<=40)g=et(this.u8.subarray(u+X,u+X+y));else{let d=new Uint8Array(y);this.inodeReadData(l,0,d,y),g=xt.decode(d)}if(g.startsWith("/")){n=1;let d=g.split("/").filter(p=>p.length>0),m=i.slice(s+1);i.length=0,i.push(...d,...m),s=-1}else{let d=g.split("/").filter(p=>p.length>0),m=i.slice(s+1);i.length=s,i.push(...d,...m),s--}continue}n=l}return n}pathResolveParent(e){if(!e.startsWith("/"))throw new z(Z,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new z(Z,"Cannot operate on /");let n=t.pop();if(n.length>255)throw new z(Tn);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new z(o);let s=this.inodeOffset(o);if((this.r32(s+N)&G)!==U)throw new z(Ee);return{parentIno:o,name:n}}fdAlloc(e,t,n){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,s,0,1)===0)return this.w32(o+Pr,e),this.w64(o+De,0),this.w32(o+Or,t),this.w32(o+Tr,n?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,s,0),he)}return Cr}fdGet(e){if(e<0||e>=Ut)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Pr),offset:this.r64(t+De),flags:this.r32(t+Or),isDir:this.r32(t+Tr)!==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+oe),mode:this.r32(t+N),linkCount:this.r32(t+D),size:this.r64(t+T),mtime:this.r64(t+ne),ctime:this.r64(t+q),atime:this.r64(t+St),uid:this.r32(t+Ar),gid:this.r32(t+Lr)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+se),linkCount:this.r32(t+D),mode:this.r32(t+N)}}open(e,t,n=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,n))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let n=this.openUnlocked(e,kr|kt,t);try{let i=this.fdGet(n);if(!i)throw new z(Q);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,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+se)!==n||this.r32(a+oe)!==i||(this.r32(a+N)&G)!==wt)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(a+se)!==n||this.r32(a+oe)!==i||this.r64(a+T)!==0)return!1;let c=this.r64(a+ne),l=this.r64(a+q);this.inodeTruncate(s,0,!0);let u=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(u!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,a+oe>>2,i),this.w64(a+ne,c),this.w64(a+q,l),new z(u<0?u:Y);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e){return e.length===0?!0:this.withNamespaceLock(()=>{let t=[],n=new Set;for(let o of e){this.validateFileSize(o.data.byteLength);let s=-1;for(let a of o.paths){let c=this.pathResolve(a,!0);if(c!==o.expectedIno)continue;let l=this.inodeOffset(c);if(this.r64(l+se)===o.expectedGeneration&&this.r32(l+oe)===o.expectedDataSequence&&(this.r32(l+N)&G)===wt&&this.r64(l+T)===0){s=c;break}}if(s<0)return!1;if(n.has(s))throw new z(Z,"duplicate conditional replacement inode");n.add(s),t.push({...o,ino:s})}let i=[...n].sort((o,s)=>o-s);for(let o of i)this.inodeWriteLock(o);try{for(let a of t){let c=this.inodeOffset(a.ino);if(this.r64(c+se)!==a.expectedGeneration||this.r32(c+oe)!==a.expectedDataSequence||(this.r32(c+N)&G)!==wt||this.r64(c+T)!==0)return!1}let o=t.map(a=>{let c=this.inodeOffset(a.ino);return{ino:a.ino,dataSequence:this.r32(c+oe),mtime:this.r64(c+ne),ctime:this.r64(c+q)}}),s=0;try{for(let a of t){s++,this.inodeTruncate(a.ino,0,!0);let c=a.data.byteLength>0?this.inodeWriteData(a.ino,0,a.data,a.data.byteLength):0;if(c!==a.data.byteLength)throw new z(c<0?c:Y)}}catch(a){for(let c=s-1;c>=0;c--){let l=o[c],u=this.inodeOffset(l.ino);this.inodeTruncate(l.ino,0,!0),Atomics.store(this.i32,u+oe>>2,l.dataSequence),this.w64(u+ne,l.mtime),this.w64(u+q,l.ctime)}throw a}return!0}finally{for(let o=i.length-1;o>=0;o--)this.inodeWriteUnlock(i[o])}})}openUnlocked(e,t,n=420){let i=t&Gt,o=(t&kt)!==0,s=(t&Bn)!==0;if(o&&s){let f=this.pathResolve(e,!1);if(f>=0)throw new z(tt);if(f!==he)throw new z(f)}let a=this.pathResolve(e,!0);if(a<0&&a===he&&o){let{parentIno:f,name:h}=this.pathResolveParent(e);this.inodeWriteLock(f);try{let y=ae.encode(h),g=this.dirLookup(f,y);if(g>=0){if(s)throw new z(tt);a=g}else{let d=this.inodeAlloc();if(d<0)throw new z(Y);let m=this.inodeOffset(d);this.w32(m+N,wt|n&4095),this.w32(m+D,1),this.w64(m+T,0);let p=Date.now();this.w64(m+St,p),this.w64(m+ne,p),this.w64(m+q,p);let w=this.dirAddEntry(f,y,d);if(w<0)throw this.inodeFree(d),new z(w);a=d}}finally{this.inodeWriteUnlock(f)}}if(a<0)throw new z(a);let c=this.inodeOffset(a),l=this.r32(c+N);if((l&G)===U&&i!==qe)throw new z(Ue);if(t&bs&&(l&G)!==U)throw new z(Ee);if(t&It){if((l&G)===U)throw new z(Ue);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let u=this.fdAlloc(a,t,!1);if(u<0)throw new z(u);return u}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new z(Q);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let n=this.fdGet(e);if(!n)throw new z(Q);let i=this.inodeOffset(n.ino);if((this.r32(i+N)&G)===U)throw new z(Ue);this.inodeReadLock(n.ino);try{let s=this.inodeReadData(n.ino,n.offset,t,t.length),a=256+e*24;return this.w64(a+De,n.offset+s),s}finally{this.inodeReadUnlock(n.ino)}}readAt(e,t,n){let i=this.fdGet(e);if(!i)throw new z(Q);let o=this.inodeOffset(i.ino);if((this.r32(o+N)&G)===U)throw new z(Ue);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 z(Q);if((n.flags&Gt)===qe)throw new z(Q);this.inodeWriteLock(n.ino);try{let o=n.offset;if(n.flags&zs){let c=this.inodeOffset(n.ino);o=this.r64(c+T)}if(!Number.isSafeInteger(o)||o<0)throw new z(Z);if(o>Qe||t.length>Qe-o)throw new z(bt);let s=this.inodeWriteData(n.ino,o,t,t.length);if(s<0)return s;let a=256+e*24;return this.w64(a+De,o+s),s}finally{this.inodeWriteUnlock(n.ino)}}writeAt(e,t,n){let i=this.fdGet(e);if(!i)throw new z(Q);if((i.flags&Gt)===qe)throw new z(Q);this.validateSeekPosition(n),this.inodeWriteLock(i.ino);try{if(n>Qe||t.length>Qe-n)throw new z(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 z(Q);let o;if(n===ks)o=t;else if(n===xs)o=i.offset+t;else if(n===Is){let a=this.inodeOffset(i.ino);o=this.r64(a+T)+t}else throw new z(Z);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+De,o),o}ftruncate(e,t){let n=this.fdGet(e);if(!n)throw new z(Q);if((n.flags&Gt)===qe)throw new z(Q);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 z(Q);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 z(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 z(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=ae.encode(n),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new z(s);let a=this.inodeOffset(s),c=this.r32(a+N);if(o&&(c&G)!==U)throw new z(Ee);if((c&G)===U)throw new z(Ue);let l=this.namespaceEntryIdentity(s),u=this.dirRemoveEntry(t,i);if(u<0)throw new z(u);let f=!1;this.inodeWriteLock(s);try{f=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return f&&this.inodeFree(s),l}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:o,name:s}=this.pathResolveParent(t);if(Pn(i)||Pn(s))throw new z(Z);let a=ae.encode(i),c=ae.encode(s),l=e.length>1&&e.endsWith("/"),u=t.length>1&&t.endsWith("/"),f=Math.min(n,o),h=Math.max(n,o);this.inodeWriteLock(f),f!==h&&this.inodeWriteLock(h);try{let y=this.dirLookup(n,a);if(y<0)throw new z(y);let g=this.inodeOffset(y),m=this.r32(g+N)&G,p=this.namespaceEntryIdentity(y);if((l||u)&&m!==U)throw new z(Ee);if(m===U&&this.dirIsAncestor(y,o))throw new z(Z);let w=this.dirLookup(o,c),v=!1,S;if(w>=0){if(w===y)return{source:p,replaced:p};S=this.namespaceEntryIdentity(w);let E=this.inodeOffset(w),x=this.r32(E+N)&G;if(m===U&&x!==U)throw new z(Ee);if(m!==U&&x===U)throw new z(Ue);let I=!1,B=w===n||w===o;B||this.inodeWriteLock(w);try{if(x===U&&!this.dirIsEmpty(w))throw new z(Rn);let C=this.dirReplaceEntryIno(o,c,y);if(C<0)throw new z(C);I=x===U?this.inodeOrphanLocked(w):this.inodeDropLinkRefLocked(w)}finally{B||this.inodeWriteUnlock(w)}I&&this.inodeFree(w),v=x===U}else{let E=this.dirAddEntry(o,c,y);if(E<0)throw new z(E)}let b=this.dirRemoveEntry(n,a);if(b<0)throw new z(b);if(m===U){if(n!==o){let E=this.inodeOffset(n);this.w32(E+D,this.r32(E+D)-1);let k=this.inodeOffset(o);this.w32(k+D,this.r32(k+D)+1),this.inodeWriteLock(y);try{let x=this.dirReplaceEntryIno(y,Br,o);if(x<0)throw new z(x);this.w64(g+q,Date.now())}finally{this.inodeWriteUnlock(y)}}if(v){let E=this.inodeOffset(o);this.w32(E+D,this.r32(E+D)-1)}}else if(v){let E=this.inodeOffset(o);this.w32(E+D,this.r32(E+D)-1)}return{source:p,replaced:S}}finally{f!==h&&this.inodeWriteUnlock(h),this.inodeWriteUnlock(f)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:n,name:i}=this.pathResolveParent(e),o=ae.encode(i);this.inodeWriteLock(n);try{if(this.dirLookup(n,o)>=0)throw new z(tt);let a=this.inodeAlloc();if(a<0)throw new z(Y);let c=this.inodeOffset(a);this.w32(c+N,U|t),this.w32(c+D,2),this.w64(c+T,0);let l=Date.now();this.w64(c+St,l),this.w64(c+ne,l),this.w64(c+q,l);let u=this.blockAllocWithGrow();if(u<0)throw this.inodeFree(a),new z(Y);this.w32(c+X,u);let f=u*4096,h=Oe(O+1),y=Oe(O+2);this.w32(f,a),this.view.setUint16(f+4,h,!0),this.view.setUint16(f+6,1,!0),this.u8[f+O]=46;let g=f+h;this.w32(g,n),this.view.setUint16(g+4,y,!0),this.view.setUint16(g+6,2,!0),this.u8[g+O]=46,this.u8[g+O+1]=46,this.w64(c+T,h+y);let d=this.dirAddEntry(n,o,a);if(d<0)throw this.blockFree(u),this.inodeFree(a),new z(d);let m=this.inodeOffset(n);this.w32(m+D,this.r32(m+D)+1)}finally{this.inodeWriteUnlock(n)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:n}=this.pathResolveParent(e);if(Pn(n))throw new z(Z);let i=ae.encode(n);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new z(o);let s=this.inodeOffset(o);if((this.r32(s+N)&G)!==U)throw new z(Ee);let c=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new z(Rn);let u=this.dirRemoveEntry(t,i);if(u<0)throw new z(u);c=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}c&&this.inodeFree(o);let l=this.inodeOffset(t);this.w32(l+D,this.r32(l+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),o=ae.encode(i),s=ae.encode(e);this.inodeWriteLock(n);try{if(this.dirLookup(n,o)>=0)throw new z(tt);let c=this.inodeAlloc();if(c<0)throw new z(Y);let l=this.inodeOffset(c);if(this.w32(l+N,vt|511),this.w32(l+D,1),s.length<=40)this.u8.set(s,l+X),this.w64(l+T,s.length);else{this.w64(l+T,0);let f=this.inodeWriteData(c,0,s,s.length);if(f!==s.length)throw f>0&&this.inodeTruncate(c,0),this.inodeFree(c),new z(f<0?f:Y)}let u=this.dirAddEntry(n,o,c);if(u<0)throw s.length<=40?(this.u8.fill(0,l+X,l+X+40),this.w64(l+T,0)):this.inodeTruncate(c,0),this.inodeFree(c),new z(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 z(n);this.inodeWriteLock(n);try{let i=this.inodeOffset(n),o=this.r32(i+N);this.w32(i+N,o&G|t&4095),this.w64(i+q,Date.now())}finally{this.inodeWriteUnlock(n)}}fchmod(e,t){let n=this.fdGet(e);if(!n)throw new z(Q);this.inodeWriteLock(n.ino);try{let i=this.inodeOffset(n.ino),o=this.r32(i+N);this.w32(i+N,o&G|t&4095),this.w64(i+q,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 z(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 z(Q);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 z(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,n)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,n){let i=this.inodeOffset(e);t!==br&&this.w32(i+Ar,t),n!==br&&this.w32(i+Lr,n);let o=this.r32(i+N);(o&G)===wt&&(o&Ss)!==0&&this.w32(i+N,o&~(vs|Es)),this.w64(i+q,Date.now())}utimens(e,t,n,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,n,i,o))}utimensUnlocked(e,t,n,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new z(s);this.inodeWriteLock(s);try{let a=this.inodeOffset(s),c=1073741823,l=1073741822,u=Date.now();if(n!==l){let f=n===c?u:t*1e3+Math.floor(n/1e6);this.w64(a+St,f)}if(o!==l){let f=o===c?u:i*1e3+Math.floor(o/1e6);this.w64(a+ne,f)}this.w64(a+q,u)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let n=this.pathResolve(e,!1);if(n<0)throw new z(n);let i=this.inodeOffset(n);if((this.r32(i+N)&G)===U)throw new z(As);let{parentIno:s,name:a}=this.pathResolveParent(t),c=ae.encode(a);this.inodeWriteLock(s);try{if(this.dirLookup(s,c)>=0)throw new z(tt);let u=this.dirAddEntry(s,c,n);if(u<0)throw new z(u);this.inodeWriteLock(n);try{let f=this.r32(i+D);this.w32(i+D,f+1),this.w64(i+q,Date.now())}finally{this.inodeWriteUnlock(n)}return{...this.namespaceEntryIdentity(n),linkCount:this.r32(i+D)}}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 z(t);let n=this.inodeOffset(t);if((this.r32(n+N)&G)!==vt)throw new z(Z);let o=this.r64(n+T);if(o<=40)return et(this.u8.subarray(n+X,n+X+o));this.inodeReadLock(t);try{let s=new Uint8Array(o);return this.inodeReadData(t,0,s,o),xt.decode(s)}finally{this.inodeReadUnlock(t)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new z(t);let n=this.inodeOffset(t);if((this.r32(n+N)&G)!==U)throw new z(Ee);let o=this.fdAlloc(t,qe,!0);if(o<0)throw new z(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new z(Q);let n=this.inodeOffset(t.ino),i=this.r64(n+T);for(;t.offset=this.r32(_e))throw new z($);let d=this.r32(Fe)*4096;if((this.r32(d+(u>>5)*4)&1<<(u&31))===0)throw new z($);let p=et(this.u8.subarray(l+O,l+O+h)),w=this.buildStat(u);return this.w64(g+De,y),t.offset=y,{name:p,stat:w}}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"?ae.encode(t):t,i=this.open(e,kr|kt|It);try{this.write(i,n)}finally{this.close(i)}}readFile(e){let t=this.open(e,qe);try{let n=this.fstat(t),i=new Uint8Array(n.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return xt.decode(this.readFile(e))}};function $r(r,e){let t=new Map,n=new Map;for(let s of r){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(n.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);n.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of r){if(s.type!=="hardlink"||o.has(s.path))continue;let a=[],c=s,l;for(;c.type==="hardlink";){let f=o.get(c.path);if(f){l=f;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}l??=c.type==="file"?c:void 0;let u=n.get(s.inodeGroup??"");if(!l||l!==u)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let f=a.length-1;f>=0;f-=1){let h=a[f];if(n.get(h.inodeGroup??"")!==l)throw new Error(`${e} hardlink ${h.path} does not resolve to its inode`);i.delete(h.path),o.set(h.path,l)}}return{canonicalByGroup:n,canonicalTargetByPath:o}}var ce={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},me={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function Fr(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>me.maxGroups)throw new Error(`${e} exceeds the ${me.maxGroups}-group cap`);if(r.archiveBytes>me.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(r.expandedBytes>me.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(r.payloadBytes>me.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(r.entries>me.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var nt="/home/linuxbrew/.linuxbrew",Gr=[["@@HOMEBREW_PREFIX@@",nt],["@@HOMEBREW_CELLAR@@",`${nt}/Cellar`],["@@HOMEBREW_REPOSITORY@@",nt],["@@HOMEBREW_LIBRARY@@",`${nt}/Library`],["@@HOMEBREW_PERL@@",`${nt}/opt/perl/bin/perl`]],Cn="@@HOMEBREW_JAVA@@",Bs=/^openjdk(?:@\d+(?:\.\d+)*)?/,rt=new TextEncoder,Cs=[...Gr.map(([r])=>r),Cn].map(r=>({placeholder:r,bytes:rt.encode(r)}));function Kr(r){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(r))}catch(a){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+Fs(a))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");let t=e,n=t.changed_files;if(n!=null&&!Array.isArray(n))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let i=Array.isArray(n)?n:[];if(i.length>1e5)throw new Error(`INSTALL_RECEIPT.json declares ${i.length} changed files, limit 100000`);let o=[],s=new Set;for(let[a,c]of i.entries()){if(typeof c!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${a}] is not a string`);if(Ms(c,"Homebrew changed file"),s.has(c))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${c}`);s.add(c),o.push(c)}return{changedFiles:o,runtimeDependencies:t.runtime_dependencies}}function Zr(r,e,t){let n=r;for(let[s,a]of Gr)n=Ur(n,rt.encode(s),rt.encode(a));let i=rt.encode(Cn);if(Dr(n,i)){let s=Ns(e.runtimeDependencies);if(s===void 0)throw new Error(`Homebrew changed file ${t} uses ${Cn} without exactly one OpenJDK runtime dependency`);n=Ur(n,i,rt.encode(s))}let o=Cs.find(({bytes:s})=>Dr(n,s));if(o!==void 0)throw new Error(`Homebrew changed file ${t} retains ${o.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,o=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,s=o===void 0?null:Bs.exec(o);o!==void 0&&s?.[0]===o&&e.push(o)}let t=[...new Set(e)];return t.length===1?`${nt}/opt/${t[0]}/libexec`:void 0}function Ms(r,e){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||$s(r)||rt.encode(r).byteLength>4096||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 Dr(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;ncn||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 Uo(r,e,t,n){let i=ln(t),o=new Map,s=e.map(a=>{let c=a.fileName,l=`Lazy archive ${JSON.stringify(r)} 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 u=a.isDirectory?c.slice(0,-1):c,f=u.split("/");if(u.length===0||f.some(h=>h===""||h==="."||h===".."))throw new Error(`${l} is not a canonical relative POSIX path`);if(o.has(u))throw new Error(`${l} 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 o.set(u,a),{entry:a,archivePath:u,vfsPath:i==="/"?`/${u}`:`${i}/${u}`}});for(let{archivePath:a}of s){let c=a.split("/");for(let l=1;lct)throw new Error(`VFS image metadata exceeds ${ct} 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 sr(e)}function Zo(r){if(r===null)return new Uint8Array(0);let e=sr(r),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>ct)throw new Error(`VFS image metadata exceeds ${ct} bytes`);return t}function Wo(r){return r.byteLength>=Ot.length&&r[0]===Ot[0]&&r[1]===Ot[1]&&r[2]===Ot[2]&&r[3]===Ot[3]?Yo(r):r}function Yt(r){let e=Wo(r);if(e.byteLengthQt)throw new Error(`VFS image lazy metadata exceeds ${Qt} bytes`);if(r.byteLengthen)throw new Error(`VFS image lazy archive metadata exceeds ${en} bytes`);if(r.byteLength=0?n:void 0}function qo(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 Tt(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"||!Do.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)>pi)throw new Error(`Lazy archive integrity byte count must be between 1 and ${pi}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function lt(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(o=>!Object.prototype.hasOwnProperty.call(n,o)))throw new Error(`${t} has unexpected or missing fields`);return n}function nr(r,e,t,n){if(typeof r!="object"||r===null||Array.isArray(r))throw new Error(`${n} must be an object`);let i=r,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(`${n} has unexpected or missing fields`);return i}function ke(r,e,t,n){if(!Array.isArray(r)||r.lengthn)throw new Error(`${e} must contain ${t} to ${n} items`);return r}function Te(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 ie(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 sn(r,e=1){let t=r,n=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=lt(r,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...n?["source"]:[]],"Lazy tree content"),o=i.decoder==="zip-v1"?"application/zip":i.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(o===null||i.mediaType!==o)throw new Error("Lazy tree decoder and media type are inconsistent");let s=Tt({sha256:i.sha256,bytes:i.bytes});if(!s)throw new Error("Lazy tree integrity is required");let a=ke(i.transports,"Lazy tree transports",e,ce.maxTransportsPerTree).map((f,h)=>Te(f,`Lazy tree transport ${h}`,ir));if(new Set(a).size!==a.length)throw new Error("Lazy tree transports contain duplicates");let c=ie(i.expandedBytes,"Lazy tree expanded byte count",0,Co),l=ie(i.sourceEntryCount,"Lazy tree source entry count",1,ft),u=n?jo(i.source,i.decoder):void 0;if(u!==void 0&&u.entries.length!==l)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:i.decoder,mediaType:o,sha256:s.sha256,bytes:s.bytes,expandedBytes:c,sourceEntryCount:l,transports:a,...u===void 0?{}:{source:u}}}function bi(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 rr(r){Fr(r,"Serialized lazy tree collection")}function Ei(r){rr(bi(r))}function jo(r,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=lt(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=ke(t.entries,"Lazy tree source entries",1,ft).map((s,a)=>{let c=s,l=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,u=l==="directory"||l==="file"?["sourcePath","type","mode","size"]:l==="symlink"||l==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(u===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let f=lt(s,u,`Lazy tree source entry ${a}`),h=fe(f.sourcePath,!1,`Lazy tree source entry ${a} path`);if(n.has(h))throw new Error(`Lazy tree source inventory duplicates ${h}`);let y=ie(f.mode,`Lazy tree source entry ${h} mode`,0,4095),g=ie(f.size,`Lazy tree source entry ${h} size`,0,tn),d;if((l==="directory"||l==="symlink"||l==="hardlink")&&g!==0)throw new Error(`Lazy tree source ${h} has payload for ${String(l)}`);l==="symlink"?d=Te(f.target,`Lazy tree source symlink ${h} target`,zi):l==="hardlink"&&(d=fe(f.target,!1,`Lazy tree source hardlink ${h} target`));let m={sourcePath:h,type:l,mode:y,size:g,...d===void 0?{}:{target:d}};return n.set(h,m),m}),o=i.map(s=>s.sourcePath);if(o.some((s,a)=>a>0&&o[a-1]>=s))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 ki(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=[],o=new Set,s=n,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 fe(r,e,t,n=!1){if(typeof r!="string"||r.length===0||new TextEncoder().encode(r).byteLength>cn||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(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return r}function xi(r,e,t,n,i=1){let o=sn(r,i),s=ln(t),a=lt(n,["mode","capabilities","roots"],"Lazy tree activation");if(a.mode!=="boot-prefetch"&&a.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let c=ke(a.capabilities,"Lazy tree activation capabilities",1,$o).map((S,b)=>{let E=Te(S,`Lazy tree activation capability ${b}`,ce.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(E))throw new Error(`Lazy tree activation capability ${b} is invalid`);return E}),l=ke(a.roots,"Lazy tree activation roots",1,Fo).map((S,b)=>fe(S,!0,`Lazy tree activation root ${b}`,!0));if(new Set(c).size!==c.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let u={mode:a.mode,capabilities:c,roots:l},f=ke(e,"Lazy tree inventory",1,ft),h=[],y=new Map,g=new Map,d=o.source===void 0?void 0:new Map(o.source.entries.map(S=>[S.sourcePath,S])),m=o.source===void 0?void 0:ki(o.source.entries),p=0;for(let[S,b]of f.entries()){if(typeof b!="object"||b===null||Array.isArray(b))throw new Error(`Lazy tree entry ${S} must be an object`);let E=b.type,k=E==="directory"?["vfsPath","sourcePath","type","mode","size"]:E==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:E==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:E==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!k)throw new Error(`Lazy tree entry ${S} has an invalid type`);let x=lt(b,[...k,...d===void 0?[]:["materialization"]],`Lazy tree entry ${S}`),I=fe(x.vfsPath,!0,`Lazy tree entry ${S} VFS path`),B=fe(x.sourcePath,!1,`Lazy tree entry ${S} source path`),C=d===void 0?void 0:x.materialization;if(d!==void 0&&C!=="archive"&&C!=="archive-homebrew-relocate"&&C!=="archive-copy"&&C!=="archive-copy-mode"&&C!=="descriptor")throw new Error(`Lazy tree entry ${I} has invalid materialization provenance`);if(s!=="/"&&I!==s&&!I.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${I} escapes its mount prefix`);if(y.has(I))throw new Error(`Lazy tree duplicates VFS path ${I}`);let ee=ie(x.mode,`Lazy tree entry ${I} mode`,0,4095),j=ie(x.size,`Lazy tree entry ${I} size`,0,tn),R,P;if(E==="directory"){if(j!==0)throw new Error(`Lazy tree directory ${I} has nonzero size`)}else if(E==="symlink"){if(R=Te(x.target,`Lazy tree symlink ${I} target`,zi),new TextEncoder().encode(R).byteLength!==j)throw new Error(`Lazy tree symlink ${I} size differs from its target`)}else P=Te(x.inodeGroup,`Lazy tree entry ${I} inode group`,cn),E==="hardlink"&&(R=fe(x.target,!0,`Lazy tree hardlink ${I} target`));if(E!=="hardlink"&&(p+=j,p>tn))throw new Error("Lazy tree inventory exceeds the expansion limit");let L={vfsPath:I,sourcePath:B,...C===void 0?{}:{materialization:C},type:E,mode:ee,size:j,...R===void 0?{}:{target:R},...P===void 0?{}:{inodeGroup:P}};if(d===void 0){let W=g.get(B);if(W){if(o.decoder!=="zip-v1"||L.type!=="hardlink"||W.inodeGroup!==L.inodeGroup)throw new Error(`Lazy tree duplicates source path ${B}`)}else{if(o.decoder==="zip-v1"&&L.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${I} does not reuse a canonical source path`);g.set(B,L)}}else if(L.materialization==="descriptor"){if(L.type!=="directory"&&L.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${I} is not structural`);if(d.has(B))throw new Error(`Lazy tree descriptor entry ${I} impersonates a source member`)}else{let W=d.get(B);if(W===void 0)throw new Error(`Lazy tree entry ${I} names absent source ${B}`);if(L.materialization==="archive-copy"||L.materialization==="archive-copy-mode"){if(L.type!=="file"||W.type!=="file"||L.materialization==="archive-copy"&&L.mode!==W.mode)throw new Error(`Lazy tree archive copy ${I} differs from its source`)}else if(L.materialization==="archive-homebrew-relocate"){if(L.type!=="file"&&L.type!=="hardlink"||W.type!==L.type||L.type==="file"&&W.mode!==L.mode)throw new Error(`Lazy tree receipt-relocated entry ${I} differs from its source`)}else if(W.type!==L.type||L.type==="symlink"&&W.target!==L.target||L.type!=="hardlink"&&W.mode!==L.mode)throw new Error(`Lazy tree archive entry ${I} differs from its source`)}h.push(L),y.set(I,L)}for(let S of h){let b=S.vfsPath.split("/").filter(Boolean);for(let E=1;E({path:S.vfsPath,type:S.type,mode:S.mode,size:S.size,target:S.target,inodeGroup:S.inodeGroup})),"Lazy tree");if(d!==void 0){let S=new Set;for(let b of h){if(b.materialization!=="archive-homebrew-relocate")continue;let E=d.get(b.sourcePath),k=E.type==="file"?E:m.get(E.sourcePath);if(k?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${b.vfsPath} is not regular`);S.add(k.sourcePath)}for(let b of h){if(b.materialization==="descriptor"||b.type!=="file"&&b.type!=="hardlink")continue;let E=d.get(b.sourcePath),k=E.type==="file"?E:m.get(E.sourcePath);if(k?.type!=="file"||!S.has(k.sourcePath)&&b.size!==k.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 E=d.get(b.sourcePath),k=y.get(b.target),x=m.get(E.sourcePath);if(E.target!==k?.sourcePath||x?.type!=="file"||x.mode!==b.mode||k?.mode!==b.mode)throw new Error(`Lazy tree hardlink ${b.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(d===void 0?g.size:d.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesb.vfsPath===S||b.vfsPath.startsWith(`${S}/`)))throw new Error(`Lazy tree activation root ${S} is not owned by its inventory`);let v=new Map;for(let S of h)S.type==="file"&&v.set(S.inodeGroup,S);if(v.size!==w.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:h,mountPrefix:s,activation:u,canonicalByGroup:v}}function on(r){return JSON.stringify([r.sourcePath,r.type,r.inodeGroup,r.target])}function Si(r,e){let t=nr(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!==nn)throw new Error("Serialized legacy lazy archive has an unsupported kind");let n=Te(t.url,"Serialized legacy lazy archive URL",ir),i=ln(t.mountPrefix),o=Tt(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=sn(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==n||!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=ke(t.entries,"Serialized legacy lazy archive entries",1,ft).map((c,l)=>{let u=nr(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}`),f=fe(u.vfsPath,!0,`Serialized legacy lazy archive entry ${l} VFS path`);if(s.has(f))throw new Error(`Serialized legacy lazy archive duplicates path ${f}`);s.add(f);let h=ie(u.ino,`Serialized legacy lazy archive entry ${f} inode`,1,Number.MAX_SAFE_INTEGER),y=u.generation===void 0?void 0:ie(u.generation,`Serialized legacy lazy archive entry ${f} generation`,0,Number.MAX_SAFE_INTEGER),g=u.dataSequence===void 0?void 0:ie(u.dataSequence,`Serialized legacy lazy archive entry ${f} data sequence`,0,Number.MAX_SAFE_INTEGER),d=ie(u.size,`Serialized legacy lazy archive entry ${f} size`,0,tn);if(u.isSymlink!==!1||u.deleted!==!1||u.materialized!==void 0&&u.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${f} is not pending`);if(u.type!==void 0&&u.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${f} has an invalid type`);let m=u.archivePath===void 0?void 0:fe(u.archivePath,!1,`Serialized legacy lazy archive entry ${f} archive path`),p=u.sourcePath===void 0?void 0:fe(u.sourcePath,!1,`Serialized legacy lazy archive entry ${f} source path`),w=u.inodeGroup===void 0?void 0:Te(u.inodeGroup,`Serialized legacy lazy archive entry ${f} inode group`,cn);if(u.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${f} has a link target`);return{vfsPath:f,ino:h,...y===void 0?{}:{generation:y},...g===void 0?{}:{dataSequence:g},size:d,isSymlink:!1,deleted:!1,materialized:!1,...m===void 0?{}:{archivePath:m},...p===void 0?{}:{sourcePath:p},type:"file",...w===void 0?{}:{inodeGroup:w}}});return{kind:nn,url:n,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:a}}function Xo(r,e){let t=lt(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=xi(t.content,t.inventory,t.mountPrefix,t.activation);if(e===rn!=(n.content.source===void 0))throw new Error(e===rn?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=Te(t.url,"Serialized lazy tree URL",ir);if(i!==n.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=Tt(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 s=new Map(n.entries.map(f=>[f.vfsPath,f])),a=new Map(n.entries.map(f=>[on(f),f])),c=ke(t.entries,"Serialized lazy tree entries",0,ft),l=new Set,u=c.map((f,h)=>{let y=nr(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 ${h}`),g=fe(y.vfsPath,!0,`Serialized lazy tree entry ${h} VFS path`);if(l.has(g))throw new Error(`Serialized lazy tree duplicates pending path ${g}`);l.add(g);let d=fe(y.sourcePath,!1,`Serialized lazy tree entry ${h} source path`),m=fe(y.archivePath,!1,`Serialized lazy tree entry ${h} archive path`),p=s.get(g),w=a.get(on({sourcePath:d,type:typeof y.type=="string"?y.type:void 0,inodeGroup:typeof y.inodeGroup=="string"?y.inodeGroup:void 0,target:typeof y.target=="string"?y.target:void 0}))??p;if(!w||w.type!=="file"&&w.type!=="hardlink"||p?.inodeGroup!==void 0&&p.inodeGroup!==w.inodeGroup)throw new Error(`Serialized lazy tree entry ${g} is absent from its inventory`);let v=n.canonicalByGroup.get(w.inodeGroup);if(y.type!==w.type||y.inodeGroup!==w.inodeGroup||y.size!==w.size||m!==v?.sourcePath||y.target!==w.target||y.isSymlink!==!1||y.deleted!==!1||y.materialized!==!1)throw new Error(`Serialized lazy tree entry ${g} disagrees with its inventory`);let S=ie(y.ino,`Serialized lazy tree entry ${g} inode`,1,Number.MAX_SAFE_INTEGER),b=ie(y.generation,`Serialized lazy tree entry ${g} generation`,0,Number.MAX_SAFE_INTEGER),E=ie(y.dataSequence,`Serialized lazy tree entry ${g} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:g,ino:S,generation:b,dataSequence:E,size:w.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:m,sourcePath:d,type:w.type,inodeGroup:w.inodeGroup,...w.target===void 0?{}:{target:w.target}}});return{kind:e,content:n.content,inventory:n.entries,activation:n.activation,url:i,mountPrefix:n.mountPrefix,integrity:o,materialized:!1,entries:u}}async function Qn(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=globalThis.crypto?.subtle;if(!n)throw new Error(`Lazy ${e} integrity verification is unavailable`);let i=new Uint8Array(r.byteLength);i.set(r);let o=new Uint8Array(await n.digest("SHA-256",i)),s=Array.from(o,a=>a.toString(16).padStart(2,"0")).join("");if(s!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${s} does not match expected ${t.sha256}`)}var an=class r{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyFetch=e=>globalThis.fetch(e);constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&at)===Jn&&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=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,i=new Map;for(let s of t.entries.values()){if(s.deleted||s.materialized||s.generation===void 0)continue;let a=r.inodeKey(s.ino,s.generation);i.has(a)||i.set(a,s)}let o=new Map;for(let[s,a]of i){let c=e.get(s);if(!(!c||c.dataSequence!==(a.dataSequence??0))){for(let l of c.paths)o.set(l,{...a,ino:c.ino,generation:c.generation,dataSequence:c.dataSequence,deleted:!1,materialized:!1});c.paths.length>0&&this.lazyArchiveInodes.set(s,t)}}t.entries=o,t.materialized=o.size===0&&!n}}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}lazyArchiveForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyArchiveInodes.get(t);if(!n)return;let i=Array.from(n.entries.values()).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return n;this.lazyArchiveInodes.delete(t);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 o=Array.from(i.entries.entries()).find(([,s])=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized)?.[0];return o===void 0?null:{token:i,path:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(n=>!n.materialized&&n.content!==void 0&&n.inventory!==void 0&&n.activation!==void 0&&Array.from(n.entries.values()).every(i=>i.deleted||i.materialized||i.isSymlink)&&n.activation.roots.some(i=>i==="/"||e===i||e.startsWith(`${i}/`)));if(t)return{token:t,path:e,directGroup:t};try{let n=this.fs.stat(e),i=this.lazyBackingForStat(n);return i?{token:i.token,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:n}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(n)===i&&this.lazyPreparations.delete(n),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(n,i),i}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 o=this.lazyBackingForPath(e);if(!o)return;n=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(n?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=n.error instanceof Error?n.error.message:String(n.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=n.error,s}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,o=n.length>1?n.replace(/\/+$/,""):n,s=`${i}/`,a=`${o}/`,c=r.inodeKey(e.ino,e.generation),l=(e.mode&at)===Xt,u=f=>f===i?o:l&&f.startsWith(s)?a+f.slice(s.length):f;for(let[f,h]of this.lazyFiles)!l&&f!==c||(h.paths=new Set(Array.from(h.paths,u)),h.path=u(h.path));for(let f of this.lazyArchiveGroups){let h=new Map;for(let[y,g]of f.entries){let d=g.generation===void 0?null:r.inodeKey(g.ino,g.generation);h.set(l||d===c?u(y):y,g)}f.entries=h,f.inventory&&(f.inventory=f.inventory.map(y=>({...y,vfsPath:u(y.vfsPath),...y.type==="hardlink"&&y.target!==void 0?{target:u(y.target)}:{}}))),f.activation&&(f.activation={...f.activation,roots:f.activation.roots.map(u)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new r(Se.mkfs(e,t))}static fromExisting(e){return new r(Se.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 o=this.serializeLazyEntries(),s=this.serializeLazyArchiveEntries(),a=new t(n.byteLength);new Uint8Array(a).set(n);let c=new r(Se.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(o),c.importLazyArchiveEntries(s);let l=Math.min(e,Math.max(n.byteLength,Bo)),u=new t(l,{maxByteLength:e}),f=r.create(u,e);f.setImageMetadata(this.imageMetadata);let h=new Set(o.flatMap(g=>g.paths??[g.path])),y=new Set;for(let g of s)if(!g.materialized)for(let d of g.entries)!d.deleted&&!d.isSymlink&&y.add(d.vfsPath);return c.copyPathToFreshFileSystem("/",f,h,y,new Map),f.importLazyEntries(o.map(g=>{let d=f.fs.lstat(g.path);return{...g,ino:d.ino,generation:d.generation,dataSequence:d.dataSequence}})),f.importLazyArchiveEntries(s.map(g=>({...g,entries:g.entries.map(d=>{if(d.deleted)return{...d,ino:0,generation:void 0};let m=f.fs.lstat(d.vfsPath);return{...d,ino:m.ino,generation:m.generation,dataSequence:m.dataSequence}})}))),f}getImageMetadata(){return Go(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:sr(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e){this.lazyFetch=e}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Ho()};for(let n of this.lazyDownloadListeners)try{n(t)}catch{}}async fetchLazyBytes(e){let t=0,n=e.integrity?.bytes??e.fallbackTotalBytes,i={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};this.emitLazyDownload({...i,status:"started",loadedBytes:t,totalBytes:n});try{let o=await this.lazyFetch(e.url);if(!o.ok)throw new Error(`HTTP ${o.status}`);if(n=Vo(o.headers)??n,e.integrity&&n!==void 0&&n!==e.integrity.bytes)throw new Error(`Lazy ${e.kind} byte count ${n} does not match expected ${e.integrity.bytes}`);if(!o.body){let l=new Uint8Array(await o.arrayBuffer());return t=l.byteLength,await Qn(l,e.kind,e.integrity),this.emitLazyDownload({...i,status:"progress",loadedBytes:t,totalBytes:n??t}),this.emitLazyDownload({...i,status:"complete",loadedBytes:t,totalBytes:n??t}),l}let s=o.body.getReader(),a=[];try{for(;;){let{done:l,value:u}=await s.read();if(l)break;if(u){if(a.push(u),t+=u.byteLength,e.integrity&&t>e.integrity.bytes)throw await s.cancel(),new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...i,status:"progress",loadedBytes:t,totalBytes:n})}}}finally{s.releaseLock()}let c=qo(a,t);return await Qn(c,e.kind,e.integrity),this.emitLazyDownload({...i,status:"complete",loadedBytes:t,totalBytes:n??t}),c}catch(o){let s=o instanceof Error?o.message:String(o);throw this.emitLazyDownload({...i,status:"error",loadedBytes:t,totalBytes:n,error:s}),o}}registerLazyFile(e,t,n,i=493){let o=e.split("/").filter(Boolean),s="";for(let c=0;c({...d})),activation:u,entries:new Map},y=d=>{let m=d.split("/").filter(Boolean),p="";for(let w=0;wm.vfsPath.split("/").length-p.vfsPath.split("/").length))if(d.type==="directory"){y(d.vfsPath);try{this.fs.mkdir(d.vfsPath,d.mode),this.fs.chmod(d.vfsPath,d.mode)}catch{if((this.fs.lstat(d.vfsPath).mode&at)!==Xt)throw new Error(`Lazy tree directory collides at ${d.vfsPath}`)}}for(let d of c){if(d.type!=="symlink")continue;y(d.vfsPath),this.fs.symlink(d.target,d.vfsPath);let m=this.fs.lstat(d.vfsPath);h.entries.set(d.vfsPath,{ino:m.ino,generation:m.generation,dataSequence:m.dataSequence,size:d.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:d.sourcePath,sourcePath:d.sourcePath,type:"symlink",target:d.target})}let g=new Map;for(let d of c){if(d.type!=="file")continue;y(d.vfsPath);let m=this.fs.createLazyStub(d.vfsPath,d.mode);this.invalidateLazyData(m),g.set(d.inodeGroup,m);let p={ino:m.ino,generation:m.generation,dataSequence:m.dataSequence,size:d.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:d.sourcePath,sourcePath:d.sourcePath,type:"file",inodeGroup:d.inodeGroup};h.entries.set(d.vfsPath,p),this.lazyArchiveInodes.set(r.inodeKey(m.ino,m.generation),h)}for(let d of c){if(d.type!=="hardlink")continue;let m=f.get(d.inodeGroup);y(d.vfsPath),this.fs.link(m.vfsPath,d.vfsPath);let p=this.fs.lstat(d.vfsPath),w=g.get(d.inodeGroup);if(p.ino!==w.ino||p.generation!==w.generation)throw new Error(`Lazy tree hardlink ${d.vfsPath} did not share its inode`);h.entries.set(d.vfsPath,{ino:p.ino,generation:p.generation,dataSequence:p.dataSequence,size:d.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:m.sourcePath,sourcePath:d.sourcePath,type:"hardlink",inodeGroup:d.inodeGroup,target:d.target})}return this.lazyArchiveGroups.push(h),h}registerLazyTreeWithMaterializationHandle(e,t,n="/",i){let o=this.registerLazyTreeInternal(e,t,n,i,!0),s=Object.freeze({[_o]:!0});return this.deferredTreeMaterializationHandles.set(s,o),s}registerLazyArchiveFromEntries(e,t,n,i,o){let s=Uo(e,t,n,i);s.some(({entry:c})=>!c.isDirectory&&!c.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let a={...o?{content:sn({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:s.reduce((c,l)=>c+l.entry.uncompressedSize,0),sourceEntryCount:s.length,transports:[e]})}:{},url:e,mountPrefix:n,integrity:Tt(o),materialized:!1,entries:new Map};for(let{entry:c,vfsPath:l}of s){if(c.isDirectory)continue;let u=l.split("/").filter(Boolean),f="";for(let h=0;hc.deleted||c.materialized),this.lazyArchiveGroups.push(a),a}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0)}importLazyArchiveEntriesInternal(e,t,n){let i=ke(e,"Serialized lazy archive groups",0,Mo).map((a,c)=>{if(typeof a!="object"||a===null||Array.isArray(a))throw new Error(`Serialized lazy archive group ${c} must be an object`);let l=a.kind;if(l===rn||l===mi)return Xo(a,l);if(l===nn)return Si(a,!1);if(l!==void 0)throw new Error(`Serialized lazy archive group ${c} has an unsupported kind`);if(n)throw new Error(`Serialized lazy archive group ${c} is missing its kind discriminator`);return Si(a,!0)});Ei([...this.serializeLazyArchiveEntries(),...i]);let o=[],s=new Map;for(let a of i){let c=new Map,l=a.mountPrefix.replace(/\/+$/,""),u=a.content!==void 0&&a.inventory!==void 0&&a.activation!==void 0,f=u?new Map(a.inventory.map(p=>[p.vfsPath,p])):null,h=u?new Map(a.inventory.map(p=>[on(p),p])):null,y=new Map,g=new Map;for(let p of a.entries){let w=null,v=a.materialized||p.materialized===!0||p.isSymlink;if(!p.deleted&&!v){if((p.generation===void 0||p.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{w=this.fs.lstat(p.vfsPath)}catch{if(u)throw new Error(`Serialized lazy tree stub ${p.vfsPath} is missing from the filesystem`);continue}if(w.ino!==p.ino){if(u)throw new Error(`Serialized lazy tree stub ${p.vfsPath} has a different inode`);continue}if(p.generation!==void 0&&w.generation!==p.generation){if(u)throw new Error(`Serialized lazy tree stub ${p.vfsPath} has a different generation`);continue}if(p.dataSequence===void 0){if(!r.canAdoptLegacyLazyStub(w)){if(u)throw new Error(`Serialized lazy tree stub ${p.vfsPath} is not pristine`);continue}}else if(w.dataSequence!==p.dataSequence){if(u)throw new Error(`Serialized lazy tree stub ${p.vfsPath} has a different data sequence`);continue}if(u){let b=f.get(p.vfsPath),E=h.get(on(p))??b;if(!E||(w.mode&at)!==Jn||w.size!==0||(w.mode&4095)!==E.mode||b?.inodeGroup!==void 0&&b.inodeGroup!==E.inodeGroup)throw new Error(`Serialized lazy tree stub ${p.vfsPath} disagrees with its inventory`);let k=r.inodeKey(w.ino,w.generation),x=p.inodeGroup,I=y.get(x),B=g.get(k);if(I!==void 0&&I!==k||B!==void 0&&B!==x)throw new Error(`Serialized lazy tree inode group ${x} disagrees with the filesystem`);y.set(x,k),g.set(k,x)}}c.set(p.vfsPath,{ino:p.ino,generation:w?.generation??p.generation,dataSequence:w?.dataSequence??p.dataSequence,size:p.size,isSymlink:p.isSymlink,deleted:p.deleted,materialized:v,archivePath:p.archivePath??p.vfsPath.slice(l.length+1),sourcePath:p.sourcePath??p.archivePath??p.vfsPath.slice(l.length+1),type:p.type??(p.isSymlink?"symlink":"file"),inodeGroup:p.inodeGroup,target:p.target})}let d=a.content===void 0?void 0:sn(a.content),m={content:d,url:d?.transports[0]??a.url,mountPrefix:a.mountPrefix,integrity:d?{sha256:d.sha256,bytes:d.bytes}:Tt(a.integrity),materialized:a.materialized||!(d&&a.inventory)&&Array.from(c.values()).every(p=>p.deleted||p.materialized),inventory:a.inventory?.map(p=>({...p})),activation:a.activation?{mode:a.activation.mode,capabilities:[...a.activation.capabilities],roots:[...a.activation.roots]}:void 0,entries:c};if(o.push(m),!m.materialized){for(let[,p]of c)if(!p.deleted&&!p.materialized&&p.generation!==void 0){let w=r.inodeKey(p.ino,p.generation),v=s.get(w);if(v!==void 0&&v!==m)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}`);s.set(w,m)}}}this.lazyArchiveGroups.push(...o);for(let[a,c]of s)this.lazyArchiveInodes.set(a,c)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups)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=Array.from(t.entries,([o,s])=>({vfsPath:o,ino:s.ino,generation:s.generation,dataSequence:s.dataSequence,size:s.size,isSymlink:s.isSymlink,deleted:s.deleted,materialized:s.materialized,archivePath:s.archivePath,sourcePath:s.sourcePath,type:s.type,inodeGroup:s.inodeGroup,target:s.target})).filter(o=>!o.deleted&&!o.materialized);if(n.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let i=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(i&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push(i?{kind:t.content.source===void 0?rn:mi,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:n}:{kind:nn,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:n})}return e}exportLazyArchiveEntries(){return this.reconcileLazyIdentityState(this.fs.identityState()),this.serializeLazyArchiveEntries()}pendingDeferredTreeUsage(){return this.reconcileLazyIdentityState(this.fs.identityState()),bi(this.serializeLazyArchiveEntries())}assertCanAppendDeferredTreeUsage(e){rr(e);let t=this.pendingDeferredTreeUsage();rr({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=>!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(n=>!n.deleted&&!n.materialized))).length>=me.maxGroups)throw new Error(`Cannot register another lazy archive group: ${me.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,n=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!o.materialized&&o.activation?.mode==="boot-prefetch"),t=0,n,i=Array.from({length:Math.min(e.length,No)},async()=>{for(;n===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){n??=s}}});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");if(n.materialized)return!1;let i=this.lazyPreparations.get(n);if(i!==void 0)return i.promise;let o=new Uint8Array(t.byteLength);o.set(t);let s={status:"pending",promise:Promise.resolve(!1)};s.promise=Promise.resolve().then(async()=>(await Qn(o,"tree",n.integrity),await this.materializeArchiveBytes(n,o),!0)).then(a=>(s.status="fulfilled",a),a=>{throw s.status="rejected",s.error=a,a}),s.promise.catch(()=>{}),this.lazyPreparations.set(n,s);try{return await s.promise}finally{this.lazyPreparations.get(n)===s&&this.lazyPreparations.delete(n)}}async prepareLazyTreeGroup(e){if(e.materialized)return!1;let t={token:e,path:e.activation?.roots[0]??e.mountPrefix,directGroup:e},n=this.lazyPreparations.get(e)??this.startLazyPreparation(t);try{return await n.promise}finally{this.lazyPreparations.get(e)===n&&this.lazyPreparations.delete(e)}}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 s=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size});for(let a=0;a<3;a++){if(this.lazyFiles.get(n)!==i)return!1;for(let c of new Set([e,...i.paths]))if(this.fs.replaceIfIdentity(c,i.ino,i.generation,i.dataSequence,s))return i.path=c,this.lazyFiles.delete(n),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(n);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(n)):!1}async decodeAndValidateLazyTree(e,t){let n=e.content,i=e.inventory;if(!n||!i)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,s=new Map(i.map(f=>[f.vfsPath,f]));if(n.source!==void 0)for(let f of n.source.entries)o.set(f.sourcePath,f);else for(let f of i){if(f.type==="hardlink"){let y=s.get(f.target);if(!y)throw new Error(`Lazy tree hardlink target disappeared: ${f.target}`);if(f.sourcePath===y.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:s.get(f.target)?.sourcePath}:{}})}let a=new Map,c=0;if(n.decoder==="zip-v1"){let{parseZipCentralDirectory:f,extractZipEntryBounded:h}=await Promise.resolve().then(()=>(Kn(),Gn)),y=f(t);if(y.length!==n.sourceEntryCount||y.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let g of y){let d=g.isDirectory?g.fileName.replace(/\/$/,""):g.fileName;if(a.has(d))throw new Error(`Lazy ZIP tree duplicates source member ${d}`);let m=o.get(d);if(!m)throw new Error(`Lazy ZIP tree has undeclared source member ${d}`);if(c+=g.uncompressedSize,c>n.expandedBytes||g.uncompressedSize!==m.size)throw new Error(`Lazy ZIP tree member ${d} exceeds its inventory`);if((g.isDirectory?"directory":g.isSymlink?"symlink":"file")!==m.type||(g.mode&4095)!==m.mode)throw new Error(`Lazy ZIP tree member ${d} differs from inventory`);if(g.isDirectory)a.set(d,{type:"directory",mode:g.mode});else{let w=h(t,g,m.size);if(g.isSymlink){let v;try{v=new TextDecoder("utf-8",{fatal:!0}).decode(w)}catch{throw new Error(`Lazy ZIP tree symlink ${d} is not UTF-8`)}a.set(d,{type:"symlink",mode:g.mode,target:v})}else a.set(d,{type:"file",mode:g.mode,data:w})}}}else{let{parseTarGzip:f}=await Promise.resolve().then(()=>(gi(),yi)),h=f(t,{label:`Lazy tree ${n.sha256}`,limits:{maxCompressedBytes:n.bytes,maxUncompressedBytes:n.expandedBytes,maxEntries:n.sourceEntryCount}});c=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let y of h){if(a.has(y.path))throw new Error(`Lazy TAR tree duplicates source member ${y.path}`);y.type==="file"?a.set(y.path,{type:"file",mode:y.mode,data:y.data}):y.type==="directory"?a.set(y.path,{type:"directory",mode:y.mode}):a.set(y.path,{type:y.type,mode:y.mode,target:y.linkName})}}if(a.size!==n.sourceEntryCount||a.size!==o.size||c!==n.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[f,h]of o){let y=a.get(f);if(!y)throw new Error(`Lazy tree is missing source member ${f}`);let g=h.type;if(y.type!==g)throw new Error(`Lazy tree member ${f} is ${y.type}, expected ${g}`);if((y.mode&4095)!==h.mode)throw new Error(`Lazy tree member ${f} mode differs from inventory`);if(g==="file"&&y.data?.byteLength!==h.size)throw new Error(`Lazy tree member ${f} size differs from inventory`);if(g==="symlink"&&y.target!==h.target)throw new Error(`Lazy tree symlink ${f} target differs from inventory`);if(g==="hardlink"&&y.target!==h.target)throw new Error(`Lazy tree hardlink ${f} target differs from inventory`)}let l=new Set(i.flatMap(f=>f.materialization==="archive-homebrew-relocate"?[f.sourcePath]:[]));if(n.source!==void 0){let f=new Map(n.source.entries.map(g=>[g.sourcePath,g])),h=ki(n.source.entries),y=n.source.entries.filter(g=>g.sourcePath==="INSTALL_RECEIPT.json"||g.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(y.length>1)throw new Error(`Lazy Homebrew bottle has ${y.length} INSTALL_RECEIPT.json source members, expected at most one`);if(y.length===0){if(l.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let g=y[0],d=g.type==="file"?g:h.get(g.sourcePath),m=d===void 0?void 0:a.get(d.sourcePath);if(d?.type!=="file"||m?.type!=="file"||m.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let p=Kr(m.data),w=g.sourcePath.lastIndexOf("/"),v=w<0?"":g.sourcePath.slice(0,w),S=new Set(p.changedFiles.map(E=>v.length===0?E:`${v}/${E}`));if(l.size!==S.size||[...l].some(E=>!S.has(E)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let b=new Set;for(let E of S){let k=f.get(E),x=k?.type==="file"?k:k===void 0?void 0:h.get(k.sourcePath),I=x===void 0?void 0:a.get(x.sourcePath);if(x?.type!=="file"||I?.type!=="file"||I.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${E} is not regular`);b.has(x.sourcePath)||(I.data=Zr(I.data,p,E),b.add(x.sourcePath))}}}else if(l.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let u=new Map;for(let f of i){if(f.type!=="file"||f.materialization==="descriptor")continue;let h=a.get(f.sourcePath);if(h?.type!=="file"||!h.data)throw new Error(`Lazy tree has no file content for ${f.sourcePath}`);u.set(f.sourcePath,h.data)}return u}async ensureArchiveMaterialized(e,t){if(e.materialized)return;let n=e.content!==void 0&&e.inventory!==void 0,i=n?e.content.transports:[e.url],o=[],s=null;for(let[a,c]of i.entries())try{s=await this.fetchLazyBytes({id:`archive:${e.mountPrefix}:${e.content?.sha256??c}:${a}`,kind:n?"tree":"archive",url:c,mountPrefix:e.mountPrefix,integrity:e.integrity});break}catch(l){o.push(l instanceof Error?l.message:String(l))}if(s===null)throw new Error(`All ${i.length} lazy ${n?"tree":"archive"} transports failed: ${o.join("; ")}`);await this.materializeArchiveBytes(e,s,t)}async materializeArchiveBytes(e,t,n){if(e.materialized)return;let o=e.content!==void 0&&e.inventory!==void 0?await this.decodeAndValidateLazyTree(e,t):null,{parseZipCentralDirectory:s,extractZipEntry:a}=await Promise.resolve().then(()=>(Kn(),Gn)),c=o?[]:s(t),l=new Map;for(let y of c){if(l.has(y.fileName))throw new Error(`Lazy archive contains duplicate member: ${y.fileName}`);l.set(y.fileName,y)}let u=e.mountPrefix.replace(/\/+$/,""),f=new Map;for(let[y,g]of e.entries){if(g.deleted||g.materialized)continue;let d=g.archivePath??y.slice(u.length+1),m=o?void 0:l.get(d),p=o?.get(d);if(o){if(p===void 0||p.byteLength!==g.size)throw new Error(`Lazy tree member ${d} does not match its registered metadata`)}else if(m===void 0||m.isDirectory||m.isSymlink||m.uncompressedSize!==g.size)throw new Error(`Lazy archive member ${d} does not match its registered metadata`);if(g.generation===void 0)continue;let w=r.inodeKey(g.ino,g.generation),v=f.get(w);if(v&&v.archivePath!==d)throw new Error(`Lazy archive aliases for inode ${w} name different members`);if(!v){let S=p??a(t,m);if(S.byteLength!==g.size)throw new Error(`Lazy archive member ${d} extracted ${S.byteLength} bytes, expected ${g.size}`);f.set(w,{archivePath:d,content:S})}}let h=n?r.inodeKey(n.ino,n.generation):null;for(let y=0;y<3;y++){let g=new Map;for(let[d,m]of e.entries){if(m.deleted||m.materialized||m.generation===void 0)continue;let p=r.inodeKey(m.ino,m.generation);if(this.lazyArchiveInodes.get(p)!==e)continue;let w=f.get(p);if(!w)throw new Error(`Lazy archive has no extracted content for inode ${p}`);let v=g.get(p);v||(v={ino:m.ino,generation:m.generation,dataSequence:m.dataSequence??0,paths:new Set,content:w.content},g.set(p,v)),v.paths.add(d),n&&n.ino===m.ino&&n.generation===m.generation&&v.paths.add(n.path)}if(g.size>0&&!this.fs.replaceManyIfIdentities(Array.from(g.values(),m=>({paths:Array.from(m.paths),expectedIno:m.ino,expectedGeneration:m.generation,expectedDataSequence:m.dataSequence,data:m.content})))){if(this.reconcileLazyIdentityState(this.fs.identityState()),h&&!this.lazyArchiveInodes.has(h))return;continue}for(let[d,m]of g){this.lazyArchiveInodes.delete(d);for(let p of e.entries.values())p.ino===m.ino&&p.generation===m.generation&&(p.materialized=!0)}if(e.materialized=Array.from(e.entries.values()).every(d=>d.deleted||d.materialized),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),h&&!this.lazyArchiveInodes.has(h)))return}if(h&&this.lazyArchiveInodes.has(h))throw new Error(`Lazy archive member kept changing names while materializing: ${n?.path}`)}async materializeAllLazyEntries(){for(let t=0;t<3;t++){this.reconcileLazyIdentityState(this.fs.identityState());let n=this.lazyArchiveGroups.filter(s=>!s.materialized&&s.content!==void 0&&s.inventory!==void 0);if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&n.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 n)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>!t.materialized&&t.content!==void 0&&t.inventory!==void 0);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();let{bytes:t,identities:n}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(n);let i=this.serializeLazyEntries(),o=i.length>0,s=o?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(s.byteLength>Qt)throw new Error(`VFS image lazy metadata exceeds ${Qt} bytes`);let a=this.serializeLazyArchiveEntries();Ei(a);let c=a.length>0,l=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(l.byteLength>en)throw new Error(`VFS image lazy archive metadata exceeds ${en} bytes`);let u=e?.metadata===void 0?this.imageMetadata:e.metadata,f=Zo(u),h=f.byteLength>0,y=c?4+l.byteLength:0,g=h?4+f.byteLength:0,d=re+t.byteLength+4+s.byteLength+y+g,m=new Uint8Array(d),p=new DataView(m.buffer);p.setUint32(0,er,!0),p.setUint32(4,tr,!0),p.setUint32(8,(o?jn:0)|(c?Jt:0)|(c?Yn:0)|(h?Xn:0),!0),p.setUint32(12,t.byteLength,!0),m.set(t,re);let w=re+t.byteLength;if(p.setUint32(w,s.byteLength,!0),s.byteLength>0&&m.set(s,w+4),c){let v=w+4+s.byteLength;p.setUint32(v,l.byteLength,!0),m.set(l,v+4)}if(h){let v=w+4+s.byteLength+y;p.setUint32(v,f.byteLength,!0),m.set(f,v+4)}return m}static readImageMetadata(e){let t=Yt(e);if(!(t.flags&Xn))return null;let{metadataOffset:n}=wi(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 m=n.subarray(g+4,g+4+d),p=ke(vi(m,"VFS image lazy metadata"),"VFS image lazy entries",0,ft);y.importLazyEntriesInternal(p,!0)}if(o&Jt){let m=a.archiveOffset,p=i.getUint32(m,!0);if(p>0){let w=n.subarray(m+4,m+4+p),v=vi(w,"VFS image lazy archive metadata");y.importLazyArchiveEntriesInternal(v,!0,!!(o&Yn))}}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 o of i.entries.values())if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,n){(t&It)===0&&!((t&kt)!==0&&(t&Bn)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,n);return(t&It)!==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 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 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 s=this.fs.writeAt(e,t.subarray(0,i),n);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}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 kn(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 kn(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 o=this.lazyArchiveInodes.get(n);if(o){let s=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(n)}else s&&o.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 o=!1;if(i){let s=r.inodeKey(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 l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(s)):l&&c.entries.delete(t)}}o||this.rewriteLazyNamespacePaths(n,e,t)}link(e,t){let n=this.fs.link(e,t),i=r.inodeKey(n.ino,n.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let a=Array.from(s.entries.values()).find(c=>c.ino===n.ino&&c.generation===n.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,n){this.fs.chown(e,t,n)}lchown(e,t,n){this.fs.lchown(e,t,n)}createFileWithOwner(e,t,n,i,o){let s=this.open(e,577,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),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,o){let s=this.lstat(e),a=s.mode&at,c=s.mode&4095;if(a===Xt){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 y=this.readdir(h);if(!y)break;y.name==="."||y.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${y.name}`:`${e}/${y.name}`,t,n,i,o)}}finally{this.closedir(h)}r.applyTimes(t,e,s);return}let l=s.nlink>1?`${s.dev}:${s.ino}`:null,u=l?o.get(l):void 0;if(u){t.link(u,e);return}if(a===Po){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),l&&o.set(l,e);return}if(a!==Jn)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(n.has(e)||i.has(e)){t.createFileWithOwner(e,c,s.uid,s.gid,new Uint8Array(0)),r.applyTimes(t,e,s),l&&o.set(l,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,c),l&&o.set(l,e)}copyRegularFileToFreshFileSystem(e,t,n,i){let o=this.open(e,Oo,0),s=null;try{s=t.open(e,To,i);let a=new Uint8Array(Math.min(Ro,Math.max(1,n.size))),c=n.size;for(;c>0;){let l=Math.min(a.byteLength,c),u=this.read(o,a,null,l);if(u<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let f=0;for(;f!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(r)}`);return r}var We=new Set(["wasm32","wasm64"]);function Be(r){if(aa(r),!r.startsWith("programs/"))return r;let e=r.slice(9),t=e.split("/",1)[0];return We.has(t)?r:`programs/wasm32/${e}`}function ca(r,e=F(gn(),"wasm")){let t=Be(r),n=[F(e,t)];return r==="kernel.wasm"?n.push(F(e,"kandelo-kernel.wasm")):r==="userspace.wasm"?n.push(F(e,"wasm_posix_userspace.wasm")):r==="rootfs.vfs"&&n.push(F(e,"rootfs.vfs")),n}var un=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function Ni(){let r=[],e=!1;try{let n=Ze();e=!0;for(let[i,o]of[["local-binaries",F(n,"local-binaries")],["binaries",F(n,"binaries")]])r.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[F(o,Be(s))]}})}catch{}let t=F(gn(),"wasm");return r.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(n){return ca(n,t)}}),r}function dt(r,e){return new Error(`Invalid package manifest ${r}: ${e}`)}function de(r){try{return hn(r),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Ai(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||r.split("/").some(n=>!n||n==="."||n===".."))throw dt(e,`${t} must be a normalized portable relative path`);return r}function fn(r,e,t,n=!0){if(r.length===0||r==="."||r===".."||r.includes("/")||r.includes("\\")||r.includes("\0")||!n&&r.includes("@"))throw dt(e,`${t} must be a safe single path component`);return r}var Li="kandelo-program-packages-v2",we="program-packages.json",_i=null,dn=null,ar=0;function Mi(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let r=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(e=>e.startsWith("~/")&&process.env.HOME!==void 0?F(process.env.HOME,e.slice(2)):yn(e)?ge(e):(r??=Ze(),ge(r,e)))}try{return[F(Ze(),"packages","registry")]}catch{return null}}function la(){let r;try{r=Ze()}catch{return null}if(!Rt(F(r,"tools","xtask","Cargo.toml"))||!Rt(F(r,"scripts","dev-shell.sh")))return null;try{let e=pe(dr()),t=pe(r);return[F(t,"host"),F(t,"scripts")].some(i=>Rt(i)&&pr(pe(i),e))?t:null}catch{return null}}function ur(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(` +var hs=Object.defineProperty;var bn=(r,e,t)=>()=>{if(t)throw t[0];try{return r&&(e=r(r=0)),e}catch(n){throw t=[n],n}};var xr=(r,e)=>{for(var t in e)hs(r,t,{get:e[t],enumerable:!0})};import{createRequire as ho}from"module";function gi(r,e){return yi(r,{i:2},e&&e.out,e&&e.dictionary)}var yo,ft,go,po,J,ct,mo,ai,ci,wo,li,ft,fi,vo,ui,Eo,_c,Zn,be,M,Rt,Bt,M,M,M,M,di,M,So,zo,Gn,ge,Wn,hi,tn,bo,fe,yi,ko,xo,lt,pi,Io,Ao,Hn=bn(()=>{yo=ho("/");try{ft=yo("worker_threads"),go=ft.Worker,po=ft.isMarkedAsUntransferable}catch{}J=Uint8Array,ct=Uint16Array,mo=Int32Array,ai=new J([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]),ci=new J([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]),wo=new J([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),li=function(r,e){for(var t=new ct(31),n=0;n<31;++n)t[n]=e+=1<>1|(M&21845)<<1,be=(be&52428)>>2|(be&13107)<<2,be=(be&61680)>>4|(be&3855)<<4,Zn[M]=((be&65280)>>8|(be&255)<<8)>>1;Rt=(function(r,e,t){for(var n=r.length,i=0,o=new ct(e);i>c]=l}else for(a=new ct(n),i=0;i>15-r[i]);return a}),Bt=new J(288);for(M=0;M<144;++M)Bt[M]=8;for(M=144;M<256;++M)Bt[M]=9;for(M=256;M<280;++M)Bt[M]=7;for(M=280;M<288;++M)Bt[M]=8;di=new J(32);for(M=0;M<32;++M)di[M]=5;So=Rt(Bt,9,1),zo=Rt(di,5,1),Gn=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},Wn=function(r,e){var t=e/8|0;return(r[t]|r[t+1]<<8|r[t+2]<<16)>>(e&7)},hi=function(r){return(r+7)/8|0},tn=function(r,e,t){return(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length),new J(r.subarray(e,t))},bo=["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||bo[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,fe),!t)throw n;return n},yi=function(r,e,t,n){var i=r.length,o=n?n.length:0;if(!i||e.f&&!e.l)return t||new J(0);var s=!t,a=s||e.i!=2,c=e.i;s&&(t=new J(i*3));var l=function(_e){var Le=t.length;if(_e>Le){var Gt=new J(Math.max(Le*2,_e));Gt.set(t),t=Gt}},h=e.f||0,f=e.p||0,d=e.b||0,y=e.l,g=e.d,p=e.m,w=e.n,u=i*8;do{if(!y){h=ge(r,f,1);var m=ge(r,f+1,3);if(f+=3,m)if(m==1)y=So,g=zo,p=9,w=5;else if(m==2){var S=ge(r,f,31)+257,k=ge(r,f+10,15)+4,I=S+ge(r,f+5,31)+1;f+=14;for(var A=new J(I),B=new J(19),N=0;N>4;if(v<16)A[N++]=v;else{var _=0,Z=0;for(v==16?(Z=3+ge(r,f,3),f+=2,_=A[N-1]):v==17?(Z=3+ge(r,f,7),f+=3):v==18&&(Z=11+ge(r,f,127),f+=7);Z--;)A[N++]=_}}var Me=A.subarray(0,S),re=A.subarray(S);p=Gn(Me),w=Gn(re),y=Rt(Me,p,1),g=Rt(re,w,1)}else fe(1);else{var v=hi(f)+4,E=r[v-4]|r[v-3]<<8,z=v+E;if(z>i){c&&fe(0);break}a&&l(d+E),t.set(r.subarray(v,z),d),e.b=d+=E,e.p=f=z*8,e.f=h;continue}if(f>u){c&&fe(0);break}}a&&l(d+131072);for(var gt=(1<>4;if(f+=_&15,f>u){c&&fe(0);break}if(_||fe(2),Ee<256)t[d++]=Ee;else if(Ee==256){Fe=f,y=null;break}else{var pt=Ee-254;if(Ee>264){var N=Ee-257,Ie=ai[N];pt=ge(r,f,(1<>4;je||fe(3),f+=je&15;var re=Eo[he];if(he>3){var Ie=ci[he];re+=Wn(r,f)&(1<u){c&&fe(0);break}a&&l(d+131072);var Ae=d+pt;if(d>3&1)+(e>>4&1);n>0;n-=!r[t++]);return t+(e&2)},lt=(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 J(32768),this.p=new J(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 J(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=yi(this.p,this.s,this.o);this.ondata(tn(n,t,this.s.b),this.d),this.o=tn(n,this.s.b-32768),this.s.b=this.o.length,this.p=tn(this.p,this.s.p/8|0),this.s.p&=7},r.prototype.push=function(e,t){this.e(e),this.c(t)},r})();pi=(function(){function r(e,t){this.v=1,this.r=0,lt.call(this,e,t)}return r.prototype.push=function(e,t){if(lt.prototype.e.call(this,e),this.r+=e.length,this.v){var n=this.p.subarray(this.v-1),i=n.length>3?xo(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}lt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=hi(this.s.p)+9,this.s={i:0},this.o=new J(0),this.push(new J(0),t)):t&<.prototype.c.call(this,t)},r})(),Io=typeof TextDecoder<"u"&&new TextDecoder,Ao=0;try{Io.decode(ko,{stream:!0}),Ao=1}catch{}});var jn={};xr(jn,{extractZipEntry:()=>No,extractZipEntryBounded:()=>Co,fetchZipCentralDirectory:()=>Fo,parseZipCentralDirectory:()=>Nt});function zi(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=Math.max(0,r.length-vi);for(let n=r.length-Po;n>=t;n--)if(e.getUint32(n,!0)===_o)return n;throw new Error("Zip EOCD record not found")}function Nt(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=zi(r),n=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let a=0;a>8,E;v===mi?E=p>>16&65535:m.startsWith("bin/")||m.startsWith("sbin/")||m.includes("/bin/")||m.includes("/sbin/")?E=493:E=420;let z=m.endsWith("/"),S=v===mi&&(E&To)===Oo;o.push({fileName:m,fileNameBytes:u,compressedSize:h,uncompressedSize:f,compressionMethod:l,localHeaderOffset:w,mode:E,isDirectory:z,isSymlink:S,externalAttrs:p,creatorOS:v}),s+=Vn+d+y+g}return o}function bi(r,e){if(r.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(n,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function Mo(r,e){let t=new DataView(r.buffer,r.byteOffset,r.byteLength),n=e.localHeaderOffset;if(n<0||n>r.byteLength-qn||t.getUint32(n,!0)!==wi)throw new Error(`Invalid local file header signature at offset ${n}`);let i=t.getUint16(n+8,!0),o=t.getUint16(n+26,!0),s=t.getUint16(n+28,!0),a=n+qn,c=a+o+s,l=c+e.compressedSize;if(i!==e.compressionMethod||cr.byteLength||!bi(r.subarray(a,a+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return r.subarray(c,l)}async function Fo(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 u=await fetch(r);if(!u.ok)throw new Error(`Fetch failed: ${u.status} ${u.statusText}`);let m=new Uint8Array(await u.arrayBuffer());return{entries:Nt(m),totalSize:m.length}}let i=Math.min(t,vi),o=t-i,s=await fetch(r,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let u=await fetch(r);if(!u.ok)throw new Error(`Fetch failed: ${u.status} ${u.statusText}`);let m=new Uint8Array(await u.arrayBuffer());return{entries:Nt(m),totalSize:m.length}}let a=new Uint8Array(await s.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),l=zi(a),h=c.getUint32(l+12,!0),f=c.getUint32(l+16,!0);if(f>=o){let u=t,m=new Uint8Array(u);return m.set(a,o),{entries:Nt(m),totalSize:u}}let d=f+h-1,y=await fetch(r,{headers:{Range:`bytes=${f}-${d}`}});if(y.status!==206)throw new Error(`Range request for CD failed: ${y.status}`);let g=new Uint8Array(await y.arrayBuffer()),p=t,w=new Uint8Array(p);return w.set(g,f),w.set(a,o),{entries:Nt(w),totalSize:p}}var _o,Lo,wi,vi,Po,Vn,qn,Ei,Si,mi,Oo,To,Ro,Bo,Yn=bn(()=>{"use strict";Hn();_o=101010256,Lo=33639248,wi=67324752,vi=65557,Po=22,Vn=46,qn=30,Ei=0,Si=8,mi=3,Oo=40960,To=61440,Ro=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Bo=new TextEncoder});var Pi={};xr(Pi,{DEFAULT_TAR_GZIP_LIMITS:()=>Li,TarParseError:()=>L,parseTarGzip:()=>Ko});function Ko(r,e={}){let t=e.label??"TAR gzip archive",n=Wo(e.limits,t);if(r.byteLength===0||r.byteLength>n.maxCompressedBytes)throw new L(`${t}: compressed byte count ${r.byteLength} is outside 1..${n.maxCompressedBytes}`);let i=Zo(r,t);if(i===0||i>n.maxUncompressedBytes)throw new L(`${t}: declared uncompressed byte count ${i} is outside 1..${n.maxUncompressedBytes}`);let o=Ho(r,t,i);if(o.byteLength!==i)throw new L(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-8,!0);if(Vo(o)!==s)throw new L(`${t}: gzip CRC32 mismatch`);return Go(o,t,n)}function Go(r,e,t){if(r.byteLength%ke!==0)throw new L(`${e}: TAR byte count is not block-aligned`);let n=[],i=0,o=0,s=0,a=null,c={},l=!1;for(;i+ke<=r.byteLength;){let h=r.subarray(i,i+ke);if(i+=ke,Jn(h)){if(i+ke>r.byteLength)throw new L(`${e}: TAR end marker is truncated`);let z=r.subarray(i,i+ke);if(!Jn(z))throw new L(`${e}: TAR has only one zero end block`);if(i+=ke,!Jn(r.subarray(i)))throw new L(`${e}: TAR has nonzero data after its end marker`);l=!0;break}Xo(h,e);let f=Ct(h,156,1,e)||"0",d=er(h,124,12,`${e}: TAR entry size`),y=er(h,100,8,`${e}: TAR entry mode`)&$o,g=Jo(h,e,t.maxPathBytes),p=Ct(h,157,100,e);if(f==="x"||f==="g"){if(s+=1,s>t.maxEntries+1)throw new L(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let z=xi(r,i,d,e);i=Ii(i,d,r.byteLength,e);let S=jo(z,e,t);f==="x"?a=S:c={...c,...S};continue}if(o+=1,o>t.maxEntries)throw new L(`${e}: TAR entry count exceeds ${t.maxEntries}`);let w={...c,...a??{}};a=null;let u=w.size===void 0?d:Yo(w.size,`${e}: PAX entry size`),m=xi(r,i,u,e);i=Ii(i,u,r.byteLength,e);let v=Qn(w.path??g,e,t.maxPathBytes),E=w.linkpath??p;switch(f){case"0":case"\0":n.push({path:v,type:"file",mode:y,data:m});break;case"5":Xn(u,e,"directory",v),n.push({path:v,type:"directory",mode:y});break;case"2":Xn(u,e,"symlink",v),Ai(E,e,v,t.maxLinkBytes,!1),n.push({path:v,type:"symlink",mode:y,linkName:E});break;case"1":Xn(u,e,"hardlink",v),Ai(E,e,v,t.maxLinkBytes,!0),n.push({path:v,type:"hardlink",mode:y,linkName:Qn(E,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new L(`${e}: unsupported TAR device/FIFO entry ${v}`);default:throw new L(`${e}: unsupported TAR entry type ${JSON.stringify(f)} for ${v}`)}}if(!l)throw new L(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new L(`${e}: local PAX header has no following entry`);return n}function Wo(r,e){let t={...Li,...r};for(let[n,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new L(`${e}: ${n} must be a positive safe integer`);return t}function Zo(r,e){if(r.byteLength<18||r[0]!==31||r[1]!==139||r[2]!==8)throw new L(`${e}: invalid gzip header`);return new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-4,!0)}function Ho(r,e,t){let n=new Uint8Array(t),i=0,o=!1,s=new pi(a=>{if(a.byteLength>t-i)throw new L(`${e}: gzip expansion exceeds its declared ${t} bytes`);n.set(a,i),i+=a.byteLength});s.onmember=()=>{throw o=!0,new L(`${e}: concatenated gzip members are unsupported`)};try{s.push(r,!0)}catch(a){throw a instanceof L?a:new L(`${e}: cannot gunzip archive: ${ea(a)}`)}if(o)throw new L(`${e}: concatenated gzip members are unsupported`);return n.subarray(0,i)}function Vo(r){let e=4294967295;for(let t of r)e=Uo[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function qo(){let r=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);r[e]=t>>>0}return r}function xi(r,e,t,n){if(t>r.byteLength-e)throw new L(`${n}: TAR entry is truncated`);return r.subarray(e,e+t)}function Ii(r,e,t,n){let o=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(o)||o>t-r)throw new L(`${n}: TAR entry padding is truncated`);return r+o}function jo(r,e,t){let n={},i=0;for(;i9)throw new L(`${e}: invalid PAX record length`);if(s=s*10+p,!Number.isSafeInteger(s))throw new L(`${e}: invalid PAX record length`)}let a=i+s;if(s<=o-i+2||a>r.byteLength||r[a-1]!==10)throw new L(`${e}: truncated PAX record`);let c=o+1;for(;c=a-1)throw new L(`${e}: invalid PAX record`);let l=r.subarray(o+1,c);if(l.byteLength>256)throw new L(`${e}: PAX record key is too long`);let h=tr(l,`${e}: PAX record key`),f=r.subarray(c+1,a-1),d=h==="path"?t.maxPathBytes:h==="linkpath"?t.maxLinkBytes:h==="size"?32:0;if(d===0){i=a;continue}if(f.byteLength>d)throw new L(`${e}: PAX ${h} value is too long`);let y=tr(f,`${e}: PAX record value`);n[h]=y,i=a}return n}function Yo(r,e){if(!/^(0|[1-9][0-9]*)$/.test(r))throw new L(`${e} is invalid`);let t=Number(r);if(!Number.isSafeInteger(t)||t<0)throw new L(`${e} is invalid`);return t}function Xo(r,e){let t=er(r,148,8,`${e}: TAR checksum`),n=0;for(let i=0;i=148&&i<156?32:r[i];if(t!==n)throw new L(`${e}: TAR checksum mismatch`)}function Jo(r,e,t){let n=Ct(r,0,100,e),i=Ct(r,345,155,e);return Qn(i?`${i}/${n}`:n,e,t)}function Qn(r,e,t){let n=r;for(;n.startsWith("./");)n=n.slice(2);return n=n.replace(/\/+$/g,""),Qo(n,`${e}: TAR path`,t),n}function Ct(r,e,t,n){let i=e,o=e+t;for(;in||r.includes("\0"))throw new L(`${e}: link target for ${t} is invalid`);if(i&&r.includes("\\"))throw new L(`${e}: hardlink target for ${t} is invalid`)}function Qo(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\0")||r.includes("\\")||_i.encode(r).byteLength>t)throw new L(`${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 L(`${e} ${JSON.stringify(r)} contains an unsafe path segment`)}function Jn(r){for(let e of r)if(e!==0)return!1;return!0}function tr(r,e){try{return Do.decode(r)}catch{throw new L(`${e} contains non-UTF-8 text`)}}function ea(r){return r instanceof Error?r.message:String(r)}var ke,$o,ki,Do,_i,Uo,Li,L,Oi=bn(()=>{"use strict";Hn();ke=512,$o=4095,ki=1024*1024,Do=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),_i=new TextEncoder,Uo=qo(),Li=Object.freeze({maxCompressedBytes:256*ki,maxUncompressedBytes:512*ki,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),L=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as $t,lstatSync as En,readdirSync as Ra,readFileSync as Ze,realpathSync as me,statSync as He}from"node:fs";import{createHash as rs}from"node:crypto";import{spawnSync as wr}from"node:child_process";import{basename as Ba,dirname as Ut,isAbsolute as Sn,join as $,relative as Na,resolve as pe,sep as Ca}from"node:path";import{fileURLToPath as Ma}from"node:url";var mt="kandelo.wpk_fork.linked_frames";var Ir=[75,76,67,70],wt=24,Ar=8,kn=3,_r=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],Xe=[{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"]}],vt=[{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 Lr=["__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 G={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 x(r,e){let t=0,n=0,i=e;for(;;){let o=r[i++];if(t|=(o&127)<=21&&n<=34?Et(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?Et(e,t):n===3?t:n>=16&&n<=79?Et(e,t):null:null}function ws(r,e,t){let[n,i]=x(r,e);e+=i+n;let[o,s]=x(r,e);e+=s+o;let a=r[e++];if(a===0){t.funcImports++;let[,c]=x(r,e);e+=c}else if(a===1){e++;let c=r[e++],[,l]=x(r,e);if(e+=l,c&1){let[,h]=x(r,e);e+=h}}else if(a===2){let c=r[e++],[,l]=x(r,e);if(e+=l,c&1){let[,h]=x(r,e);e+=h}}else a===3&&(t.globalImports++,e+=2);return e}function Wt(r){return r.length>=8&&r[0]===0&&r[1]===97&&r[2]===115&&r[3]===109}function Pe(r,e){let[t,n]=x(r,e);return e+=n,[new TextDecoder().decode(r.subarray(e,e+t)),e+t]}function vs(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 Pr(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 xn(r,e){let[t,n]=x(r,e);e+=n;let[,i]=x(r,e);if(e+=i,(t&1)!==0){let[,o]=x(r,e);e+=o}return{flags:t,next:e}}function Ss(r){let e=new Uint8Array(r);if(!Wt(e))throw new Error("not a wasm binary");let t=[],n=[],i=[],o={functionImports:new Map,functionExports:new Map,memoryPointerWidths:[],linkedFrameDescriptors:[],importsKernelFork:!1},s=8;for(;se.length)throw new Error("wasm section exceeds file size");let d=h,y=!1;if(a===0){let[g,p]=Pe(e,d);g===mt&&o.linkedFrameDescriptors.push(e.slice(p,f))}else if(a===1){y=!0;let[g,p]=x(e,d);d+=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!==wt)throw new Error(`linked-frame descriptor declares size ${n}, expected ${wt}`);let i=e.getUint8(8),o=_r.find(({bytes:a})=>a===i);if(!o)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Ar)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let s=e.getUint16(10,!0);if(s!==kn)throw new Error(`linked-frame descriptor flags 0x${s.toString(16)} do not equal required flags 0x${kn.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 Or(r,e){return r==="i32"?127:e===8?126:127}function Tr(r,e,t,n){return r.params.length===e.length&&r.results.length===t.length&&r.params.every((i,o)=>i===Or(e[o],n))&&r.results.every((i,o)=>i===Or(t[o],n))}function Rr(r,e,t){let n=i=>i==="ptr"&&t===8?"i64":"i32";return`(${r.map(n).join(", ")}) -> (${e.map(n).join(", ")})`}function bs(r){let e=[];for(let s of vt){let a=r.functionExports.get(s.name);a&&a.length!==1&&e.push(`duplicate ABI 42 wasm-fork-instrument export ${s.name}`)}let t=vt.filter(({name:s})=>!r.functionExports.has(s)).map(({name:s})=>s);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 ${mt} descriptor`);else if(r.linkedFrameDescriptors.length!==1)e.push(`has ${r.linkedFrameDescriptors.length} ${mt} descriptors, expected exactly one`);else try{n=zs(r.linkedFrameDescriptors[0])}catch(s){e.push(s instanceof Error?s.message:String(s))}let i=Xe.filter(({module:s,name:a})=>r.functionImports.has(`${s}.${a}`)),o=r.importsKernelFork||i.length>0;if(o){let s=Xe.filter(({module:a,name:c})=>!r.functionImports.has(`${a}.${c}`)).map(({module:a,name:c})=>`${a}.${c}`);s.length>0&&e.push(`incomplete ABI 42 linked-frame imports; missing ${s.join(", ")}`);for(let a of Xe){let c=`${a.module}.${a.name}`,l=r.functionImports.get(c);l&&l.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 s=n===8?"an":"a";e.push(`ABI 42 linked-frame descriptor declares ${s} ${n}-byte pointer but the module memory uses ${r.memoryPointerWidths[0]}-byte addresses`)}for(let s of vt){let a=r.functionExports.get(s.name);a?.length===1&&!Tr(a[0],s.params,s.results,n)&&e.push(`ABI 42 wasm-fork-instrument export ${s.name} has the wrong signature; expected ${Rr(s.params,s.results,n)}`)}if(o)for(let s of Xe){let a=`${s.module}.${s.name}`,c=r.functionImports.get(a);c?.length===1&&!Tr(c[0],s.params,s.results,n)&&e.push(`ABI 42 linked-frame import ${a} has the wrong signature; expected ${Rr(s.params,s.results,n)}`)}}return e}function ks(r){let e=new Uint8Array(r);if(!Wt(e))return[];let t=[],n=8;for(;nt.startsWith("reloc."))}function Nr(r,e={}){let t=[];if(Is(r)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null){let f=Ls(r);f!==null&&f!==e.expectedAbi&&t.push(`ABI ${f}, expected ${e.expectedAbi}`)}let n=new Set(xs(r));if(e.requiredExports){let f=e.requiredExports.filter(d=>!n.has(d));f.length>0&&t.push(`missing required exports: ${f.join(", ")}`)}let i=Es.filter(f=>n.has(f)),o=ks(r),s=Br(r),a=Xe.filter(({module:f,name:d})=>o.includes(`${f}.${d}`)),c=s.filter(f=>f===mt).length,l=i.length>0||a.length>0||c>0;if(e.forbidForkInstrumentation&&l&&t.push("contains ABI 42 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!As(r))&&(l||o.includes("kernel.kernel_fork")))try{t.push(...bs(Ss(r)))}catch(f){t.push(`cannot validate ABI 42 fork-artifact contract: ${f instanceof Error?f.message:String(f)}`)}return t}function _s(r,e){let t=new Uint8Array(r);if(t.length<8)return null;let n=0,i=null,o=null,s=8;for(;s=c)return null;let p=a;for(let m=0;m=g)return null;let[p,w]=x(t,y);y+=w;for(let u=0;ug)return null}return y}function d(y,g=0){if(g>4)return null;let p=h(y);if(!p)return null;let w=f(p.start,p.end);if(w===null)return null;let u=w,m=p.end;for(;u=32&&v<=38||v===208){let[,E]=x(t,u);u+=E}else if(v>=40&&v<=62)u=Et(t,u);else if(v===63||v===64)u++;else if(v===66){let[,E]=gs(t,u);u+=E}else if(v===67)u+=4;else if(v===68)u+=8;else if(v===252||v===253||v===254){let E=ms(v,t,u);if(E===null)return null;u=E}}return null}return d(i)}function Ls(r){return _s(r,"__abi_version")}var Ps=ArrayBuffer,H=Uint8Array,Zt=Uint16Array,Os=Int16Array;var Ht=Int32Array,An=function(r,e,t){if(H.prototype.slice)return H.prototype.slice.call(r,e,t);(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length);var n=new H(t-e);return n.set(r.subarray(e,t)),n},zt=function(r,e,t,n){if(H.prototype.fill)return H.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"],V=function(r,e,t){var n=new Error(e||Rs[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,V),!t)throw n;return n},Cr=function(r,e,t){for(var n=0,i=0;n>>0},Ns=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,o=n>>2&1,s=n&3,a=n>>6;n&8&&V(0);var c=6-i,l=s==3?4:s,h=Cr(r,c,l);c+=l;var f=a?1<>3);y=g+(g>>3)*(r[5]&7)}y>2145386496&&V(1);var p=new H((e==1?d||y:e?0:y)+12);return p[0]=1,p[4]=4,p[8]=8,{b:c+f,y:0,l:0,d:h,w:e&&e!=1?e:p.subarray(12),e:y,o:new Ht(p.buffer,0,3),u:d,c:o,m:Math.min(131072,y)}}else if((t>>4|r[3]<<20)==25481893)return Bs(r,4)+8;V(0)},De=function(r){for(var e=0;1<t&&V(3);for(var o=1<0;){var m=De(s+1),v=n>>3,E=(1<>(n&7)&E,S=(1<S&&(z-=k)),d[++a]=--z,z==-1?(s+=z,w[--h]=a):s-=z,!z)do{var A=n>>3;c=(r[A]|r[A+1]<<8)>>(n&7)&3,n+=2,a+=c}while(c==3)}(a>255||s)&&V(0);for(var B=0,N=(o>>1)+(o>>3)+3,te=o-1,j=0;j<=a;++j){var R=d[j];if(R<1){y[j]=-R;continue}for(l=0;l=h)}}for(B&&V(0),l=0;l>3,{b:i,s:w,n:u,t:g}]},Cs=function(r,e){var t=0,n=-1,i=new H(292),o=r[e],s=i.subarray(0,256),a=i.subarray(256,268),c=new Zt(i.buffer,268);if(o<128){var l=bt(r,e+1,6),h=l[0],f=l[1];e+=o;var d=h<<3,y=r[e];y||V(0);for(var g=0,p=0,w=f.b,u=w,m=(++e<<3)-8+De(y);m-=w,!(m>3;if(g+=(r[v]|r[v+1]<<8)>>(m&7)&(1<>3,p+=(r[v]|r[v+1]<<8)>>(m&7)&(1<255&&V(0)}else{for(n=o-127;t>4,s[t+1]=E&15}++e}var z=0;for(t=0;t11&&V(0),z+=S&&1<0;--t){var j=c[t];zt(te,t,j,c[t-1]=j+a[t]*(1<a&&f>3,y=(r[d]|r[d+1]<<8|r[d+2]<<16)>>(h&7);c=(c<>2,s=o<<1,a=o+s;St(r.subarray(n,n+=r[0]|r[1]<<8),e.subarray(0,o),t),St(r.subarray(n,n+=r[2]|r[3]<<8),e.subarray(o,s),t),St(r.subarray(n,n+=r[4]|r[5]<<8),e.subarray(s,a),t),St(r.subarray(n),e.subarray(a),t)},Gs=function(r,e,t){var n,i=e.b,o=r[i],s=o>>1&3;e.l=o&1;var a=o>>3|r[i+1]<<5|r[i+2]<<13,c=(i+=3)+a;if(s==1)return i>=r.length?void 0:(e.b=i+1,t?(zt(t,r[i],e.y,e.y+=a),t):zt(new H(a),r[i]));if(!(c>r.length)){if(s==0)return e.b=c,t?(t.set(r.subarray(i,c),e.y),e.y+=a,t):An(r,i,c);if(s==2){var l=r[i],h=l&3,f=l>>2&3,d=l>>4,y=0,g=0;h<2?f&1?d|=r[++i]<<4|(f&2&&r[++i]<<12):d=l>>3:(g=f,f<2?(d|=(r[++i]&63)<<4,y=r[i]>>6|r[++i]<<2):f==2?(d|=r[++i]<<4|(r[++i]&3)<<12,y=r[i]>>2|r[++i]<<6):(d|=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 H(e.m),w=p.length-d;if(h==0)p.set(r.subarray(i,i+=d),w);else if(h==1)zt(p,r[i++],w);else{var u=e.h;if(h==2){var m=Cs(r,i);y+=i-(i=m[0]),e.h=u=m[1]}else u||V(0);(g?Ks:St)(r.subarray(i,i+=y),p.subarray(w),u)}var v=r[i++];if(v){v==255?v=(r[i++]|r[i++]<<8)+32512:v>127&&(v=v-128<<8|r[i++]);var E=r[i++];E&3&&V(0);for(var z=[Fs,$s,Ms],S=2;S>-1;--S){var k=E>>(S<<1)+2&3;if(k==1){var I=new H([0,0,r[i++]]);z[S]={s:I.subarray(2,3),n:I.subarray(0,1),t:new Zt(I.buffer,0,1),b:0}}else k==2?(n=bt(r,i,9-(S&1)),i=n[0],z[S]=n[1]):k==3&&(e.t||V(0),z[S]=e.t[S])}var A=e.t=z,B=A[0],N=A[1],te=A[2],j=r[c-1];j||V(0);var R=(c<<3)-8+De(j)-te.b,P=R>>3,_=0,Z=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var Me=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var re=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var je=1<>>(R&7)&je-1);P=(R-=Ln[Fe])>>3;var Ae=Us[Fe]+((r[P]|r[P+1]<<8|r[P+2]<<16)>>(R&7)&(1<>3;var $e=Ds[gt]+((r[P]|r[P+1]<<8|r[P+2]<<16)>>(R&7)&(1<<_n[gt])-1);if(P=(R-=Kt)>>3,Z=te.t[Z]+((r[P]|r[P+1]<<8)>>(R&7)&(1<>3,re=B.t[re]+((r[P]|r[P+1]<<8)>>(R&7)&(1<>3,Me=N.t[Me]+((r[P]|r[P+1]<<8)>>(R&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=he-=3;else{var Ye=he-($e!=0);Ye?(he=Ye==3?e.o[0]-1:e.o[Ye],Ye>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=he):he=e.o[0]}for(var S=0;S<$e;++S)p[_+S]=p[w+S];_+=$e,w+=$e;var _e=_-he;if(_e<0){var Le=-_e,Gt=e.e+_e;Le>Ae&&(Le=Ae);for(var S=0;S=i){let I=(y+1)*4096;try{e.grow(I)}catch{throw new b(X)}if(i=Math.floor(e.byteLength/4096),y>=i)throw new b(X)}new Uint8Array(e).fill(0);let g=new r(e);g.w32(Bn,Tn),g.w32(Nn,Rn),g.w32(jt,4096),g.w32(Qe,i),g.w32(Oe,s),g.w32(Ue,h),g.w32(Xt,f),g.w32(Ur,d),g.w32(Jt,y),g.w32(eo,a),g.w32(to,c),g.w32(no,l),g.w32(It,o),g.w32(Kr,256);let p=f*4096;for(let I=0;I>2)+(I>>5);g.i32[A]|=1<<(I&31)}let w=i-y;Atomics.store(g.i32,et>>2,w),g.blockAllocHint=y;let u=h*4096;g.i32[u>>2]|=3,Atomics.store(g.i32,Yt>>2,s-2),g.inodeAllocHint=2;let m=g.inodeOffset(1);g.w32(m+C,U|493),g.w32(m+D,2),g.w64(m+oe,1);let v=g.blockAlloc();if(v<0)throw new b(X);g.w32(m+Y,v);let E=v*4096,z=Re(O+1),S=Re(O+2);g.w32(E,1),g.view.setUint16(E+4,z,!0),g.view.setUint16(E+6,1,!0),g.u8[E+O]=46;let k=E+z;return g.w32(k,1),g.view.setUint16(k+4,S,!0),g.view.setUint16(k+6,2,!0),g.u8[k+O]=46,g.u8[k+O+1]=46,g.w64(m+T,z+S),Atomics.store(g.i32,Cn>>2,1),g}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 b(W,"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 b(Fn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Oe);for(let a=0;a=1&&this.inodeIsAllocated(a)?n:0n;s.setBigUint64(c+At,l,!0),s.setBigUint64(c+ie,l,!0),s.setBigUint64(c+q,l,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=[{ino:1,path:"/"}],n=new Set;for(;t.length>0;){let i=t.pop();if(n.has(i.ino))throw new b(F);n.add(i.ino);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&K)!==U)throw new b(F);let s=this.r64(o+T),a=0;for(;a>2)>>>0,paths:[]},e.set(S,k)),k.paths.push(v),(this.r32(E+C)&K)===U&&t.push({ino:p,path:v})}}y+=w}a+=d}}return e}statfs(){let e=this.r32(jt),t=this.r32(Qe),n=this.r32(It),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(n,o)),a=Atomics.load(this.i32,et>>2),c=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:a+c,totalInodes:this.r32(Oe),freeInodes:Atomics.load(this.i32,Yt>>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(Qe),t=this.r32(Jt),n=this.r32(Xt)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(n>>5),o=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=Qt>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=en>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=en>>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,Qt>>2,0),Atomics.store(this.i32,en>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Oe),t=this.r32(Ue)*4096;for(let n=0;n>5)*4)&1<<(n&31))===0||this.r32(i+D)!==0)continue;let s=this.r32(i+C),a=this.r64(i+T);(s&K)===xt&&a<=40?(this.u8.fill(0,i+Y,i+Y+40),this.w64(i+T,0)):this.inodeTruncate(n,0),this.inodeFree(n)}}blockAlloc(){let e=this.r32(Qe),t=this.r32(Xt)*4096,n=this.r32(Jt),i=this.blockAllocHint>=n&&this.blockAllocHint>2)+(a>>5),l=a&31,h=Atomics.load(this.i32,c);if(h&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,n),s=o&~(1<>2,1),e>=this.r32(Jt)&&e>2)>0)return 0;let e=this.r32(Qe),t=this.r32(It),n=this.r32(Kr),i=e+n;if(i>t&&(i=t,n=i-e,n===0))return X;let o=i*4096;if(this.buffer.byteLength>2,n),Atomics.add(this.i32,Cn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let n=this.r32(Ur)+Math.floor(e/32),i=e%32*128;return n*4096+i}inodeAlloc(){let e=this.r32(Oe),t=this.r32(Ue)*4096,n=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),c=s&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let n=(this.r32(Ue)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,n);if((o&1<>2,1),e>=2&&e0&&this.w32(n+Te,i-1),i<=1&&this.r32(n+D)===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+D);return n>1?(this.w32(t+D,n-1),this.w64(t+q,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+D,0),this.w64(t+q,Date.now()),this.r32(t+Te)>0)return!1;let n=this.r32(t+C),i=this.r64(t+T);return(n&K)===xt&&i<=40?(this.u8.fill(0,t+Y,t+Y+40),this.w64(t+T,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+tt>>2;for(;;){let n=Atomics.load(this.i32,t);if(n&jr){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,n,n+1)===n)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+tt>>2;(Atomics.sub(this.i32,t,1)&ro)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+tt>>2;for(;;){let n=Atomics.load(this.i32,t);if(n!==0){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,0,jr)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+tt>>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 o=this.r32(i+Y+t*4);if(o!==0)return o;if(!n)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+Y+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+_t),s=!1;if(o===0){if(!n)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+_t,o),s=!0}let a=o*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!n)return 0;let l=this.blockAllocWithGrow();return l<0?(s&&(this.w32(i+_t,0),this.blockFree(o)),l):(this.w32(a,l),l)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,a=this.r32(i+nt),c=!1;if(a===0){if(!n)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+nt,a),c=!0}let l=a*4096+o*4,h=this.r32(l),f=!1;if(h===0){if(!n)return 0;if(h=this.blockAllocWithGrow(),h<0)return c&&(this.w32(i+nt,0),this.blockFree(a)),h;this.w32(l,h),f=!0}let d=h*4096+s*4,y=this.r32(d);if(y!==0)return y;if(!n)return 0;let g=this.blockAllocWithGrow();return g<0?(f&&(this.w32(l,0),this.blockFree(h)),c&&(this.w32(i+nt,0),this.blockFree(a)),g):(this.w32(d,g),g)}return W}inodeReadData(e,t,n,i){let o=this.inodeOffset(e),s=this.r64(o+T);if(t>=s)return 0;t+i>s&&(i=s-t);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),h=t%4096,f=4096-h;f>i&&(f=i);let d=this.inodeBlockMap(e,l,!1);if(d<=0)n.fill(0,c,c+f);else{let y=d*4096+h;n.set(this.u8.subarray(y,y+f),c)}c+=f,t+=f,i-=f,a+=f}return a}inodeWriteData(e,t,n,i){let o=this.inodeOffset(e),s=this.r64(o+T);t>s&&this.zeroOldEofTail(e,s);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),h=t%4096,f=4096-h;f>i&&(f=i);let d=this.inodeBlockMap(e,l,!0);if(d<0){if(a===0)return d;break}let y=d*4096+h;this.u8.set(n.subarray(c,c+f),y),c+=f,t+=f,i-=f,a+=f}if(a>0&&t>this.r64(o+T)&&this.w64(o+T,t),a>0){let l=Date.now();this.w64(o+ie,l),this.w64(o+q,l),Atomics.add(this.i32,o+ae>>2,1)}return a}zeroInodeRange(e,t,n){for(;t0){let c=a*4096+o;this.u8.fill(0,c,c+s)}t+=s}}zeroOldEofTail(e,t){let n=t%4096;if(n===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+n;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let n=this.inodeOffset(e);for(let s=t;s<10;s++){let a=this.r32(n+Y+s*4);a&&(this.blockFree(a),this.w32(n+Y+s*4,0))}let i=this.r32(n+_t);if(i){let s=t>10?t-10:0;for(let a=s;a<1024;a++){let c=i*4096+a*4,l=this.r32(c);l&&(this.blockFree(l),this.w32(c,0))}s===0&&(this.blockFree(i),this.w32(n+_t,0))}let o=this.r32(n+nt);if(o){let s=t>1034?t-10-1024:0,a=Math.floor(s/1024);for(let c=a;c<1024;c++){let l=o*4096+c*4,h=this.r32(l);if(!h)continue;let f=c===a?s%1024:0;for(let d=f;d<1024;d++){let y=h*4096+d*4,g=this.r32(y);g&&(this.blockFree(g),this.w32(y,0))}f===0&&(this.blockFree(h),this.w32(l,0))}a===0&&(this.blockFree(o),this.w32(n+nt,0))}}inodeTruncate(e,t,n=!1){let i=this.inodeOffset(e),o=this.r64(i+T),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+T,t),s||n){let c=Date.now();this.w64(i+ie,c),this.w64(i+q,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),s||n){let c=Date.now();this.w64(i+ie,c),this.w64(i+q,c),Atomics.add(this.i32,i+ae>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new b(W);if(e>rt)throw new b(Lt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new b(Qr);if(e<0)throw new b(W);if(e>rt)throw new b(Lt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),n=Date.now();this.w64(t+ie,n),this.w64(t+q,n);let i=Atomics.add(this.i32,t+Zr>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+T))}dirNameKey(e){return it(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=O&&n%4===0&&e+n<=t&&i<=n-O}inodeIsAllocated(e){let t=this.r32(Oe);if(e<=0||e>=t)return!1;let n=this.r32(Ue)*4096;return(Atomics.load(this.i32,(n>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,n,i){let o=new Map,s=[],a=0;for(;a4096-h&&(y=4096-h);let g=h;for(;g=O&&s.push({abs:p,recLen:u});g+=u}a+=y}let c={generation:t,mutationSequence:n,size:i,entries:o,free:s};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),n=this.r64(t+T),i=this.r64(t+oe),o=Atomics.load(this.i32,t+Zr>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===n?s:(s&&this.dirIndexes.delete(e),n=0;s--){let a=e.free[s];if(!(a.recLen4096-c&&(f=4096-c);let d=c;for(;dn)return-1;a=c,s+=l}return s===n?a:-1}dirAppendEntry(e,t,n,i=-1){let o=this.inodeOffset(e),s=this.r64(o+T),a=Re(O+t.length),c=s,l=Math.floor(c/4096),h=c%4096,f=0;if(h!==0&&h+a>4096){let g=4096-h,p=0;if(g>=O){if(p=this.inodeBlockMap(e,l,!1),p<=0)return F}else if(i<0&&(i=this.findLastDirEntryInBlock(e,l,h)),i<0)return F;if(f=this.inodeBlockMap(e,l+1,!0),f<0)return f;if(g>=O){let w=p*4096+h;this.w32(w,0),this.view.setUint16(w+4,g,!0),this.view.setUint16(w+6,0,!0)}else{let u=this.view.getUint16(i+4,!0)+g;this.view.setUint16(i+4,u,!0),this.updateDirIndexRecLen(e,i,u)}c=(l+1)*4096,l++,h=0}let d;if(h===0){if(d=f||this.inodeBlockMap(e,l,!0),d<0)return d}else if(d=this.inodeBlockMap(e,l,!1),d<=0)return F;let y=d*4096+h;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+O),this.w64(o+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 o=this.inodeOffset(e),s=this.r64(o+T),a=Re(O+t.length),c=-1,l=0;for(;l4096-f&&(g=4096-f);let p=f;for(;pf+g||v>m-O)return F;if(u===0&&m>=a)return this.w32(w,n),this.view.setUint16(w+6,t.length,!0),this.u8.set(t,w+O),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,w,m),0;let E=Re(O+v),z=m-E;if(u!==0&&z>=a){this.view.setUint16(w+4,E,!0);let S=w+E;return this.w32(S,n),this.view.setUint16(S+4,z,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+O),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,S,z),0}c=w,p+=m}l+=g}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 ye;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),o=this.r64(i+T),s=0;for(;s4096-c&&(f=4096-c);let d=c;for(;d4096-l&&(d=4096-l);let y=l;for(;y4096-s&&(l=4096-s);let h=s;for(;hs+l||g>y-O)throw new b(F);if(d!==0){if(g===1&&this.u8[f+O]===46){h+=y;continue}if(g===2&&this.u8[f+O]===46&&this.u8[f+O+1]===46){h+=y;continue}return!1}h+=y}i+=l}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 o=this.dirLookup(n,Yr);if(o<0||o===n)throw new b(F);n=o}throw new b(F)}pathResolve(e,t){if(!e.startsWith("/"))return ye;let n=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return $n;let c=ce.encode(a),l;this.inodeReadLock(n);try{let d=this.inodeOffset(n);if((this.r32(d+C)&K)!==U)return Se;l=this.dirLookup(n,c)}finally{this.inodeReadUnlock(n)}if(l<0)return l;let h=this.inodeOffset(l);if((this.r32(h+C)&K)===xt&&(!(s===i.length-1)||t)){if(++o>8)return Jr;let y=this.r64(h+T),g;if(y<=40)g=it(this.u8.subarray(h+Y,h+Y+y));else{let p=new Uint8Array(y);this.inodeReadData(l,0,p,y),g=Ot.decode(p)}if(g.startsWith("/")){n=1;let p=g.split("/").filter(u=>u.length>0),w=i.slice(s+1);i.length=0,i.push(...p,...w),s=-1}else{let p=g.split("/").filter(u=>u.length>0),w=i.slice(s+1);i.length=s,i.push(...p,...w),s--}continue}n=l}return n}pathResolveParent(e){if(!e.startsWith("/"))throw new b(W,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new b(W,"Cannot operate on /");let n=t.pop();if(n.length>255)throw new b($n);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new b(o);let s=this.inodeOffset(o);if((this.r32(s+C)&K)!==U)throw new b(Se);return{parentIno:o,name:n}}fdAlloc(e,t,n){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,s,0,1)===0)return this.w32(o+Hr,e),this.w64(o+Ke,0),this.w32(o+Vr,t),this.w32(o+qr,n?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,s,0),ye)}return Xr}fdGet(e){if(e<0||e>=Vt)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Hr),offset:this.r64(t+Ke),flags:this.r32(t+Vr),isDir:this.r32(t+qr)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+oe),dataSequence:this.r32(t+ae),mode:this.r32(t+C),linkCount:this.r32(t+D),size:this.r64(t+T),mtime:this.r64(t+ie),ctime:this.r64(t+q),atime:this.r64(t+At),uid:this.r32(t+Gr),gid:this.r32(t+Wr)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+oe),linkCount:this.r32(t+D),mode:this.r32(t+C)}}open(e,t,n=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,n))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let n=this.openUnlocked(e,Dr|Pt,t);try{let i=this.fdGet(n);if(!i)throw new b(Q);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,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+oe)!==n||this.r32(a+ae)!==i||(this.r32(a+C)&K)!==kt)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(a+oe)!==n||this.r32(a+ae)!==i||this.r64(a+T)!==0)return!1;let c=this.r64(a+ie),l=this.r64(a+q);this.inodeTruncate(s,0,!0);let h=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(h!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,a+ae>>2,i),this.w64(a+ie,c),this.w64(a+q,l),new b(h<0?h:X);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e){return e.length===0?!0:this.withNamespaceLock(()=>{let t=[],n=new Set;for(let o of e){this.validateFileSize(o.data.byteLength);let s=-1;for(let a of o.paths){let c=this.pathResolve(a,!0);if(c!==o.expectedIno)continue;let l=this.inodeOffset(c);if(this.r64(l+oe)===o.expectedGeneration&&this.r32(l+ae)===o.expectedDataSequence&&(this.r32(l+C)&K)===kt&&this.r64(l+T)===0){s=c;break}}if(s<0)return!1;if(n.has(s))throw new b(W,"duplicate conditional replacement inode");n.add(s),t.push({...o,ino:s})}let i=[...n].sort((o,s)=>o-s);for(let o of i)this.inodeWriteLock(o);try{for(let a of t){let c=this.inodeOffset(a.ino);if(this.r64(c+oe)!==a.expectedGeneration||this.r32(c+ae)!==a.expectedDataSequence||(this.r32(c+C)&K)!==kt||this.r64(c+T)!==0)return!1}let o=t.map(a=>{let c=this.inodeOffset(a.ino);return{ino:a.ino,dataSequence:this.r32(c+ae),mtime:this.r64(c+ie),ctime:this.r64(c+q)}}),s=0;try{for(let a of t){s++,this.inodeTruncate(a.ino,0,!0);let c=a.data.byteLength>0?this.inodeWriteData(a.ino,0,a.data,a.data.byteLength):0;if(c!==a.data.byteLength)throw new b(c<0?c:X)}}catch(a){for(let c=s-1;c>=0;c--){let l=o[c],h=this.inodeOffset(l.ino);this.inodeTruncate(l.ino,0,!0),Atomics.store(this.i32,h+ae>>2,l.dataSequence),this.w64(h+ie,l.mtime),this.w64(h+q,l.ctime)}throw a}return!0}finally{for(let o=i.length-1;o>=0;o--)this.inodeWriteUnlock(i[o])}})}openUnlocked(e,t,n=420){let i=t&qt,o=(t&Pt)!==0,s=(t&Un)!==0;if(o&&s){let f=this.pathResolve(e,!1);if(f>=0)throw new b(st);if(f!==ye)throw new b(f)}let a=this.pathResolve(e,!0);if(a<0&&a===ye&&o){let{parentIno:f,name:d}=this.pathResolveParent(e);this.inodeWriteLock(f);try{let y=ce.encode(d),g=this.dirLookup(f,y);if(g>=0){if(s)throw new b(st);a=g}else{let p=this.inodeAlloc();if(p<0)throw new b(X);let w=this.inodeOffset(p);this.w32(w+C,kt|n&4095),this.w32(w+D,1),this.w64(w+T,0);let u=Date.now();this.w64(w+At,u),this.w64(w+ie,u),this.w64(w+q,u);let m=this.dirAddEntry(f,y,p);if(m<0)throw this.inodeFree(p),new b(m);a=p}}finally{this.inodeWriteUnlock(f)}}if(a<0)throw new b(a);let c=this.inodeOffset(a),l=this.r32(c+C);if((l&K)===U&&i!==Je)throw new b(Ge);if(t&js&&(l&K)!==U)throw new b(Se);if(t&Tt){if((l&K)===U)throw new b(Ge);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let h=this.fdAlloc(a,t,!1);if(h<0)throw new b(h);return h}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new b(Q);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let n=this.fdGet(e);if(!n)throw new b(Q);let i=this.inodeOffset(n.ino);if((this.r32(i+C)&K)===U)throw new b(Ge);this.inodeReadLock(n.ino);try{let s=this.inodeReadData(n.ino,n.offset,t,t.length),a=256+e*24;return this.w64(a+Ke,n.offset+s),s}finally{this.inodeReadUnlock(n.ino)}}readAt(e,t,n){let i=this.fdGet(e);if(!i)throw new b(Q);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&K)===U)throw new b(Ge);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 b(Q);if((n.flags&qt)===Je)throw new b(Q);this.inodeWriteLock(n.ino);try{let o=n.offset;if(n.flags&qs){let c=this.inodeOffset(n.ino);o=this.r64(c+T)}if(!Number.isSafeInteger(o)||o<0)throw new b(W);if(o>rt||t.length>rt-o)throw new b(Lt);let s=this.inodeWriteData(n.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(n.ino)}}writeAt(e,t,n){let i=this.fdGet(e);if(!i)throw new b(Q);if((i.flags&qt)===Je)throw new b(Q);this.validateSeekPosition(n),this.inodeWriteLock(i.ino);try{if(n>rt||t.length>rt-n)throw new b(Lt);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 b(Q);let o;if(n===Ys)o=t;else if(n===Xs)o=i.offset+t;else if(n===Js){let a=this.inodeOffset(i.ino);o=this.r64(a+T)+t}else throw new b(W);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+Ke,o),o}ftruncate(e,t){let n=this.fdGet(e);if(!n)throw new b(Q);if((n.flags&qt)===Je)throw new b(Q);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 b(Q);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 b(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 b(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=ce.encode(n),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new b(s);let a=this.inodeOffset(s),c=this.r32(a+C);if(o&&(c&K)!==U)throw new b(Se);if((c&K)===U)throw new b(Ge);let l=this.namespaceEntryIdentity(s),h=this.dirRemoveEntry(t,i);if(h<0)throw new b(h);let f=!1;this.inodeWriteLock(s);try{f=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return f&&this.inodeFree(s),l}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:o,name:s}=this.pathResolveParent(t);if(Mn(i)||Mn(s))throw new b(W);let a=ce.encode(i),c=ce.encode(s),l=e.length>1&&e.endsWith("/"),h=t.length>1&&t.endsWith("/"),f=Math.min(n,o),d=Math.max(n,o);this.inodeWriteLock(f),f!==d&&this.inodeWriteLock(d);try{let y=this.dirLookup(n,a);if(y<0)throw new b(y);let g=this.inodeOffset(y),w=this.r32(g+C)&K,u=this.namespaceEntryIdentity(y);if((l||h)&&w!==U)throw new b(Se);if(w===U&&this.dirIsAncestor(y,o))throw new b(W);let m=this.dirLookup(o,c),v=!1,E;if(m>=0){if(m===y)return{source:u,replaced:u};E=this.namespaceEntryIdentity(m);let S=this.inodeOffset(m),I=this.r32(S+C)&K;if(w===U&&I!==U)throw new b(Se);if(w!==U&&I===U)throw new b(Ge);let A=!1,B=m===n||m===o;B||this.inodeWriteLock(m);try{if(I===U&&!this.dirIsEmpty(m))throw new b(Dn);let N=this.dirReplaceEntryIno(o,c,y);if(N<0)throw new b(N);A=I===U?this.inodeOrphanLocked(m):this.inodeDropLinkRefLocked(m)}finally{B||this.inodeWriteUnlock(m)}A&&this.inodeFree(m),v=I===U}else{let S=this.dirAddEntry(o,c,y);if(S<0)throw new b(S)}let z=this.dirRemoveEntry(n,a);if(z<0)throw new b(z);if(w===U){if(n!==o){let S=this.inodeOffset(n);this.w32(S+D,this.r32(S+D)-1);let k=this.inodeOffset(o);this.w32(k+D,this.r32(k+D)+1),this.inodeWriteLock(y);try{let I=this.dirReplaceEntryIno(y,Yr,o);if(I<0)throw new b(I);this.w64(g+q,Date.now())}finally{this.inodeWriteUnlock(y)}}if(v){let S=this.inodeOffset(o);this.w32(S+D,this.r32(S+D)-1)}}else if(v){let S=this.inodeOffset(o);this.w32(S+D,this.r32(S+D)-1)}return{source:u,replaced:E}}finally{f!==d&&this.inodeWriteUnlock(d),this.inodeWriteUnlock(f)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:n,name:i}=this.pathResolveParent(e),o=ce.encode(i);this.inodeWriteLock(n);try{if(this.dirLookup(n,o)>=0)throw new b(st);let a=this.inodeAlloc();if(a<0)throw new b(X);let c=this.inodeOffset(a);this.w32(c+C,U|t),this.w32(c+D,2),this.w64(c+T,0);let l=Date.now();this.w64(c+At,l),this.w64(c+ie,l),this.w64(c+q,l);let h=this.blockAllocWithGrow();if(h<0)throw this.inodeFree(a),new b(X);this.w32(c+Y,h);let f=h*4096,d=Re(O+1),y=Re(O+2);this.w32(f,a),this.view.setUint16(f+4,d,!0),this.view.setUint16(f+6,1,!0),this.u8[f+O]=46;let g=f+d;this.w32(g,n),this.view.setUint16(g+4,y,!0),this.view.setUint16(g+6,2,!0),this.u8[g+O]=46,this.u8[g+O+1]=46,this.w64(c+T,d+y);let p=this.dirAddEntry(n,o,a);if(p<0)throw this.blockFree(h),this.inodeFree(a),new b(p);let w=this.inodeOffset(n);this.w32(w+D,this.r32(w+D)+1)}finally{this.inodeWriteUnlock(n)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:n}=this.pathResolveParent(e);if(Mn(n))throw new b(W);let i=ce.encode(n);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new b(o);let s=this.inodeOffset(o);if((this.r32(s+C)&K)!==U)throw new b(Se);let c=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new b(Dn);let h=this.dirRemoveEntry(t,i);if(h<0)throw new b(h);c=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}c&&this.inodeFree(o);let l=this.inodeOffset(t);this.w32(l+D,this.r32(l+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),o=ce.encode(i),s=ce.encode(e);this.inodeWriteLock(n);try{if(this.dirLookup(n,o)>=0)throw new b(st);let c=this.inodeAlloc();if(c<0)throw new b(X);let l=this.inodeOffset(c);if(this.w32(l+C,xt|511),this.w32(l+D,1),s.length<=40)this.u8.set(s,l+Y),this.w64(l+T,s.length);else{this.w64(l+T,0);let f=this.inodeWriteData(c,0,s,s.length);if(f!==s.length)throw f>0&&this.inodeTruncate(c,0),this.inodeFree(c),new b(f<0?f:X)}let h=this.dirAddEntry(n,o,c);if(h<0)throw s.length<=40?(this.u8.fill(0,l+Y,l+Y+40),this.w64(l+T,0)):this.inodeTruncate(c,0),this.inodeFree(c),new b(h)}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 b(n);this.inodeWriteLock(n);try{let i=this.inodeOffset(n),o=this.r32(i+C);this.w32(i+C,o&K|t&4095),this.w64(i+q,Date.now())}finally{this.inodeWriteUnlock(n)}}fchmod(e,t){let n=this.fdGet(e);if(!n)throw new b(Q);this.inodeWriteLock(n.ino);try{let i=this.inodeOffset(n.ino),o=this.r32(i+C);this.w32(i+C,o&K|t&4095),this.w64(i+q,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 b(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 b(Q);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 b(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,n)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,n){let i=this.inodeOffset(e);t!==$r&&this.w32(i+Gr,t),n!==$r&&this.w32(i+Wr,n);let o=this.r32(i+C);(o&K)===kt&&(o&Vs)!==0&&this.w32(i+C,o&~(Zs|Hs)),this.w64(i+q,Date.now())}utimens(e,t,n,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,n,i,o))}utimensUnlocked(e,t,n,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new b(s);this.inodeWriteLock(s);try{let a=this.inodeOffset(s),c=1073741823,l=1073741822,h=Date.now();if(n!==l){let f=n===c?h:t*1e3+Math.floor(n/1e6);this.w64(a+At,f)}if(o!==l){let f=o===c?h:i*1e3+Math.floor(o/1e6);this.w64(a+ie,f)}this.w64(a+q,h)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let n=this.pathResolve(e,!1);if(n<0)throw new b(n);let i=this.inodeOffset(n);if((this.r32(i+C)&K)===U)throw new b(Qs);let{parentIno:s,name:a}=this.pathResolveParent(t),c=ce.encode(a);this.inodeWriteLock(s);try{if(this.dirLookup(s,c)>=0)throw new b(st);let h=this.dirAddEntry(s,c,n);if(h<0)throw new b(h);this.inodeWriteLock(n);try{let f=this.r32(i+D);this.w32(i+D,f+1),this.w64(i+q,Date.now())}finally{this.inodeWriteUnlock(n)}return{...this.namespaceEntryIdentity(n),linkCount:this.r32(i+D)}}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 b(t);let n=this.inodeOffset(t);if((this.r32(n+C)&K)!==xt)throw new b(W);let o=this.r64(n+T);if(o<=40)return it(this.u8.subarray(n+Y,n+Y+o));this.inodeReadLock(t);try{let s=new Uint8Array(o);return this.inodeReadData(t,0,s,o),Ot.decode(s)}finally{this.inodeReadUnlock(t)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new b(t);let n=this.inodeOffset(t);if((this.r32(n+C)&K)!==U)throw new b(Se);let o=this.fdAlloc(t,Je,!0);if(o<0)throw new b(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new b(Q);let n=this.inodeOffset(t.ino),i=this.r64(n+T);for(;t.offset=this.r32(Oe))throw new b(F);let p=this.r32(Ue)*4096;if((this.r32(p+(h>>5)*4)&1<<(h&31))===0)throw new b(F);let u=it(this.u8.subarray(l+O,l+O+d)),m=this.buildStat(h);return this.w64(g+Ke,y),t.offset=y,{name:u,stat:m}}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"?ce.encode(t):t,i=this.open(e,Dr|Pt|Tt);try{this.write(i,n)}finally{this.close(i)}}readFile(e){let t=this.open(e,Je);try{let n=this.fstat(t),i=new Uint8Array(n.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return Ot.decode(this.readFile(e))}};function ei(r,e){let t=new Map,n=new Map;for(let s of r){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(n.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);n.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of r){if(s.type!=="hardlink"||o.has(s.path))continue;let a=[],c=s,l;for(;c.type==="hardlink";){let f=o.get(c.path);if(f){l=f;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 d=t.get(c.target);if(!d)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(d.type!=="file"&&d.type!=="hardlink"||!c.inodeGroup||d.inodeGroup!==c.inodeGroup||d.size!==c.size||d.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=d}l??=c.type==="file"?c:void 0;let h=n.get(s.inodeGroup??"");if(!l||l!==h)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let f=a.length-1;f>=0;f-=1){let d=a[f];if(n.get(d.inodeGroup??"")!==l)throw new Error(`${e} hardlink ${d.path} does not resolve to its inode`);i.delete(d.path),o.set(d.path,l)}}return{canonicalByGroup:n,canonicalTargetByPath:o}}var le={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},we={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function ti(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>we.maxGroups)throw new Error(`${e} exceeds the ${we.maxGroups}-group cap`);if(r.archiveBytes>we.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(r.expandedBytes>we.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(r.payloadBytes>we.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(r.entries>we.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var ot="/home/linuxbrew/.linuxbrew",ii=[["@@HOMEBREW_PREFIX@@",ot],["@@HOMEBREW_CELLAR@@",`${ot}/Cellar`],["@@HOMEBREW_REPOSITORY@@",ot],["@@HOMEBREW_LIBRARY@@",`${ot}/Library`],["@@HOMEBREW_PERL@@",`${ot}/opt/perl/bin/perl`]],Kn="@@HOMEBREW_JAVA@@",oo=/^openjdk(?:@\d+(?:\.\d+)*)?/,at=new TextEncoder,ao=[...ii.map(([r])=>r),Kn].map(r=>({placeholder:r,bytes:at.encode(r)}));function si(r){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(r))}catch(a){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+uo(a))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");let t=e,n=t.changed_files;if(n!=null&&!Array.isArray(n))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let i=Array.isArray(n)?n:[];if(i.length>1e5)throw new Error(`INSTALL_RECEIPT.json declares ${i.length} changed files, limit 100000`);let o=[],s=new Set;for(let[a,c]of i.entries()){if(typeof c!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${a}] is not a string`);if(lo(c,"Homebrew changed file"),s.has(c))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${c}`);s.add(c),o.push(c)}return{changedFiles:o,runtimeDependencies:t.runtime_dependencies}}function oi(r,e,t){let n=r;for(let[s,a]of ii)n=ri(n,at.encode(s),at.encode(a));let i=at.encode(Kn);if(ni(n,i)){let s=co(e.runtimeDependencies);if(s===void 0)throw new Error(`Homebrew changed file ${t} uses ${Kn} without exactly one OpenJDK runtime dependency`);n=ri(n,i,at.encode(s))}let o=ao.find(({bytes:s})=>ni(n,s));if(o!==void 0)throw new Error(`Homebrew changed file ${t} retains ${o.placeholder}`);return n}function co(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,o=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,s=o===void 0?null:oo.exec(o);o!==void 0&&s?.[0]===o&&e.push(o)}let t=[...new Set(e)];return t.length===1?`${ot}/opt/${t[0]}/libexec`:void 0}function lo(r,e){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||fo(r)||at.encode(r).byteLength>4096||r.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${r}`)}function fo(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 ni(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;ngn||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=pn(t),o=new Map,s=e.map(a=>{let c=a.fileName,l=`Lazy archive ${JSON.stringify(r)} 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 h=a.isDirectory?c.slice(0,-1):c,f=h.split("/");if(h.length===0||f.some(d=>d===""||d==="."||d===".."))throw new Error(`${l} is not a canonical relative POSIX path`);if(o.has(h))throw new Error(`${l} collides with another member at ${JSON.stringify(h)}`);if(a.isSymlink&&!n?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return o.set(h,a),{entry:a,archivePath:h,vfsPath:i==="/"?`/${h}`:`${i}/${h}`}});for(let{archivePath:a}of s){let c=a.split("/");for(let l=1;ldt)throw new Error(`VFS image metadata exceeds ${dt} 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 hr(e)}function wa(r){if(r===null)return new Uint8Array(0);let e=hr(r),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>dt)throw new Error(`VFS image metadata exceeds ${dt} bytes`);return t}function va(r){return r.byteLength>=Mt.length&&r[0]===Mt[0]&&r[1]===Mt[1]&&r[2]===Mt[2]&&r[3]===Mt[3]?Ta(r):r}function rn(r){let e=va(r);if(e.byteLengthon)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);if(r.byteLengthan)throw new Error(`VFS image lazy archive metadata exceeds ${an} bytes`);if(r.byteLength=0?n:void 0}function za(r){return r===408||r===429||r>=500&&r<=599}function ba(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,Ui)}function ka(r){if(!(typeof r!="object"||r===null||!("cause"in r)))return r.cause}function Ki(r){if(!(typeof r!="object"||r===null||!("name"in r)))return typeof r.name=="string"?r.name:void 0}function Gi(r){if(!(typeof r!="object"||r===null||!("code"in r)))return typeof r.code=="string"?r.code:void 0}function Wi(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=ka(n)}return!1}function Zi(r){return Wi(r,e=>Ki(e)==="AbortError"||Gi(e)==="ABORT_ERR")}function xa(r){return Zi(r)?!1:Wi(r,e=>{let t=Ki(e),n=Gi(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||n!==void 0&&ya.has(n)})}function Ia(r,e){if(r instanceof un){if(!za(r.status))return null;if(r.retryAfterMs!==void 0)return r.retryAfterMs}else if(!xa(r))return null;return Math.min(da*2**e,Ui)}function ee(r){if(r?.aborted)throw r.reason}function Aa(r,e){return ee(e),r===0?Promise.resolve():new Promise((t,n)=>{let i=setTimeout(()=>a(!1),r),o=()=>a(!0,e.reason),s=!1;function a(c,l){s||(s=!0,clearTimeout(i),e?.removeEventListener("abort",o),c?n(l):t())}e?.addEventListener("abort",o,{once:!0}),e?.aborted&&o()})}async function or(r,e){try{await r.body?.cancel(e)}catch{}}function _a(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 Ft(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"||!ha.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)>Ti)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Ti}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function We(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(o=>!Object.prototype.hasOwnProperty.call(n,o)))throw new Error(`${t} has unexpected or missing fields`);return n}function fr(r,e,t,n){if(typeof r!="object"||r===null||Array.isArray(r))throw new Error(`${n} must be an object`);let i=r,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(`${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 Be(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 ne(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 dn(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,o=We(r,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...n?["source"]:[]],"Lazy tree content"),s=o.decoder==="zip-v1"?"application/zip":o.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(s===null||o.mediaType!==s)throw new Error("Lazy tree decoder and media type are inconsistent");let a=Ft({sha256:o.sha256,bytes:o.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=xe(o.transports,"Lazy tree transports",e,le.maxTransportsPerTree).map((y,g)=>Be(y,`Lazy tree transport ${g}`,dr));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let l=ne(o.expandedBytes,"Lazy tree expanded byte count",0,aa),h=ne(o.sourceEntryCount,"Lazy tree source entry count",1,ht),f=n?La(o.source,o.decoder):void 0,d=i?o.modePolicy:void 0;if(d!==void 0&&(d!=="portable-posix-v1"||o.decoder!=="zip-v1"||n))throw new Error("Lazy tree mode policy is invalid for its decoder");if(f!==void 0&&f.entries.length!==h)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:o.decoder,mediaType:s,sha256:a.sha256,bytes:a.bytes,expandedBytes:l,sourceEntryCount:h,transports:c,...d===void 0?{}:{modePolicy:d},...f===void 0?{}:{source:f}}}function Hi(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 ur(r){ti(r,"Serialized lazy tree collection")}function Fi(r){ur(Hi(r))}function La(r,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=We(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,ht).map((s,a)=>{let c=s,l=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,h=l==="directory"||l==="file"?["sourcePath","type","mode","size"]:l==="symlink"||l==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(h===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let f=We(s,h,`Lazy tree source entry ${a}`),d=ue(f.sourcePath,!1,`Lazy tree source entry ${a} path`);if(n.has(d))throw new Error(`Lazy tree source inventory duplicates ${d}`);let y=ne(f.mode,`Lazy tree source entry ${d} mode`,0,4095),g=ne(f.size,`Lazy tree source entry ${d} size`,0,cn),p;if((l==="directory"||l==="symlink"||l==="hardlink")&&g!==0)throw new Error(`Lazy tree source ${d} has payload for ${String(l)}`);l==="symlink"?p=Be(f.target,`Lazy tree source symlink ${d} target`,Di):l==="hardlink"&&(p=ue(f.target,!1,`Lazy tree source hardlink ${d} target`));let w={sourcePath:d,type:l,mode:y,size:g,...p===void 0?{}:{target:p}};return n.set(d,w),w}),o=i.map(s=>s.sourcePath);if(o.some((s,a)=>a>0&&o[a-1]>=s))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 Vi(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=[],o=new Set,s=n,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 ue(r,e,t,n=!1){if(typeof r!="string"||r.length===0||new TextEncoder().encode(r).byteLength>gn||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(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return r}function Pa(r){let e=We(r,["uid","gid"],"Lazy tree registration owner");return{uid:ne(e.uid,"Lazy tree registration owner uid",0,Ri),gid:ne(e.gid,"Lazy tree registration owner gid",0,Ri)}}function qi(r,e,t,n,i=1){let o=dn(r,i),s=pn(t),a=We(n,["mode","capabilities","roots"],"Lazy tree activation");if(a.mode!=="boot-prefetch"&&a.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let c=xe(a.capabilities,"Lazy tree activation capabilities",1,fa).map((E,z)=>{let S=Be(E,`Lazy tree activation capability ${z}`,le.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(S))throw new Error(`Lazy tree activation capability ${z} is invalid`);return S}),l=xe(a.roots,"Lazy tree activation roots",1,ua).map((E,z)=>ue(E,!0,`Lazy tree activation root ${z}`,!0));if(new Set(c).size!==c.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let h={mode:a.mode,capabilities:c,roots:l},f=xe(e,"Lazy tree inventory",1,ht),d=[],y=new Map,g=new Map,p=o.source===void 0?void 0:new Map(o.source.entries.map(E=>[E.sourcePath,E])),w=o.source===void 0?void 0:Vi(o.source.entries),u=0;for(let[E,z]of f.entries()){if(typeof z!="object"||z===null||Array.isArray(z))throw new Error(`Lazy tree entry ${E} must be an object`);let S=z.type,k=S==="directory"?["vfsPath","sourcePath","type","mode","size"]:S==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:S==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:S==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!k)throw new Error(`Lazy tree entry ${E} has an invalid type`);let I=We(z,[...k,...p===void 0?[]:["materialization"]],`Lazy tree entry ${E}`),A=ue(I.vfsPath,!0,`Lazy tree entry ${E} VFS path`),B=ue(I.sourcePath,!1,`Lazy tree entry ${E} source path`),N=p===void 0?void 0:I.materialization;if(p!==void 0&&N!=="archive"&&N!=="archive-homebrew-relocate"&&N!=="archive-copy"&&N!=="archive-copy-mode"&&N!=="descriptor")throw new Error(`Lazy tree entry ${A} has invalid materialization provenance`);if(s!=="/"&&A!==s&&!A.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${A} escapes its mount prefix`);if(y.has(A))throw new Error(`Lazy tree duplicates VFS path ${A}`);let te=ne(I.mode,`Lazy tree entry ${A} mode`,0,4095),j=ne(I.size,`Lazy tree entry ${A} size`,0,cn),R,P;if(S==="directory"){if(j!==0)throw new Error(`Lazy tree directory ${A} has nonzero size`)}else if(S==="symlink"){if(R=Be(I.target,`Lazy tree symlink ${A} target`,Di),new TextEncoder().encode(R).byteLength!==j)throw new Error(`Lazy tree symlink ${A} size differs from its target`)}else P=Be(I.inodeGroup,`Lazy tree entry ${A} inode group`,gn),S==="hardlink"&&(R=ue(I.target,!0,`Lazy tree hardlink ${A} target`));if(S!=="hardlink"&&(u+=j,u>cn))throw new Error("Lazy tree inventory exceeds the expansion limit");let _={vfsPath:A,sourcePath:B,...N===void 0?{}:{materialization:N},type:S,mode:te,size:j,...R===void 0?{}:{target:R},...P===void 0?{}:{inodeGroup:P}};if(p===void 0){let Z=g.get(B);if(Z){if(o.decoder!=="zip-v1"||_.type!=="hardlink"||Z.inodeGroup!==_.inodeGroup)throw new Error(`Lazy tree duplicates source path ${B}`)}else{if(o.decoder==="zip-v1"&&_.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${A} does not reuse a canonical source path`);g.set(B,_)}}else if(_.materialization==="descriptor"){if(_.type!=="directory"&&_.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${A} is not structural`);if(p.has(B))throw new Error(`Lazy tree descriptor entry ${A} impersonates a source member`)}else{let Z=p.get(B);if(Z===void 0)throw new Error(`Lazy tree entry ${A} names absent source ${B}`);if(_.materialization==="archive-copy"||_.materialization==="archive-copy-mode"){if(_.type!=="file"||Z.type!=="file"||_.materialization==="archive-copy"&&_.mode!==Z.mode)throw new Error(`Lazy tree archive copy ${A} differs from its source`)}else if(_.materialization==="archive-homebrew-relocate"){if(_.type!=="file"&&_.type!=="hardlink"||Z.type!==_.type||_.type==="file"&&Z.mode!==_.mode)throw new Error(`Lazy tree receipt-relocated entry ${A} differs from its source`)}else if(Z.type!==_.type||_.type==="symlink"&&Z.target!==_.target||_.type!=="hardlink"&&Z.mode!==_.mode)throw new Error(`Lazy tree archive entry ${A} differs from its source`)}d.push(_),y.set(A,_)}for(let E of d){let z=E.vfsPath.split("/").filter(Boolean);for(let S=1;S({path:E.vfsPath,type:E.type,mode:E.mode,size:E.size,target:E.target,inodeGroup:E.inodeGroup})),"Lazy tree");if(p!==void 0){let E=new Set;for(let z of d){if(z.materialization!=="archive-homebrew-relocate")continue;let S=p.get(z.sourcePath),k=S.type==="file"?S:w.get(S.sourcePath);if(k?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${z.vfsPath} is not regular`);E.add(k.sourcePath)}for(let z of d){if(z.materialization==="descriptor"||z.type!=="file"&&z.type!=="hardlink")continue;let S=p.get(z.sourcePath),k=S.type==="file"?S:w.get(S.sourcePath);if(k?.type!=="file"||!E.has(k.sourcePath)&&z.size!==k.size)throw new Error(`Lazy tree archive entry ${z.vfsPath} differs from its source`)}for(let z of d){if(z.type!=="hardlink"||z.materialization!=="archive"&&z.materialization!=="archive-homebrew-relocate")continue;let S=p.get(z.sourcePath),k=y.get(z.target),I=w.get(S.sourcePath);if(S.target!==k?.sourcePath||I?.type!=="file"||I.mode!==z.mode||k?.mode!==z.mode)throw new Error(`Lazy tree hardlink ${z.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(p===void 0?g.size:p.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesz.vfsPath===E||z.vfsPath.startsWith(`${E}/`)))throw new Error(`Lazy tree activation root ${E} is not owned by its inventory`);let v=new Map;for(let E of d)E.type==="file"&&v.set(E.inodeGroup,E);if(v.size!==m.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:d,mountPrefix:s,activation:h,canonicalByGroup:v}}function hn(r){return JSON.stringify([r.sourcePath,r.type,r.inodeGroup,r.target])}function $i(r,e){let t=fr(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!==ln)throw new Error("Serialized legacy lazy archive has an unsupported kind");let n=Be(t.url,"Serialized legacy lazy archive URL",dr),i=pn(t.mountPrefix),o=Ft(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=dn(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==n||!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=xe(t.entries,"Serialized legacy lazy archive entries",1,ht).map((c,l)=>{let h=fr(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}`),f=ue(h.vfsPath,!0,`Serialized legacy lazy archive entry ${l} VFS path`);if(s.has(f))throw new Error(`Serialized legacy lazy archive duplicates path ${f}`);s.add(f);let d=ne(h.ino,`Serialized legacy lazy archive entry ${f} inode`,1,Number.MAX_SAFE_INTEGER),y=h.generation===void 0?void 0:ne(h.generation,`Serialized legacy lazy archive entry ${f} generation`,0,Number.MAX_SAFE_INTEGER),g=h.dataSequence===void 0?void 0:ne(h.dataSequence,`Serialized legacy lazy archive entry ${f} data sequence`,0,Number.MAX_SAFE_INTEGER),p=ne(h.size,`Serialized legacy lazy archive entry ${f} size`,0,cn);if(h.isSymlink!==!1||h.deleted!==!1||h.materialized!==void 0&&h.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${f} is not pending`);if(h.type!==void 0&&h.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${f} has an invalid type`);let w=h.archivePath===void 0?void 0:ue(h.archivePath,!1,`Serialized legacy lazy archive entry ${f} archive path`),u=h.sourcePath===void 0?void 0:ue(h.sourcePath,!1,`Serialized legacy lazy archive entry ${f} source path`),m=h.inodeGroup===void 0?void 0:Be(h.inodeGroup,`Serialized legacy lazy archive entry ${f} inode group`,gn);if(h.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${f} has a link target`);return{vfsPath:f,ino:d,...y===void 0?{}:{generation:y},...g===void 0?{}:{dataSequence:g},size:p,isSymlink:!1,deleted:!1,materialized:!1,...w===void 0?{}:{archivePath:w},...u===void 0?{}:{sourcePath:u},type:"file",...m===void 0?{}:{inodeGroup:m}}});return{kind:ln,url:n,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:a}}function Oa(r,e){let t=We(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=qi(t.content,t.inventory,t.mountPrefix,t.activation);if(e===fn!=(n.content.source===void 0))throw new Error(e===fn?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=Be(t.url,"Serialized lazy tree URL",dr);if(i!==n.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=Ft(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 s=new Map(n.entries.map(f=>[f.vfsPath,f])),a=new Map(n.entries.map(f=>[hn(f),f])),c=xe(t.entries,"Serialized lazy tree entries",0,ht),l=new Set,h=c.map((f,d)=>{let y=fr(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 ${d}`),g=ue(y.vfsPath,!0,`Serialized lazy tree entry ${d} VFS path`);if(l.has(g))throw new Error(`Serialized lazy tree duplicates pending path ${g}`);l.add(g);let p=ue(y.sourcePath,!1,`Serialized lazy tree entry ${d} source path`),w=ue(y.archivePath,!1,`Serialized lazy tree entry ${d} archive path`),u=s.get(g),m=a.get(hn({sourcePath:p,type:typeof y.type=="string"?y.type:void 0,inodeGroup:typeof y.inodeGroup=="string"?y.inodeGroup:void 0,target:typeof y.target=="string"?y.target:void 0}))??u;if(!m||m.type!=="file"&&m.type!=="hardlink"||u?.inodeGroup!==void 0&&u.inodeGroup!==m.inodeGroup)throw new Error(`Serialized lazy tree entry ${g} is absent from its inventory`);let v=n.canonicalByGroup.get(m.inodeGroup);if(y.type!==m.type||y.inodeGroup!==m.inodeGroup||y.size!==m.size||w!==v?.sourcePath||y.target!==m.target||y.isSymlink!==!1||y.deleted!==!1||y.materialized!==!1)throw new Error(`Serialized lazy tree entry ${g} disagrees with its inventory`);let E=ne(y.ino,`Serialized lazy tree entry ${g} inode`,1,Number.MAX_SAFE_INTEGER),z=ne(y.generation,`Serialized lazy tree entry ${g} generation`,0,Number.MAX_SAFE_INTEGER),S=ne(y.dataSequence,`Serialized lazy tree entry ${g} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:g,ino:E,generation:z,dataSequence:S,size:m.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:w,sourcePath:p,type:m.type,inodeGroup:m.inodeGroup,...m.target===void 0?{}:{target:m.target}}});return{kind:e,content:n.content,inventory:n.entries,activation:n.activation,url:i,mountPrefix:n.mountPrefix,integrity:o,materialized:!1,entries:h}}async function ar(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=globalThis.crypto?.subtle;if(!n)throw new Error(`Lazy ${e} integrity verification is unavailable`);let i=new Uint8Array(r.byteLength);i.set(r);let o=new Uint8Array(await n.digest("SHA-256",i)),s=Array.from(o,a=>a.toString(16).padStart(2,"0")).join("");if(s!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${s} does not match expected ${t.sha256}`)}var yn=class r{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>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&ut)===sr&&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=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,i=new Map;for(let s of t.entries.values()){if(s.deleted||s.materialized||s.generation===void 0)continue;let a=r.inodeKey(s.ino,s.generation);i.has(a)||i.set(a,s)}let o=new Map;for(let[s,a]of i){let c=e.get(s);if(!(!c||c.dataSequence!==(a.dataSequence??0))){for(let l of c.paths)o.set(l,{...a,ino:c.ino,generation:c.generation,dataSequence:c.dataSequence,deleted:!1,materialized:!1});c.paths.length>0&&this.lazyArchiveInodes.set(s,t)}}t.entries=o,t.materialized=o.size===0&&!n}}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}lazyArchiveForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyArchiveInodes.get(t);if(!n)return;let i=Array.from(n.entries.values()).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return n;this.lazyArchiveInodes.delete(t);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 o=Array.from(i.entries.entries()).find(([,s])=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized)?.[0];return o===void 0?null:{token:i,path:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(n=>!n.materialized&&n.content!==void 0&&n.inventory!==void 0&&n.activation!==void 0&&Array.from(n.entries.values()).every(i=>i.deleted||i.materialized||i.isSymlink)&&n.activation.roots.some(i=>i==="/"||e===i||e.startsWith(`${i}/`)));if(t)return{token:t,path:e,directGroup:t};try{let n=this.fs.stat(e),i=this.lazyBackingForStat(n);return i?{token:i.token,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:n}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(n)===i&&this.lazyPreparations.delete(n),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(n,i),i}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 o=this.lazyBackingForPath(e);if(!o)return;n=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(n?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=n.error instanceof Error?n.error.message:String(n.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=n.error,s}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,o=n.length>1?n.replace(/\/+$/,""):n,s=`${i}/`,a=`${o}/`,c=r.inodeKey(e.ino,e.generation),l=(e.mode&ut)===nn,h=f=>f===i?o:l&&f.startsWith(s)?a+f.slice(s.length):f;for(let[f,d]of this.lazyFiles)!l&&f!==c||(d.paths=new Set(Array.from(d.paths,h)),d.path=h(d.path));for(let f of this.lazyArchiveGroups){let d=new Map;for(let[y,g]of f.entries){let p=g.generation===void 0?null:r.inodeKey(g.ino,g.generation);d.set(l||p===c?h(y):y,g)}f.entries=d,f.inventory&&(f.inventory=f.inventory.map(y=>({...y,vfsPath:h(y.vfsPath),...y.type==="hardlink"&&y.target!==void 0?{target:h(y.target)}:{}}))),f.activation&&(f.activation={...f.activation,roots:f.activation.roots.map(h)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new r(ze.mkfs(e,t))}static fromExisting(e){return new r(ze.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 o=this.serializeLazyEntries(),s=this.serializeLazyArchiveEntries(),a=new t(n.byteLength);new Uint8Array(a).set(n);let c=new r(ze.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(o),c.importLazyArchiveEntries(s);let l=Math.min(e,Math.max(n.byteLength,oa)),h=new t(l,{maxByteLength:e}),f=r.create(h,e);f.setImageMetadata(this.imageMetadata);let d=new Set(o.flatMap(g=>g.paths??[g.path])),y=new Set;for(let g of s)if(!g.materialized)for(let p of g.entries)!p.deleted&&!p.isSymlink&&y.add(p.vfsPath);return c.copyPathToFreshFileSystem("/",f,d,y,new Map),f.importLazyEntries(o.map(g=>{let p=f.fs.lstat(g.path);return{...g,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),f.importLazyArchiveEntries(s.map(g=>({...g,entries:g.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let w=f.fs.lstat(p.vfsPath);return{...p,ino:w.ino,generation:w.generation,dataSequence:w.dataSequence}})}))),f}getImageMetadata(){return pa(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:hr(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:Ea()};for(let n of this.lazyDownloadListeners)try{n(t)}catch{}}async fetchLazyBytes(e,t){let n=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:n,totalBytes:i})}}}catch(f){try{await c.cancel(f)}catch{}throw f}}finally{c.releaseLock()}let h=_a(l,n);return ee(t.signal),await ar(h,e.kind,e.integrity),ee(t.signal),this.emitLazyDownload({...o,status:"complete",loadedBytes:n,totalBytes:i??n}),h}catch(a){if(t.signal?.aborted){let h=t.signal.reason,f=h instanceof Error?h.message:String(h);throw this.emitLazyDownload({...o,status:"error",loadedBytes:n,totalBytes:i,error:f}),h}let c=s+1({...u})),activation:f,entries:new Map},p=u=>{let m=u.split("/").filter(Boolean),v="";for(let E=0;Em.vfsPath.split("/").length-v.vfsPath.split("/").length))if(u.type==="directory"){p(u.vfsPath);try{this.fs.mkdir(u.vfsPath,u.mode),this.fs.chmod(u.vfsPath,u.mode)}catch{if((this.fs.lstat(u.vfsPath).mode&ut)!==nn)throw new Error(`Lazy tree directory collides at ${u.vfsPath}`)}}for(let u of l){if(u.type!=="symlink")continue;p(u.vfsPath),this.fs.symlink(u.target,u.vfsPath);let m=this.fs.lstat(u.vfsPath);g.entries.set(u.vfsPath,{ino:m.ino,generation:m.generation,dataSequence:m.dataSequence,size:u.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:u.sourcePath,sourcePath:u.sourcePath,type:"symlink",target:u.target})}let w=new Map;for(let u of l){if(u.type!=="file")continue;p(u.vfsPath);let m=this.fs.createLazyStub(u.vfsPath,u.mode);this.invalidateLazyData(m),w.set(u.inodeGroup,m);let v={ino:m.ino,generation:m.generation,dataSequence:m.dataSequence,size:u.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:u.sourcePath,sourcePath:u.sourcePath,type:"file",inodeGroup:u.inodeGroup};g.entries.set(u.vfsPath,v)}for(let u of l){if(u.type!=="hardlink")continue;let m=d.get(u.inodeGroup);p(u.vfsPath),this.fs.link(m.vfsPath,u.vfsPath);let v=this.fs.lstat(u.vfsPath),E=w.get(u.inodeGroup);if(v.ino!==E.ino||v.generation!==E.generation)throw new Error(`Lazy tree hardlink ${u.vfsPath} did not share its inode`);g.entries.set(u.vfsPath,{ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:u.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:m.sourcePath,sourcePath:u.sourcePath,type:"hardlink",inodeGroup:u.inodeGroup,target:u.target})}if(y!==void 0)for(let u of l)this.lchown(u.vfsPath,y.uid,y.gid);for(let u of g.entries.values())u.isSymlink||u.generation===void 0||this.lazyArchiveInodes.set(r.inodeKey(u.ino,u.generation),g);return this.lazyArchiveGroups.push(g),g}registerLazyTreeWithMaterializationHandle(e,t,n="/",i,o){let s=this.registerLazyTreeInternal(e,t,n,i,!0,o),a=Object.freeze({[ta]:!0});return this.deferredTreeMaterializationHandles.set(a,s),a}registerLazyArchiveFromEntries(e,t,n,i,o){let s=ga(e,t,n,i);s.some(({entry:c})=>!c.isDirectory&&!c.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let a={...o?{content:dn({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:s.reduce((c,l)=>c+l.entry.uncompressedSize,0),sourceEntryCount:s.length,transports:[e]})}:{},url:e,mountPrefix:n,integrity:Ft(o),materialized:!1,entries:new Map};for(let{entry:c,vfsPath:l}of s){if(c.isDirectory)continue;let h=l.split("/").filter(Boolean),f="";for(let d=0;dc.deleted||c.materialized),this.lazyArchiveGroups.push(a),a}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0)}importLazyArchiveEntriesInternal(e,t,n){let i=xe(e,"Serialized lazy archive groups",0,la).map((a,c)=>{if(typeof a!="object"||a===null||Array.isArray(a))throw new Error(`Serialized lazy archive group ${c} must be an object`);let l=a.kind;if(l===fn||l===Ni)return Oa(a,l);if(l===ln)return $i(a,!1);if(l!==void 0)throw new Error(`Serialized lazy archive group ${c} has an unsupported kind`);if(n)throw new Error(`Serialized lazy archive group ${c} is missing its kind discriminator`);return $i(a,!0)});Fi([...this.serializeLazyArchiveEntries(),...i]);let o=[],s=new Map;for(let a of i){let c=new Map,l=a.mountPrefix.replace(/\/+$/,""),h=a.content!==void 0&&a.inventory!==void 0&&a.activation!==void 0,f=h?new Map(a.inventory.map(u=>[u.vfsPath,u])):null,d=h?new Map(a.inventory.map(u=>[hn(u),u])):null,y=new Map,g=new Map;for(let u of a.entries){let m=null,v=a.materialized||u.materialized===!0||u.isSymlink;if(!u.deleted&&!v){if((u.generation===void 0||u.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{m=this.fs.lstat(u.vfsPath)}catch{if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} is missing from the filesystem`);continue}if(m.ino!==u.ino){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} has a different inode`);continue}if(u.generation!==void 0&&m.generation!==u.generation){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} has a different generation`);continue}if(u.dataSequence===void 0){if(!r.canAdoptLegacyLazyStub(m)){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} is not pristine`);continue}}else if(m.dataSequence!==u.dataSequence){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} has a different data sequence`);continue}if(h){let z=f.get(u.vfsPath),S=d.get(hn(u))??z;if(!S||(m.mode&ut)!==sr||m.size!==0||(m.mode&4095)!==S.mode||z?.inodeGroup!==void 0&&z.inodeGroup!==S.inodeGroup)throw new Error(`Serialized lazy tree stub ${u.vfsPath} disagrees with its inventory`);let k=r.inodeKey(m.ino,m.generation),I=u.inodeGroup,A=y.get(I),B=g.get(k);if(A!==void 0&&A!==k||B!==void 0&&B!==I)throw new Error(`Serialized lazy tree inode group ${I} disagrees with the filesystem`);y.set(I,k),g.set(k,I)}}c.set(u.vfsPath,{ino:u.ino,generation:m?.generation??u.generation,dataSequence:m?.dataSequence??u.dataSequence,size:u.size,isSymlink:u.isSymlink,deleted:u.deleted,materialized:v,archivePath:u.archivePath??u.vfsPath.slice(l.length+1),sourcePath:u.sourcePath??u.archivePath??u.vfsPath.slice(l.length+1),type:u.type??(u.isSymlink?"symlink":"file"),inodeGroup:u.inodeGroup,target:u.target})}let p=a.content===void 0?void 0:dn(a.content),w={content:p,url:p?.transports[0]??a.url,mountPrefix:a.mountPrefix,integrity:p?{sha256:p.sha256,bytes:p.bytes}:Ft(a.integrity),materialized:a.materialized||!(p&&a.inventory)&&Array.from(c.values()).every(u=>u.deleted||u.materialized),inventory:a.inventory?.map(u=>({...u})),activation:a.activation?{mode:a.activation.mode,capabilities:[...a.activation.capabilities],roots:[...a.activation.roots]}:void 0,entries:c};if(o.push(w),!w.materialized){for(let[,u]of c)if(!u.deleted&&!u.materialized&&u.generation!==void 0){let m=r.inodeKey(u.ino,u.generation),v=s.get(m);if(v!==void 0&&v!==w)throw new Error(`Serialized lazy archive groups share pending inode ${m}`);if(this.lazyArchiveInodes.has(m))throw new Error(`Serialized lazy archive group collides with pending inode ${m}`);s.set(m,w)}}}this.lazyArchiveGroups.push(...o);for(let[a,c]of s)this.lazyArchiveInodes.set(a,c)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups)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=Array.from(t.entries,([o,s])=>({vfsPath:o,ino:s.ino,generation:s.generation,dataSequence:s.dataSequence,size:s.size,isSymlink:s.isSymlink,deleted:s.deleted,materialized:s.materialized,archivePath:s.archivePath,sourcePath:s.sourcePath,type:s.type,inodeGroup:s.inodeGroup,target:s.target})).filter(o=>!o.deleted&&!o.materialized);if(n.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let i=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(i&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push(i?{kind:t.content.source===void 0?fn:Ni,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:n}:{kind:ln,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:n})}return e}exportLazyArchiveEntries(){return this.reconcileLazyIdentityState(this.fs.identityState()),this.serializeLazyArchiveEntries()}pendingDeferredTreeUsage(){return this.reconcileLazyIdentityState(this.fs.identityState()),Hi(this.serializeLazyArchiveEntries())}assertCanAppendDeferredTreeUsage(e){ur(e);let t=this.pendingDeferredTreeUsage();ur({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=>!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(n=>!n.deleted&&!n.materialized))).length>=we.maxGroups)throw new Error(`Cannot register another lazy archive group: ${we.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,n=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!o.materialized&&o.activation?.mode==="boot-prefetch"),t=0,n,i=Array.from({length:Math.min(e.length,ca)},async()=>{for(;n===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){n??=s}}});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");if(n.materialized)return!1;let i=this.lazyPreparations.get(n);if(i!==void 0)return i.promise;let o=new Uint8Array(t.byteLength);o.set(t);let s={status:"pending",promise:Promise.resolve(!1)};s.promise=Promise.resolve().then(async()=>(await ar(o,"tree",n.integrity),await this.materializeArchiveBytes(n,o),!0)).then(a=>(s.status="fulfilled",a),a=>{throw s.status="rejected",s.error=a,a}),s.promise.catch(()=>{}),this.lazyPreparations.set(n,s);try{return await s.promise}finally{this.lazyPreparations.get(n)===s&&this.lazyPreparations.delete(n)}}async prepareLazyTreeGroup(e){if(e.materialized)return!1;let t={token:e,path:e.activation?.roots[0]??e.mountPrefix,directGroup:e},n=this.lazyPreparations.get(e)??this.startLazyPreparation(t);try{return await n.promise}finally{this.lazyPreparations.get(e)===n&&this.lazyPreparations.delete(e)}}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 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(n)!==i)return!1;for(let l of new Set([e,...i.paths]))if(ee(s.signal),this.fs.replaceIfIdentity(l,i.ino,i.generation,i.dataSequence,a))return i.path=l,this.lazyFiles.delete(n),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(n);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(n)):!1}async decodeAndValidateLazyTree(e,t){let n=e.content,i=e.inventory;if(!n||!i)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,s=new Map(i.map(f=>[f.vfsPath,f]));if(n.source!==void 0)for(let f of n.source.entries)o.set(f.sourcePath,f);else for(let f of i){if(f.type==="hardlink"){let y=s.get(f.target);if(!y)throw new Error(`Lazy tree hardlink target disappeared: ${f.target}`);if(f.sourcePath===y.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:s.get(f.target)?.sourcePath}:{}})}let a=new Map,c=0;if(n.decoder==="zip-v1"){let{parseZipCentralDirectory:f,extractZipEntryBounded:d}=await Promise.resolve().then(()=>(Yn(),jn)),y=f(t);if(y.length!==n.sourceEntryCount||y.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let g of y){let p=g.isDirectory?g.fileName.replace(/\/$/,""):g.fileName;if(a.has(p))throw new Error(`Lazy ZIP tree duplicates source member ${p}`);let w=o.get(p);if(!w)throw new Error(`Lazy ZIP tree has undeclared source member ${p}`);if(c+=g.uncompressedSize,c>n.expandedBytes||g.uncompressedSize!==w.size)throw new Error(`Lazy ZIP tree member ${p} exceeds its inventory`);let u=g.isDirectory?"directory":g.isSymlink?"symlink":"file",m=n.modePolicy==="portable-posix-v1"?u==="directory"?493:u==="symlink"?511:(g.mode&73)!==0?493:420:g.mode&4095;if(u!==w.type||m!==w.mode)throw new Error(`Lazy ZIP tree member ${p} differs from inventory`);if(g.isDirectory)a.set(p,{type:"directory",mode:m});else{let v=d(t,g,w.size);if(g.isSymlink){let E;try{E=new TextDecoder("utf-8",{fatal:!0}).decode(v)}catch{throw new Error(`Lazy ZIP tree symlink ${p} is not UTF-8`)}a.set(p,{type:"symlink",mode:m,target:E})}else a.set(p,{type:"file",mode:m,data:v})}}}else{let{parseTarGzip:f}=await Promise.resolve().then(()=>(Oi(),Pi)),d=f(t,{label:`Lazy tree ${n.sha256}`,limits:{maxCompressedBytes:n.bytes,maxUncompressedBytes:n.expandedBytes,maxEntries:n.sourceEntryCount}});c=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let y of d){if(a.has(y.path))throw new Error(`Lazy TAR tree duplicates source member ${y.path}`);y.type==="file"?a.set(y.path,{type:"file",mode:y.mode,data:y.data}):y.type==="directory"?a.set(y.path,{type:"directory",mode:y.mode}):a.set(y.path,{type:y.type,mode:y.mode,target:y.linkName})}}if(a.size!==n.sourceEntryCount||a.size!==o.size||c!==n.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[f,d]of o){let y=a.get(f);if(!y)throw new Error(`Lazy tree is missing source member ${f}`);let g=d.type;if(y.type!==g)throw new Error(`Lazy tree member ${f} is ${y.type}, expected ${g}`);if((y.mode&4095)!==d.mode)throw new Error(`Lazy tree member ${f} mode differs from inventory`);if(g==="file"&&y.data?.byteLength!==d.size)throw new Error(`Lazy tree member ${f} size differs from inventory`);if(g==="symlink"&&y.target!==d.target)throw new Error(`Lazy tree symlink ${f} target differs from inventory`);if(g==="hardlink"&&y.target!==d.target)throw new Error(`Lazy tree hardlink ${f} target differs from inventory`)}let l=new Set(i.flatMap(f=>f.materialization==="archive-homebrew-relocate"?[f.sourcePath]:[]));if(n.source!==void 0){let f=new Map(n.source.entries.map(g=>[g.sourcePath,g])),d=Vi(n.source.entries),y=n.source.entries.filter(g=>g.sourcePath==="INSTALL_RECEIPT.json"||g.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(y.length>1)throw new Error(`Lazy Homebrew bottle has ${y.length} INSTALL_RECEIPT.json source members, expected at most one`);if(y.length===0){if(l.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let g=y[0],p=g.type==="file"?g:d.get(g.sourcePath),w=p===void 0?void 0:a.get(p.sourcePath);if(p?.type!=="file"||w?.type!=="file"||w.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let u=si(w.data),m=g.sourcePath.lastIndexOf("/"),v=m<0?"":g.sourcePath.slice(0,m),E=new Set(u.changedFiles.map(S=>v.length===0?S:`${v}/${S}`));if(l.size!==E.size||[...l].some(S=>!E.has(S)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let z=new Set;for(let S of E){let k=f.get(S),I=k?.type==="file"?k:k===void 0?void 0:d.get(k.sourcePath),A=I===void 0?void 0:a.get(I.sourcePath);if(I?.type!=="file"||A?.type!=="file"||A.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${S} is not regular`);z.has(I.sourcePath)||(A.data=oi(A.data,u,S),z.add(I.sourcePath))}}}else if(l.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let h=new Map;for(let f of i){if(f.type!=="file"||f.materialization==="descriptor")continue;let d=a.get(f.sourcePath);if(d?.type!=="file"||!d.data)throw new Error(`Lazy tree has no file content for ${f.sourcePath}`);h.set(f.sourcePath,d.data)}return h}async ensureArchiveMaterialized(e,t){if(e.materialized)return;let n=e.content!==void 0&&e.inventory!==void 0,i=this.lazyTransport,o=n?e.content.transports:[e.url],s=[],a=null;for(let[c,l]of o.entries())try{a=await this.fetchLazyBytes({id:`archive:${e.mountPrefix}:${e.content?.sha256??l}:${c}`,kind:n?"tree":"archive",url:l,mountPrefix:e.mountPrefix,integrity:e.integrity},i);break}catch(h){if(ee(i.signal),Zi(h))throw h;s.push(h instanceof Error?h.message:String(h))}if(ee(i.signal),a===null)throw new Error(`All ${o.length} lazy ${n?"tree":"archive"} transports failed: ${s.join("; ")}`);ee(i.signal),await this.materializeArchiveBytes(e,a,t,i.signal)}async materializeArchiveBytes(e,t,n,i){if(ee(i),e.materialized)return;let s=e.content!==void 0&&e.inventory!==void 0?await this.decodeAndValidateLazyTree(e,t):null;ee(i);let{parseZipCentralDirectory:a,extractZipEntry:c}=await Promise.resolve().then(()=>(Yn(),jn));ee(i);let l=s?[]:a(t),h=new Map;for(let g of l){if(h.has(g.fileName))throw new Error(`Lazy archive contains duplicate member: ${g.fileName}`);h.set(g.fileName,g)}let f=e.mountPrefix.replace(/\/+$/,""),d=new Map;for(let[g,p]of e.entries){if(p.deleted||p.materialized)continue;let w=p.archivePath??g.slice(f.length+1),u=s?void 0:h.get(w),m=s?.get(w);if(s){if(m===void 0||m.byteLength!==p.size)throw new Error(`Lazy tree member ${w} does not match its registered metadata`)}else if(u===void 0||u.isDirectory||u.isSymlink||u.uncompressedSize!==p.size)throw new Error(`Lazy archive member ${w} does not match its registered metadata`);if(p.generation===void 0)continue;let v=r.inodeKey(p.ino,p.generation),E=d.get(v);if(E&&E.archivePath!==w)throw new Error(`Lazy archive aliases for inode ${v} name different members`);if(!E){let z=m??c(t,u);if(z.byteLength!==p.size)throw new Error(`Lazy archive member ${w} extracted ${z.byteLength} bytes, expected ${p.size}`);d.set(v,{archivePath:w,content:z})}}let y=n?r.inodeKey(n.ino,n.generation):null;for(let g=0;g<3;g++){let p=new Map;for(let[w,u]of e.entries){if(u.deleted||u.materialized||u.generation===void 0)continue;let m=r.inodeKey(u.ino,u.generation);if(this.lazyArchiveInodes.get(m)!==e)continue;let v=d.get(m);if(!v)throw new Error(`Lazy archive has no extracted content for inode ${m}`);let E=p.get(m);E||(E={ino:u.ino,generation:u.generation,dataSequence:u.dataSequence??0,paths:new Set,content:v.content},p.set(m,E)),E.paths.add(w),n&&n.ino===u.ino&&n.generation===u.generation&&E.paths.add(n.path)}if(p.size>0&&(ee(i),!this.fs.replaceManyIfIdentities(Array.from(p.values(),u=>({paths:Array.from(u.paths),expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,data:u.content}))))){if(this.reconcileLazyIdentityState(this.fs.identityState()),y&&!this.lazyArchiveInodes.has(y))return;continue}ee(i);for(let[w,u]of p){this.lazyArchiveInodes.delete(w);for(let m of e.entries.values())m.ino===u.ino&&m.generation===u.generation&&(m.materialized=!0)}if(e.materialized=Array.from(e.entries.values()).every(w=>w.deleted||w.materialized),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),y&&!this.lazyArchiveInodes.has(y)))return}if(y&&this.lazyArchiveInodes.has(y))throw new Error(`Lazy archive member kept changing names while materializing: ${n?.path}`)}async materializeAllLazyEntries(){for(let t=0;t<3;t++){this.reconcileLazyIdentityState(this.fs.identityState());let n=this.lazyArchiveGroups.filter(s=>!s.materialized&&s.content!==void 0&&s.inventory!==void 0);if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&n.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 n)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>!t.materialized&&t.content!==void 0&&t.inventory!==void 0);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();let{bytes:t,identities:n}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(n);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.serializeLazyArchiveEntries();Fi(a);let c=a.length>0,l=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(l.byteLength>an)throw new Error(`VFS image lazy archive metadata exceeds ${an} bytes`);let h=e?.metadata===void 0?this.imageMetadata:e.metadata,f=wa(h),d=f.byteLength>0,y=c?4+l.byteLength:0,g=d?4+f.byteLength:0,p=se+t.byteLength+4+s.byteLength+y+g,w=new Uint8Array(p),u=new DataView(w.buffer);u.setUint32(0,cr,!0),u.setUint32(4,lr,!0),u.setUint32(8,(o?nr:0)|(c?sn:0)|(c?ir:0)|(d?rr:0),!0),u.setUint32(12,t.byteLength,!0),w.set(t,se);let m=se+t.byteLength;if(u.setUint32(m,s.byteLength,!0),s.byteLength>0&&w.set(s,m+4),c){let v=m+4+s.byteLength;u.setUint32(v,l.byteLength,!0),w.set(l,v+4)}if(d){let v=m+4+s.byteLength+y;u.setUint32(v,f.byteLength,!0),w.set(f,v+4)}return w}static readImageMetadata(e){let t=rn(e);if(!(t.flags&rr))return null;let{metadataOffset:n}=Ci(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthdt)throw new Error(`VFS image metadata exceeds ${dt} bytes`);if(t.image.byteLength0){let w=n.subarray(g+4,g+4+p),u=xe(Mi(w,"VFS image lazy metadata"),"VFS image lazy entries",0,ht);y.importLazyEntriesInternal(u,!0)}if(o&sn){let w=a.archiveOffset,u=i.getUint32(w,!0);if(u>0){let m=n.subarray(w+4,w+4+u),v=Mi(m,"VFS image lazy archive metadata");y.importLazyArchiveEntriesInternal(v,!0,!!(o&ir))}}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 o of i.entries.values())if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,n){(t&Tt)===0&&!((t&Pt)!==0&&(t&Un)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,n);return(t&Tt)!==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 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 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 s=this.fs.writeAt(e,t.subarray(0,i),n);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}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 On(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 On(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 o=this.lazyArchiveInodes.get(n);if(o){let s=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(n)}else s&&o.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 o=!1;if(i){let s=r.inodeKey(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 l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(s)):l&&c.entries.delete(t)}}o||this.rewriteLazyNamespacePaths(n,e,t)}link(e,t){let n=this.fs.link(e,t),i=r.inodeKey(n.ino,n.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let a=Array.from(s.entries.values()).find(c=>c.ino===n.ino&&c.generation===n.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,n){this.fs.chown(e,t,n)}lchown(e,t,n){this.fs.lchown(e,t,n)}createFileWithOwner(e,t,n,i,o){let s=this.open(e,577,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),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,o){let s=this.lstat(e),a=s.mode&ut,c=s.mode&4095;if(a===nn){e==="/"?(t.chown(e,s.uid,s.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,s.uid,s.gid);let d=this.opendir(e);try{for(;;){let y=this.readdir(d);if(!y)break;y.name==="."||y.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${y.name}`:`${e}/${y.name}`,t,n,i,o)}}finally{this.closedir(d)}r.applyTimes(t,e,s);return}let l=s.nlink>1?`${s.dev}:${s.ino}`:null,h=l?o.get(l):void 0;if(h){t.link(h,e);return}if(a===na){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),l&&o.set(l,e);return}if(a!==sr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(n.has(e)||i.has(e)){t.createFileWithOwner(e,c,s.uid,s.gid,new Uint8Array(0)),r.applyTimes(t,e,s),l&&o.set(l,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,c),l&&o.set(l,e)}copyRegularFileToFreshFileSystem(e,t,n,i){let o=this.open(e,ra,0),s=null;try{s=t.open(e,ia,i);let a=new Uint8Array(Math.min(sa,Math.max(1,n.size))),c=n.size;for(;c>0;){let l=Math.min(a.byteLength,c),h=this.read(o,a,null,l);if(h<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let f=0;for(;f!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(r)}`);return r}var qe=new Set(["wasm32","wasm64"]);function Ce(r){if(Ka(r),!r.startsWith("programs/"))return r;let e=r.slice(9),t=e.split("/",1)[0];return qe.has(t)?r:`programs/wasm32/${e}`}function Ga(r,e=$(zn(),"wasm")){let t=Ce(r),n=[$(e,t)];return r==="kernel.wasm"?n.push($(e,"kandelo-kernel.wasm")):r==="userspace.wasm"?n.push($(e,"wasm_posix_userspace.wasm")):r==="rootfs.vfs"&&n.push($(e,"rootfs.vfs")),n}var vn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function ss(){let r=[],e=!1;try{let n=Ve();e=!0;for(let[i,o]of[["local-binaries",$(n,"local-binaries")],["binaries",$(n,"binaries")]])r.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[$(o,Ce(s))]}})}catch{}let t=$(zn(),"wasm");return r.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(n){return Ga(n,t)}}),r}function yt(r,e){return new Error(`Invalid package manifest ${r}: ${e}`)}function de(r){try{return En(r),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Yi(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||r.split("/").some(n=>!n||n==="."||n===".."))throw yt(e,`${t} must be a normalized portable relative path`);return r}function mn(r,e,t,n=!0){if(r.length===0||r==="."||r===".."||r.includes("/")||r.includes("\\")||r.includes("\0")||!n&&r.includes("@"))throw yt(e,`${t} must be a safe single path component`);return r}var Xi="kandelo-program-packages-v2",ve="program-packages.json",Ji=null,wn=null,gr=0;function os(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let r=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(e=>e.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,e.slice(2)):Sn(e)?pe(e):(r??=Ve(),pe(r,e)))}try{return[$(Ve(),"packages","registry")]}catch{return null}}function Wa(){let r;try{r=Ve()}catch{return null}if(!$t($(r,"tools","xtask","Cargo.toml"))||!$t($(r,"scripts","dev-shell.sh")))return null;try{let e=me(vr()),t=me(r);return[$(t,"host"),$(t,"scripts")].some(i=>$t(i)&&kr(me(i),e))?t:null}catch{return null}}function Er(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 fa(r){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",n=e?["-vV"]:[F(r,"scripts","dev-shell.sh"),"rustc","-vV"],i=fr(t,n,{cwd:r,encoding:"utf8"});if(i.status!==0)throw new Error(ur(t,n,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 ${r}`);return o}function cr(r){try{if(hn(r).isFile())return pe(r)}catch{}throw new Error(`Prepared xtask is not a regular file: ${r}`)}function da(r){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let l=yn(e)?ge(e):ge(r,e);return cr(l)}if(dn?.sourceRepoRoot===r)return cr(dn.xtaskPath);let t=fa(r),n=F(r,"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:[F(r,"scripts","dev-shell.sh"),"cargo",...i],c=fr(s,a,{cwd:r,encoding:"utf8"});if(c.status!==0)throw new Error(ur(s,a,c));return dn={sourceRepoRoot:r,xtaskPath:cr(n)},dn.xtaskPath}function ua(){let r=la();if(r===null)return;let e=Mi();if(e===null)return;if(_i){_i(r,e);return}let t=da(r),n=["build-deps","program-index-context-check"],i=fr(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: -${ur(t,n,i)}`)}function ha(r,e){if(ar>0||!r.some(t=>t.startsWith("programs/")))return e();ar+=1;try{return ua(),e()}finally{ar-=1}}function Re(r,e){let t=Object.keys(r).sort(),n=[...e].sort();return t.length===n.length&&t.every((i,o)=>i===n[o])}function lr(r){let e;try{e=JSON.parse(Ge(r,"utf8"))}catch(s){throw new Error(`Invalid program package index ${r}: ${s instanceof Error?s.message:String(s)}`)}if(typeof e!="object"||e===null||!Re(e,["format","identities","packages"])||e.format!==Li||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 ${Li}`);let t=new Map,n=e.identities;for(let[s,a]of Object.entries(n)){if(fn(s,r,"identity package name",!1),typeof a!="object"||a===null||!Re(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(s)}`);let c=a.cacheKeys;if(!Re(c,["wasm32","wasm64"])||Object.values(c).some(l=>typeof l!="string"||!/^[a-f0-9]{64}$/.test(l)))throw new Error(`Invalid program package index ${r}: 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(fn(s,r,"package name",!1),typeof a!="object"||a===null||!Re(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(s)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(d=>typeof d!="string"||!We.has(d)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid arches`);let l=a.cacheKeys;if(!Re(l,c)||Object.values(l).some(d=>typeof d!="string"||!/^[a-f0-9]{64}$/.test(d)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid cache keys`);let u=a.dependencyClosures;if(!Re(u,c))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid dependency closure arches`);let f={};for(let d of c){let m=u[d];if(!Array.isArray(m))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has a malformed dependency closure for ${d}`);let p=new Set;f[d]=m.map((w,v)=>{if(typeof w!="object"||w===null||!Re(w,["packageName","manifestSha256","cacheKey"])||typeof w.packageName!="string"||typeof w.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(w.manifestSha256)||typeof w.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(w.cacheKey))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency ${v+1} for ${d} is malformed`);let S=w;if(fn(S.packageName,r,`${s} dependency packageName`,!1),S.packageName===s||p.has(S.packageName))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency closure for ${d} must contain unique dependencies other than itself`);p.add(S.packageName);let b=t.get(S.packageName);if(!b||b.manifestSha256!==S.manifestSha256||b.cacheKeys[d]!==S.cacheKey)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency ${JSON.stringify(S.packageName)} for ${d} does not match the index's authoritative contextual identity`);return S})}let h=a.members.map((d,m)=>{if(typeof d!="object"||d===null||d.kind!=="output"&&d.kind!=="runtime-file"||typeof d.sourceArtifact!="string"||typeof d.mirrorPath!="string")throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} member ${m+1} is malformed`);let p=d,w=p.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Re(p,w))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} member ${m+1} has unknown or missing fields`);if(Ai(p.sourceArtifact,r,`${s} sourceArtifact`),Ai(p.mirrorPath,r,`${s} mirrorPath`),p.kind==="output"){if(typeof p.outputName!="string"||p.forkInstrumentation!=="auto"&&p.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} output member lacks outputName or forkInstrumentation`);fn(p.outputName,r,`${s} outputName`)}else if(typeof p.guestPath!="string"||!p.guestPath.startsWith("/")||!Number.isInteger(p.mode)||p.mode<0||p.mode>511)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} runtime member lacks valid guestPath or mode`);return p});if(h.length===0||new Set(h.map(d=>d.sourceArtifact)).size!==h.length||new Set(h.map(d=>d.mirrorPath)).size!==h.length||h.length===1&&h[0].mirrorPath.includes("/")||h.length>1&&h.some(d=>!d.mirrorPath.startsWith(`${s}/`)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} members are empty, collide, or violate scalar/package-directory layout`);let y=a.manifestSha256,g=t.get(s);if(!g||g.manifestSha256!==y||c.some(d=>g.cacheKeys[d]!==l[d]))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} does not match its contextual package identity`);i.set(s,{manifestSha256:y,arches:c,cacheKeys:l,dependencyClosures:f,members:h})}return{identities:t,packages:i,indexPath:r}}function $i(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 hr(){let r=F(gn(),"wasm",we);return de(r)?lr(r):null}function ya(r){let e=hr();if(!e)return null;let t=r.split("/");if(t[0]!=="programs"||!We.has(t[1]))return null;let n=t[1];if(t.length>=4){let o=t[2];return e.packages.get(o)?.arches.includes(n)?o:null}if(t.length!==3)return null;let i=t[2];for(let[o,s]of e.packages)if(s.arches.includes(n)&&s.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return o;return null}function Pi(r){let e=ya(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 Fi(){let r=Mi(),e=new Map,t=new Map,n=new Map,i=new Map,o=[];if(r===null){let l=F(gn(),"wasm",we);if(!de(l))return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o};let u=lr(l);for(let[f,h]of u.identities)e.set(f,{...h,packageName:f,policyPath:`${u.indexPath}#identities.${f}`});for(let[f,h]of u.packages)o.push({packageName:f,projection:h,selected:!0}),n.set(f,{...h,packageName:f,policyPath:`${u.indexPath}#${f}`});return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o}}let s=new Set,a=null,c=null;for(let l of r){if(!de(l))continue;if(!Ke(l).isDirectory())throw new Error(`Program registry root is not a directory: ${l}`);let u=F(l,we);if(!de(u))throw new Error(`Program registry ${l} is missing ${we}; generate it with xtask build-deps program-index`);let f=lr(u);a??=f.identities,c??=f.packages;let h=Jo(l,{withFileTypes:!0}).filter(y=>y.isDirectory()||y.isSymbolicLink()).sort((y,g)=>y.name.localeCompare(g.name));for(let y of h){let g=y.name,d=F(l,g,"package.toml");if(!de(d))continue;let m=!1;try{m=Ke(d).isFile()}catch{m=!1}if(!m)continue;let p=f.packages.get(g),w=!s.has(g);if(p&&o.push({packageName:g,projection:p,selected:w}),!w)continue;s.add(g);let v=a.get(g);v?e.set(g,{...v,packageName:g,manifestPath:d,policyPath:d}):t.set(g,d);let S=c.get(g);if(!S){i.set(g,d);continue}n.set(g,{...S,packageName:g,manifestPath:d,policyPath:d})}}return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o}}function Oi(r){if(!r.manifestPath)return;let e;try{e=Ge(r.manifestPath)}catch(n){throw new Error(`Program package identity cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(Bi("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package identity is stale for ${r.manifestPath}; regenerate ${we}`)}function ga(r){if(!r.manifestPath)return;let e;try{e=Ge(r.manifestPath)}catch(n){throw new Error(`Program package projection cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(Bi("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package projection is stale for ${r.manifestPath}; regenerate ${we}`)}function Bt(r){let e=yr(),t=e.packages.get(r);if(t)return ga(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 ${we}; regenerate the registry projection`);return null}function pa(r,e){let t=r.dependencyClosures[e];if(!t)throw dt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a dependency identity closure for ${e}`);let n=Fi(),i=n.identities.get(r.packageName);if(!i){let s=n.unidentifiedPackages.get(r.packageName);throw new Error(`Program package ${JSON.stringify(r.packageName)} has no authoritative contextual identity for ${e}${s?` at ${s}`:""}; regenerate ${we} with the exact ordered registry roots`)}Oi(i);let o=i.cacheKeys[e];if(i.manifestSha256!==r.manifestSha256||o!==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 ${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=n.identities.get(s.packageName);if(!a){let l=n.unidentifiedPackages.get(s.packageName);throw l?new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but the first-hit package at ${l} has no contextual identity in ${we}`):new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}Oi(a);let c=a.cacheKeys[e];if(a.manifestSha256!==s.manifestSha256||c!==s.cacheKey)throw new Error(`Program package ${JSON.stringify(r.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 yr(){let r=Fi(),{physicalProgramClaims:e,...t}=r,n={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let o of r.packages.values()){let s=o.members.length>1;for(let a of o.arches)for(let c of o.members){let l=i.find(y=>y.arch===a&&(y.path===c.mirrorPath||y.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${y.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(o.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:o.packageName}),c.kind!=="output")continue;let u=c.mirrorPath.split("/").at(-1),f=`${a}/${u}`,h=n.legacyFlatOutputs.get(f);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(f,h)),s?h.packagePaths.set(`programs/${a}/${c.mirrorPath}`,o.packageName):h.scalarOwners.add(o.packageName),c.forkInstrumentation==="disabled"&&n.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,o.packageName)}}for(let{packageName:o,projection:s,selected:a}of e)if(!(a&&r.packages.has(o)))for(let c of s.arches)for(let l of s.members){if(l.kind!=="output")continue;let u=l.mirrorPath.split("/").at(-1),f=`${c}/${u}`,h=n.legacyFlatOutputs.get(f);h||(h={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(f,h)),h.shadowedOwners.add(o)}return n}function ma(r){let e=r.split("/");if(e.length!==3||e[0]!=="programs"||!We.has(e[1]))return null;let t=yr().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let n of t.scalarOwners){let i=Bt(n);if(i)return i}for(let n of t.packagePaths.values())Bt(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=Bt(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 Ti(r,e,t){if(!r.arches.includes(e))throw dt(r.policyPath,`package ${JSON.stringify(r.packageName)} does not declare resolver artifacts for ${e}`);let n=r.cacheKeys[e];if(!n)throw dt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a cache identity for ${e}`);pa(r,e);let i=$i(r),o=r.members.map(s=>({packageName:r.packageName,relPath:`programs/${e}/${s.mirrorPath}`,sourceArtifact:s.sourceArtifact,cacheKey:n,forkInstrumentation:s.kind==="output"?s.forkInstrumentation??null:null,projectionIdentity:i}));if(!o.some(s=>s.relPath===t))throw dt(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:o}}function wa(r){let e=Be(r),t=e.split("/");if(t[0]==="programs"&&!sa()&&hr()===null)throw new Error(`Installed host package is missing wasm/${we}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let s=ma(e);return s?Ti(s,t[1],e):(Pi(e),null)}if(t.length<4||t[0]!=="programs"||!We.has(t[1]))return null;let n=t[1],i=t[2],o=Bt(i);return o?Ti(o,n,e):(Pi(e),null)}function va(r){let e=Be(r);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function Ea(r){let e=Be(r);for(let t of We){let n=`programs/${t}/`;if(e.startsWith(n)){let i=yr().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(n.length)}`);return i?Bt(i)!==null:!1}}return!1}function Sa(r){let e=Be(r);if(e==="kernel.wasm")return wr;let t=va(e);if(t&&t.endsWith(".wasm"))return ra}function za(r,e,t){if(!r.endsWith(".wasm"))return!1;try{let n=Ge(r),i=n.buffer.slice(n.byteOffset,n.byteOffset+n.byteLength),o=t===void 0?Ea(e):t==="disabled";return vr(i,{expectedAbi:41,requiredExports:Sa(e),requireForkInstrumentation:o?!1:void 0,forbidForkInstrumentation:o}).length>0}catch{return!0}}function ba(r){if(!r.endsWith(".vfs")&&!r.endsWith(".vfs.zst"))return!1;try{let t=an.readImageMetadata(Ge(r))?.kernelAbi;return t!==void 0&&t!==41}catch{return!0}}function gr(r,e,t){return za(r,e,t)||ba(r)}function Di(r,e,t){let n=r.filter(de);return n.length===0?null:n.find(i=>{try{return Ke(i).isFile()&&!gr(i,e,t)}catch{return!1}})??null}function Ui(r,e,t){try{if(!hn(r).isSymbolicLink())return r;let i=pe(r);if(!Ke(i).isFile()||gr(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Be(e).startsWith("programs/")&&ka(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 ka(r){let e=[Ci()];try{e.push(F(Ze(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return de(t)&&pr(pe(t),r)}catch{return!1}})}function pr(r,e){let t=ea(r,e);return t===""||t!==".."&&!t.startsWith(`..${ta}`)&&!yn(t)}function xa(r,e){let t=e.split("/"),n=r;for(let i=0;ia.packageName!==o))return"declared package members do not share a valid program namespace";if(!Ke(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(r.identity==="local-generation"){let a=F(r.root,".kandelo-local-generations",i,o,s);if(!de(a))return"local mirror targets are not one direct immutable local generation";let c=pe(a);return Ct(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(r.identity==="program-cache"){let a=Ci();if(!de(a))return"fetched mirror targets are not one canonical program-cache generation";let c=pe(a),l=Qo(e),u=l.startsWith(`${o}-`)&&new RegExp(`-rev[0-9]+-${i}-${s}$`).test(l);return Ct(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 Aa(r,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let n=e.map(l=>{let u=hn(l);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(l=>l==="symlink"),o=n.every(l=>l==="file");if(!i&&!o)return{failure:"regular files and symlinks cannot share one package identity"};if(o){if(!r.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let l=t[0].packageName,u=t[0].projectionIdentity;if(t.some(d=>d.packageName!==l||d.projectionIdentity!==u))return{failure:"declared members do not share one selected package projection"};let h=hr()?.packages.get(l);if(!h||$i(h)!==u)return{failure:"installed bytes do not match the selected package projection"};let y=pe(r.root),g=[];for(let d of e){let m=pe(d);if(!pr(y,m)||!Ke(m).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};g.push(m)}return{paths:g}}let s=null,a=[];for(let l=0;lLa(r))}function La(r){let e=Be(r),t=wa(e);if(t){let s=_a(t.members.map(a=>a.relPath),t.members);if(s)return s[t.members.findIndex(a=>a.relPath===e)];throw new un(`Package artifacts not found for ${t.packageName}: ${e}`)}let n=[],i=[];for(let s of Ni())for(let a of s.candidatesFor(r))n.push(a),i.push(a);let o=Di(i,r);if(o)return Ui(o,r);throw i.some(de)?new Error(`Binary exists but was rejected by artifact policy: ${r} +${n}`:""}`}function Za(r){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",n=e?["-vV"]:[$(r,"scripts","dev-shell.sh"),"rustc","-vV"],i=wr(t,n,{cwd:r,encoding:"utf8"});if(i.status!==0)throw new Error(Er(t,n,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 ${r}`);return o}function pr(r){try{if(En(r).isFile())return me(r)}catch{}throw new Error(`Prepared xtask is not a regular file: ${r}`)}function Ha(r){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let l=Sn(e)?pe(e):pe(r,e);return pr(l)}if(wn?.sourceRepoRoot===r)return pr(wn.xtaskPath);let t=Za(r),n=$(r,"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:[$(r,"scripts","dev-shell.sh"),"cargo",...i],c=wr(s,a,{cwd:r,encoding:"utf8"});if(c.status!==0)throw new Error(Er(s,a,c));return wn={sourceRepoRoot:r,xtaskPath:pr(n)},wn.xtaskPath}function Va(){let r=Wa();if(r===null)return;let e=os();if(e===null)return;if(Ji){Ji(r,e);return}let t=Ha(r),n=["build-deps","program-index-context-check"],i=wr(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: +${Er(t,n,i)}`)}function qa(r,e){if(gr>0||!r.some(t=>t.startsWith("programs/")))return e();gr+=1;try{return Va(),e()}finally{gr-=1}}function Ne(r,e){let t=Object.keys(r).sort(),n=[...e].sort();return t.length===n.length&&t.every((i,o)=>i===n[o])}function mr(r){let e;try{e=JSON.parse(Ze(r,"utf8"))}catch(s){throw new Error(`Invalid program package index ${r}: ${s instanceof Error?s.message:String(s)}`)}if(typeof e!="object"||e===null||!Ne(e,["format","identities","packages"])||e.format!==Xi||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 ${Xi}`);let t=new Map,n=e.identities;for(let[s,a]of Object.entries(n)){if(mn(s,r,"identity package name",!1),typeof a!="object"||a===null||!Ne(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(s)}`);let c=a.cacheKeys;if(!Ne(c,["wasm32","wasm64"])||Object.values(c).some(l=>typeof l!="string"||!/^[a-f0-9]{64}$/.test(l)))throw new Error(`Invalid program package index ${r}: 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(mn(s,r,"package name",!1),typeof a!="object"||a===null||!Ne(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(s)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(p=>typeof p!="string"||!qe.has(p)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid arches`);let l=a.cacheKeys;if(!Ne(l,c)||Object.values(l).some(p=>typeof p!="string"||!/^[a-f0-9]{64}$/.test(p)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid cache keys`);let h=a.dependencyClosures;if(!Ne(h,c))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid dependency closure arches`);let f={};for(let p of c){let w=h[p];if(!Array.isArray(w))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has a malformed dependency closure for ${p}`);let u=new Set;f[p]=w.map((m,v)=>{if(typeof m!="object"||m===null||!Ne(m,["packageName","manifestSha256","cacheKey"])||typeof m.packageName!="string"||typeof m.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(m.manifestSha256)||typeof m.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(m.cacheKey))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency ${v+1} for ${p} is malformed`);let E=m;if(mn(E.packageName,r,`${s} dependency packageName`,!1),E.packageName===s||u.has(E.packageName))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency closure for ${p} must contain unique dependencies other than itself`);u.add(E.packageName);let z=t.get(E.packageName);if(!z||z.manifestSha256!==E.manifestSha256||z.cacheKeys[p]!==E.cacheKey)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency ${JSON.stringify(E.packageName)} for ${p} does not match the index's authoritative contextual identity`);return E})}let d=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(s)} member ${w+1} is malformed`);let u=p,m=u.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Ne(u,m))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} member ${w+1} has unknown or missing fields`);if(Yi(u.sourceArtifact,r,`${s} sourceArtifact`),Yi(u.mirrorPath,r,`${s} mirrorPath`),u.kind==="output"){if(typeof u.outputName!="string"||u.forkInstrumentation!=="auto"&&u.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} output member lacks outputName or forkInstrumentation`);mn(u.outputName,r,`${s} outputName`)}else if(typeof u.guestPath!="string"||!u.guestPath.startsWith("/")||!Number.isInteger(u.mode)||u.mode<0||u.mode>511)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} runtime member lacks valid guestPath or mode`);return u});if(d.length===0||new Set(d.map(p=>p.sourceArtifact)).size!==d.length||new Set(d.map(p=>p.mirrorPath)).size!==d.length||d.length===1&&d[0].mirrorPath.includes("/")||d.length>1&&d.some(p=>!p.mirrorPath.startsWith(`${s}/`)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} members are empty, collide, or violate scalar/package-directory layout`);let y=a.manifestSha256,g=t.get(s);if(!g||g.manifestSha256!==y||c.some(p=>g.cacheKeys[p]!==l[p]))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} does not match its contextual package identity`);i.set(s,{manifestSha256:y,arches:c,cacheKeys:l,dependencyClosures:f,members:d})}return{identities:t,packages:i,indexPath:r}}function as(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 Sr(){let r=$(zn(),"wasm",ve);return de(r)?mr(r):null}function ja(r){let e=Sr();if(!e)return null;let t=r.split("/");if(t[0]!=="programs"||!qe.has(t[1]))return null;let n=t[1];if(t.length>=4){let o=t[2];return e.packages.get(o)?.arches.includes(n)?o:null}if(t.length!==3)return null;let i=t[2];for(let[o,s]of e.packages)if(s.arches.includes(n)&&s.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return o;return null}function Qi(r){let e=ja(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 cs(){let r=os(),e=new Map,t=new Map,n=new Map,i=new Map,o=[];if(r===null){let l=$(zn(),"wasm",ve);if(!de(l))return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o};let h=mr(l);for(let[f,d]of h.identities)e.set(f,{...d,packageName:f,policyPath:`${h.indexPath}#identities.${f}`});for(let[f,d]of h.packages)o.push({packageName:f,projection:d,selected:!0}),n.set(f,{...d,packageName:f,policyPath:`${h.indexPath}#${f}`});return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o}}let s=new Set,a=null,c=null;for(let l of r){if(!de(l))continue;if(!He(l).isDirectory())throw new Error(`Program registry root is not a directory: ${l}`);let h=$(l,ve);if(!de(h))throw new Error(`Program registry ${l} is missing ${ve}; generate it with xtask build-deps program-index`);let f=mr(h);a??=f.identities,c??=f.packages;let d=Ra(l,{withFileTypes:!0}).filter(y=>y.isDirectory()||y.isSymbolicLink()).sort((y,g)=>y.name.localeCompare(g.name));for(let y of d){let g=y.name,p=$(l,g,"package.toml");if(!de(p))continue;let w=!1;try{w=He(p).isFile()}catch{w=!1}if(!w)continue;let u=f.packages.get(g),m=!s.has(g);if(u&&o.push({packageName:g,projection:u,selected:m}),!m)continue;s.add(g);let v=a.get(g);v?e.set(g,{...v,packageName:g,manifestPath:p,policyPath:p}):t.set(g,p);let E=c.get(g);if(!E){i.set(g,p);continue}n.set(g,{...E,packageName:g,manifestPath:p,policyPath:p})}}return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o}}function es(r){if(!r.manifestPath)return;let e;try{e=Ze(r.manifestPath)}catch(n){throw new Error(`Program package identity cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(rs("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package identity is stale for ${r.manifestPath}; regenerate ${ve}`)}function Ya(r){if(!r.manifestPath)return;let e;try{e=Ze(r.manifestPath)}catch(n){throw new Error(`Program package projection cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(rs("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package projection is stale for ${r.manifestPath}; regenerate ${ve}`)}function Dt(r){let e=zr(),t=e.packages.get(r);if(t)return Ya(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 ${ve}; regenerate the registry projection`);return null}function Xa(r,e){let t=r.dependencyClosures[e];if(!t)throw yt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a dependency identity closure for ${e}`);let n=cs(),i=n.identities.get(r.packageName);if(!i){let s=n.unidentifiedPackages.get(r.packageName);throw new Error(`Program package ${JSON.stringify(r.packageName)} has no authoritative contextual identity for ${e}${s?` at ${s}`:""}; regenerate ${ve} with the exact ordered registry roots`)}es(i);let o=i.cacheKeys[e];if(i.manifestSha256!==r.manifestSha256||o!==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 ${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=n.identities.get(s.packageName);if(!a){let l=n.unidentifiedPackages.get(s.packageName);throw l?new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but the first-hit package at ${l} has no contextual identity in ${ve}`):new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}es(a);let c=a.cacheKeys[e];if(a.manifestSha256!==s.manifestSha256||c!==s.cacheKey)throw new Error(`Program package ${JSON.stringify(r.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 zr(){let r=cs(),{physicalProgramClaims:e,...t}=r,n={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let o of r.packages.values()){let s=o.members.length>1;for(let a of o.arches)for(let c of o.members){let l=i.find(y=>y.arch===a&&(y.path===c.mirrorPath||y.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${y.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(o.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:o.packageName}),c.kind!=="output")continue;let h=c.mirrorPath.split("/").at(-1),f=`${a}/${h}`,d=n.legacyFlatOutputs.get(f);d||(d={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(f,d)),s?d.packagePaths.set(`programs/${a}/${c.mirrorPath}`,o.packageName):d.scalarOwners.add(o.packageName),c.forkInstrumentation==="disabled"&&n.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,o.packageName)}}for(let{packageName:o,projection:s,selected:a}of e)if(!(a&&r.packages.has(o)))for(let c of s.arches)for(let l of s.members){if(l.kind!=="output")continue;let h=l.mirrorPath.split("/").at(-1),f=`${c}/${h}`,d=n.legacyFlatOutputs.get(f);d||(d={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(f,d)),d.shadowedOwners.add(o)}return n}function Ja(r){let e=r.split("/");if(e.length!==3||e[0]!=="programs"||!qe.has(e[1]))return null;let t=zr().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let n of t.scalarOwners){let i=Dt(n);if(i)return i}for(let n of t.packagePaths.values())Dt(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=Dt(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 ts(r,e,t){if(!r.arches.includes(e))throw yt(r.policyPath,`package ${JSON.stringify(r.packageName)} does not declare resolver artifacts for ${e}`);let n=r.cacheKeys[e];if(!n)throw yt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a cache identity for ${e}`);Xa(r,e);let i=as(r),o=r.members.map(s=>({packageName:r.packageName,relPath:`programs/${e}/${s.mirrorPath}`,sourceArtifact:s.sourceArtifact,cacheKey:n,forkInstrumentation:s.kind==="output"?s.forkInstrumentation??null:null,projectionIdentity:i}));if(!o.some(s=>s.relPath===t))throw yt(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:o}}function Qa(r){let e=Ce(r),t=e.split("/");if(t[0]==="programs"&&!Da()&&Sr()===null)throw new Error(`Installed host package is missing wasm/${ve}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let s=Ja(e);return s?ts(s,t[1],e):(Qi(e),null)}if(t.length<4||t[0]!=="programs"||!qe.has(t[1]))return null;let n=t[1],i=t[2],o=Dt(i);return o?ts(o,n,e):(Qi(e),null)}function ec(r){let e=Ce(r);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function tc(r){let e=Ce(r);for(let t of qe){let n=`programs/${t}/`;if(e.startsWith(n)){let i=zr().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(n.length)}`);return i?Dt(i)!==null:!1}}return!1}function nc(r){let e=Ce(r);if(e==="kernel.wasm")return Lr;let t=ec(e);if(t&&t.endsWith(".wasm"))return Fa}function rc(r,e,t){if(!r.endsWith(".wasm"))return!1;try{let n=Ze(r),i=n.buffer.slice(n.byteOffset,n.byteOffset+n.byteLength),o=t===void 0?tc(e):t==="disabled";return Nr(i,{expectedAbi:42,requiredExports:nc(e),requireForkInstrumentation:o?!1:void 0,forbidForkInstrumentation:o}).length>0}catch{return!0}}function ic(r){if(!r.endsWith(".vfs")&&!r.endsWith(".vfs.zst"))return!1;try{let t=yn.readImageMetadata(Ze(r))?.kernelAbi;return t!==void 0&&t!==42}catch{return!0}}function br(r,e,t){return rc(r,e,t)||ic(r)}function ls(r,e,t){let n=r.filter(de);return n.length===0?null:n.find(i=>{try{return He(i).isFile()&&!br(i,e,t)}catch{return!1}})??null}function fs(r,e,t){try{if(!En(r).isSymbolicLink())return r;let i=me(r);if(!He(i).isFile()||br(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ce(e).startsWith("programs/")&&sc(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 sc(r){let e=[is()];try{e.push($(Ve(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return de(t)&&kr(me(t),r)}catch{return!1}})}function kr(r,e){let t=Na(r,e);return t===""||t!==".."&&!t.startsWith(`..${Ca}`)&&!Sn(t)}function oc(r,e){let t=e.split("/"),n=r;for(let i=0;ia.packageName!==o))return"declared package members do not share a valid program namespace";if(!He(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(r.identity==="local-generation"){let a=$(r.root,".kandelo-local-generations",i,o,s);if(!de(a))return"local mirror targets are not one direct immutable local generation";let c=me(a);return Ut(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(r.identity==="program-cache"){let a=is();if(!de(a))return"fetched mirror targets are not one canonical program-cache generation";let c=me(a),l=Ba(e),h=l.startsWith(`${o}-`)&&new RegExp(`-rev[0-9]+-${i}-${s}$`).test(l);return Ut(e)===c&&h?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function cc(r,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let n=e.map(l=>{let h=En(l);return h.isSymbolicLink()?"symlink":h.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(l=>l==="symlink"),o=n.every(l=>l==="file");if(!i&&!o)return{failure:"regular files and symlinks cannot share one package identity"};if(o){if(!r.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let l=t[0].packageName,h=t[0].projectionIdentity;if(t.some(p=>p.packageName!==l||p.projectionIdentity!==h))return{failure:"declared members do not share one selected package projection"};let d=Sr()?.packages.get(l);if(!d||as(d)!==h)return{failure:"installed bytes do not match the selected package projection"};let y=me(r.root),g=[];for(let p of e){let w=me(p);if(!kr(y,w)||!He(w).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};g.push(w)}return{paths:g}}let s=null,a=[];for(let l=0;llc(r))}function lc(r){let e=Ce(r),t=Qa(e);if(t){let s=fc(t.members.map(a=>a.relPath),t.members);if(s)return s[t.members.findIndex(a=>a.relPath===e)];throw new vn(`Package artifacts not found for ${t.packageName}: ${e}`)}let n=[],i=[];for(let s of ss())for(let a of s.candidatesFor(r))n.push(a),i.push(a);let o=ls(i,r);if(o)return fs(o,r);throw i.some(de)?new Error(`Binary exists but was rejected by artifact policy: ${r} `+n.map(s=>` checked: ${s}`).join(` -`)):new un(`Binary not found: ${r} +`)):new vn(`Binary not found: ${r} `+n.map(s=>` checked: ${s}`).join(` `)+` - Run scripts/fetch-binaries.sh, place a file at local-binaries/${e}, or install a package that includes wasm/${r}.`)}function _a(r,e){if(r.length===0)return[];let t=!1,n=[];for(let i of Ni()){let o=[],s=[];if(e){let[a,c,l]=e[0].relPath.split("/");a==="programs"&&c&&l&&(t||=de(F(i.root,a,c,l)))}for(let[a,c]of r.entries()){let l=i.candidatesFor(c),u=l.filter(de);t||=u.length>0;let f=Di(l,c,e?.[a]?.forkInstrumentation);f?o.push(f):u.length>0?s.push(`${c} (rejected by artifact policy)`):s.push(`${c} (missing)`)}if(s.length===0&&e){let a=Aa(i,o,e);if("failure"in a)s.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((l,u)=>gr(l,r[u],e[u].forkInstrumentation)?[r[u]]:[]);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)=>Ui(a,r[c],e?.[c]?.forkInstrumentation));n.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/${r}.`)}function fc(r,e){if(r.length===0)return[];let t=!1,n=[];for(let i of ss()){let o=[],s=[];if(e){let[a,c,l]=e[0].relPath.split("/");a==="programs"&&c&&l&&(t||=de($(i.root,a,c,l)))}for(let[a,c]of r.entries()){let l=i.candidatesFor(c),h=l.filter(de);t||=h.length>0;let f=ls(l,c,e?.[a]?.forkInstrumentation);f?o.push(f):h.length>0?s.push(`${c} (rejected by artifact policy)`):s.push(`${c} (missing)`)}if(s.length===0&&e){let a=cc(i,o,e);if("failure"in a)s.push(`shared package identity rejected: ${a.failure}`);else{let c=a.paths.flatMap((l,h)=>br(l,r[h],e[h].forkInstrumentation)?[r[h]]:[]);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)=>fs(a,r[c],e?.[c]?.forkInstrumentation));n.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. `+n.join(` -`))}var[Ki,...Pa]=process.argv.slice(2);(!Ki||Pa.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${Gi(Ki)} +`))}var[ds,...uc]=process.argv.slice(2);(!ds||uc.length>0)&&(console.error("usage: scripts/resolve-binary.sh "),process.exit(2));try{process.stdout.write(`${us(ds)} `)}catch(r){console.error(r instanceof Error?r.message:String(r)),process.exit(1)} diff --git a/scripts/run-browser-mariadb-tests.sh b/scripts/run-browser-mariadb-tests.sh index 5a0f1224d1..c0beff9431 100755 --- a/scripts/run-browser-mariadb-tests.sh +++ b/scripts/run-browser-mariadb-tests.sh @@ -18,8 +18,8 @@ KERNEL_WASM="$("$REPO_ROOT/scripts/resolve-binary.sh" kernel.wasm)" VFS_IMAGE="$REPO_ROOT/apps/browser-demos/public/mariadb-test.vfs.zst" RUNNER="$REPO_ROOT/scripts/browser-mariadb-test-runner.ts" -# ── Curated tests (from full browser triage of all 1184 tests) ── -# 185 tests verified to pass in headless Chromium with MariaDB on kandelo. +# ── Curated tests (from full browser triage of all 1183 tests) ── +# 184 tests verified to pass in headless Chromium with MariaDB on kandelo. # Excludes: 230 connect-command tests (deadlock with no-threads), 339 timeouts, # 143 self-skipping, 287 other failures. CURATED_TESTS=( @@ -69,7 +69,7 @@ CURATED_TESTS=( set_statement_notembedded show_create_user show_function_with_pad_char_to_full_length show_row_order-9226 signal_demo1 signal_demo2 signal_demo3 - signal_sqlmode simple_select single_delete_update + signal_sqlmode single_delete_update skip_log_bin sp-bugs2 sp-condition-handler sp-destruct sp-memory-leak sp-no-code sp-no-valgrind sp-ucs2 sp-vars sp_gis sp_missing_4665 sql_mode_pad_char_to_full_length diff --git a/scripts/test-homebrew-bootstrap-source.sh b/scripts/test-homebrew-bootstrap-source.sh index 7b8ffac972..1c784b18ee 100755 --- a/scripts/test-homebrew-bootstrap-source.sh +++ b/scripts/test-homebrew-bootstrap-source.sh @@ -18,7 +18,7 @@ BOTTLE_SHA256="919fe4746f30a775963040995297c149972874fea50356530a8cb81b70845865" # namespace; fetching, pouring, and execution belong to integration tests. BOTTLE_ROOT_URL="https://ghcr.io/v2/kandelo-dev/homebrew-tap-core" -for tool in git node sha256sum unzip; do +for tool in git node unzip; do command -v "$tool" >/dev/null 2>&1 || { echo "test-homebrew-bootstrap-source: $tool not found; run through scripts/dev-shell.sh" >&2 exit 2 @@ -37,10 +37,17 @@ prepare() { local output_root="$2" local repository="${3:-$BREW_REPOSITORY}" local revision="${4:-$BREW_REVISION}" + local source_checkout="${5:-}" mkdir -p "$output_root" + local source_args=( + --repository "$repository" + --revision "$revision" + ) + if [ -n "$source_checkout" ]; then + source_args+=(--source-checkout "$source_checkout") + fi "$PREPARE" \ - --repository "$repository" \ - --revision "$revision" \ + "${source_args[@]}" \ --patch "$PATCH_FILE" \ --expected-patch-sha256 "$PATCH_SHA256" \ --arch "$arch" \ @@ -56,6 +63,91 @@ prepare wasm64 "$RUN_ROOT/wasm64" export TZ=EST5 prepare wasm32 "$RUN_ROOT/wasm32-est" ) + +# Source preparation must not inherit Git config injection, credential +# callbacks, template hooks, or fsmonitor commands from the caller. +HOSTILE_GIT_ROOT="$RUN_ROOT/hostile-git" +HOSTILE_MARKER="$HOSTILE_GIT_ROOT/invoked" +HOSTILE_COMMAND="$HOSTILE_GIT_ROOT/fail-if-invoked" +HOSTILE_EXEC_PATH="$HOSTILE_GIT_ROOT/exec-path" +HOSTILE_TEMPLATE="$HOSTILE_GIT_ROOT/template" +HOSTILE_GLOBAL_CONFIG="$HOSTILE_GIT_ROOT/global.config" +mkdir -p "$HOSTILE_EXEC_PATH" "$HOSTILE_TEMPLATE/hooks" +cat >"$HOSTILE_COMMAND" <"$HOSTILE_MARKER" +exit 97 +EOF +chmod 0700 "$HOSTILE_COMMAND" +cp "$HOSTILE_COMMAND" "$HOSTILE_EXEC_PATH/git-remote-https" +cp "$HOSTILE_COMMAND" "$HOSTILE_EXEC_PATH/git-upload-pack" +cp "$HOSTILE_COMMAND" "$HOSTILE_TEMPLATE/hooks/post-fetch" +cat >"$HOSTILE_GLOBAL_CONFIG" <&2 + exit 1 +fi + +# Reproducible package builds consume the resolver's sealed checkout instead +# of fetching Homebrew a second time. Preparing from that checkout must not +# mutate it or change any emitted bytes. +SOURCE_CHECKOUT="$RUN_ROOT/source-checkout" +git init -q "$SOURCE_CHECKOUT" +git -C "$SOURCE_CHECKOUT" fetch -q --depth=1 "$RUN_ROOT/wasm32/brew.git" "$BREW_REVISION" +git -C "$SOURCE_CHECKOUT" checkout -q --detach FETCH_HEAD +cp "$SOURCE_CHECKOUT/.git/config" "$RUN_ROOT/source-checkout.config.before" +SOURCE_STATUS_BEFORE="$(git -C "$SOURCE_CHECKOUT" status --porcelain=v1 --untracked-files=all)" +prepare wasm32 "$RUN_ROOT/wasm32-local" \ + "$BREW_REPOSITORY" "$BREW_REVISION" "$SOURCE_CHECKOUT" +SOURCE_STATUS_AFTER="$(git -C "$SOURCE_CHECKOUT" status --porcelain=v1 --untracked-files=all)" +if [ "$SOURCE_STATUS_BEFORE" != "$SOURCE_STATUS_AFTER" ]; then + echo "test-homebrew-bootstrap-source: local source checkout was mutated" >&2 + exit 1 +fi +if ! cmp -s "$SOURCE_CHECKOUT/.git/config" "$RUN_ROOT/source-checkout.config.before"; then + echo "test-homebrew-bootstrap-source: local source Git configuration was mutated" >&2 + exit 1 +fi + +# A sealed source checkout with executable local Git behavior is rejected +# before Git can run it. Resolver-owned checkouts carry only the allowlisted +# structural keys exercised by the successful preparation above. +git -C "$SOURCE_CHECKOUT" config core.fsmonitor "$HOSTILE_COMMAND" +set +e +prepare wasm32 "$RUN_ROOT/hostile-source-config-output" \ + "$BREW_REPOSITORY" "$BREW_REVISION" "$SOURCE_CHECKOUT" \ + >"$RUN_ROOT/hostile-source-config.log" 2>&1 +HOSTILE_SOURCE_CONFIG_STATUS=$? +set -e +git -C "$SOURCE_CHECKOUT" config --unset core.fsmonitor +if [ "$HOSTILE_SOURCE_CONFIG_STATUS" -eq 0 ]; then + echo "test-homebrew-bootstrap-source: executable source Git config unexpectedly accepted" >&2 + exit 1 +fi +grep -Fq 'source checkout has unsupported local Git configuration: core.fsmonitor' \ + "$RUN_ROOT/hostile-source-config.log" +if [ -e "$HOSTILE_MARKER" ] || [ -L "$HOSTILE_MARKER" ]; then + echo "test-homebrew-bootstrap-source: source Git callback executed before rejection" >&2 + exit 1 +fi + ( export TZ=HST10 prepare wasm32 "$RUN_ROOT/wasm32-hst" @@ -129,6 +221,29 @@ for timezone_root in "$RUN_ROOT/wasm32-est" "$RUN_ROOT/wasm32-hst"; do exit 1 fi done +if ! cmp -s "$ARCHIVE32" "$RUN_ROOT/wasm32-hostile-env/homebrew-brew.zip" || + ! cmp -s "$PROVENANCE32" "$RUN_ROOT/wasm32-hostile-env/homebrew-source.json"; then + echo "test-homebrew-bootstrap-source: ambient Git state changed source identity" >&2 + exit 1 +fi +if ! cmp -s "$ARCHIVE32" "$RUN_ROOT/wasm32-local/homebrew-brew.zip" || + ! cmp -s "$PROVENANCE32" "$RUN_ROOT/wasm32-local/homebrew-source.json"; then + echo "test-homebrew-bootstrap-source: local checkout changed source identity" >&2 + exit 1 +fi + +printf '\n# dirty source fixture\n' >>"$SOURCE_CHECKOUT/README.md" +set +e +prepare wasm32 "$RUN_ROOT/dirty-source-output" \ + "$BREW_REPOSITORY" "$BREW_REVISION" "$SOURCE_CHECKOUT" \ + >"$RUN_ROOT/dirty-source.log" 2>&1 +DIRTY_SOURCE_STATUS=$? +set -e +if [ "$DIRTY_SOURCE_STATUS" -eq 0 ]; then + echo "test-homebrew-bootstrap-source: dirty source checkout unexpectedly accepted" >&2 + exit 1 +fi +grep -Fq 'source checkout is dirty' "$RUN_ROOT/dirty-source.log" node --input-type=module - \ "$PROVENANCE32" "$PROVENANCE64" "$ARCHIVE32" "$PATCH_SHA256" "$BREW_REVISION" <<'NODE' diff --git a/scripts/test-homebrew-formula-runtime-closure.sh b/scripts/test-homebrew-formula-runtime-closure.sh index 6b3e05c6b0..4745ed3988 100755 --- a/scripts/test-homebrew-formula-runtime-closure.sh +++ b/scripts/test-homebrew-formula-runtime-closure.sh @@ -83,8 +83,8 @@ chmod 0444 "$PRIMARY_RESOLVED_TAPS" 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", "runtime_and_test", "schema", "tap", "target_taps"] and - .schema == 3 and + keys == ["build", "build_and_test", "formula", "full_name", "native_requirements", "runtime_and_test", "schema", "tap", "target_taps"] and + .schema == 4 and .tap == "kandelo-dev/tap-core" and .formula == "host-plan" and .full_name == "kandelo-dev/tap-core/host-plan" and @@ -95,6 +95,7 @@ jq -e ' }] and .build == ["python@3.14", "wabt"] and .build_and_test == ["check", "python@3.14", "wabt"] and + .native_requirements == [] and .runtime_and_test == ["check", "wabt"] ' <<<"$host_plan" >/dev/null [ "$host_plan" = "$(KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ @@ -230,7 +231,7 @@ jq -e ' cross_host="$(KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$RESOLVED_TAPS" \ ruby "$resolver" "$TAP_ROOT" acme/tools m4 --host-dependencies-json)" jq -e ' - .schema == 3 and + .schema == 4 and .target_taps == [ { tap_commit: "1111111111111111111111111111111111111111", @@ -243,7 +244,8 @@ jq -e ' tap_repository: "kandelo-dev/homebrew-tap-core" } ] and - .build == [] and .build_and_test == [] and .runtime_and_test == [] + .build == [] and .build_and_test == [] and + .native_requirements == [] and .runtime_and_test == [] ' \ <<<"$cross_host" >/dev/null @@ -407,6 +409,27 @@ else module KandeloFormulaSupport KANDELO_FORMULA_SUPPORT_API_VERSION = 1 + class BinaryenRequirement < Requirement + KANDELO_NATIVE_FORMULA = "binaryen" + KANDELO_NATIVE_SENTINEL = "wasm-opt" + fatal true + satisfy(build_env: false) { which("wasm-opt") } + end + + class PkgconfRequirement < Requirement + KANDELO_NATIVE_FORMULA = "pkgconf" + KANDELO_NATIVE_SENTINEL = "pkg-config" + fatal true + satisfy(build_env: false) { which("pkg-config") } + end + + class WabtRequirement < Requirement + KANDELO_NATIVE_FORMULA = "wabt" + KANDELO_NATIVE_SENTINEL = "wasm-validate" + fatal true + satisfy(build_env: false) { which("wasm-validate") } + end + def self.kandelo_load_tier2_runtime! support_path = Pathname(__FILE__).realpath support_path.freeze @@ -421,6 +444,158 @@ end end RUBY +cat >"$TAP_ROOT/Formula/native-requirements.rb" <<'RUBY' +require (Tap.fetch("kandelo-dev", "tap-core").path/"Kandelo/formula_support/kandelo_formula_support").to_s + +class NativeRequirements < Formula + depends_on KandeloFormulaSupport::BinaryenRequirement => :build + depends_on KandeloFormulaSupport::PkgconfRequirement => [:build, :test] + depends_on KandeloFormulaSupport::WabtRequirement => [:build, :test] +end +RUBY +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 + .build == ["binaryen", "pkgconf", "wabt"] and + .build_and_test == ["binaryen", "pkgconf", "wabt"] and + .native_requirements == [ + { + class: "KandeloFormulaSupport::BinaryenRequirement", + formula: "binaryen", + sentinel: "wasm-opt", + tags: ["build"] + }, + { + class: "KandeloFormulaSupport::PkgconfRequirement", + formula: "pkgconf", + sentinel: "pkg-config", + tags: ["build", "test"] + }, + { + class: "KandeloFormulaSupport::WabtRequirement", + formula: "wabt", + sentinel: "wasm-validate", + tags: ["build", "test"] + } + ] and + .runtime_and_test == ["pkgconf", "wabt"] +' <<<"$native_plan" >/dev/null + +cat >"$TAP_ROOT/Formula/unknown-requirement.rb" <<'RUBY' +require (Tap.fetch("kandelo-dev", "tap-core").path/"Kandelo/formula_support/kandelo_formula_support").to_s + +class UnknownRequirement < Formula + depends_on KandeloFormulaSupport::CurlRequirement => :build +end +RUBY +if KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ + ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core unknown-requirement \ + --host-dependencies-json >"$TMP_ROOT/unknown-requirement.out" \ + 2>"$TMP_ROOT/unknown-requirement.err"; then + echo "test-homebrew-formula-runtime-closure.sh: accepted an unknown native Requirement" >&2 + exit 1 +fi +grep -F 'depends_on uses unknown native Requirement CurlRequirement' \ + "$TMP_ROOT/unknown-requirement.err" >/dev/null + +cat >"$TAP_ROOT/Formula/dynamic-requirement.rb" <<'RUBY' +require (Tap.fetch("kandelo-dev", "tap-core").path/"Kandelo/formula_support/kandelo_formula_support").to_s + +class DynamicRequirement < Formula + depends_on KandeloFormulaSupport.const_get("BinaryenRequirement") => :build +end +RUBY +if KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ + ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core dynamic-requirement \ + --host-dependencies-json >"$TMP_ROOT/dynamic-requirement.out" \ + 2>"$TMP_ROOT/dynamic-requirement.err"; then + echo "test-homebrew-formula-runtime-closure.sh: accepted a dynamic native Requirement" >&2 + exit 1 +fi +grep -F 'Formula uses forbidden dependency metaprogramming "const_get"' \ + "$TMP_ROOT/dynamic-requirement.err" >/dev/null + +cat >"$TAP_ROOT/Formula/test-only-requirement.rb" <<'RUBY' +require (Tap.fetch("kandelo-dev", "tap-core").path/"Kandelo/formula_support/kandelo_formula_support").to_s + +class TestOnlyRequirement < Formula + depends_on KandeloFormulaSupport::PkgconfRequirement => :test +end +RUBY +if KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ + ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core test-only-requirement \ + --host-dependencies-json >"$TMP_ROOT/test-only-requirement.out" \ + 2>"$TMP_ROOT/test-only-requirement.err"; then + echo "test-homebrew-formula-runtime-closure.sh: accepted a test-only native Requirement" >&2 + exit 1 +fi +grep -F 'native Requirement must include :build and may also include :test' \ + "$TMP_ROOT/test-only-requirement.err" >/dev/null + +cat >"$TAP_ROOT/Formula/unloaded-requirement.rb" <<'RUBY' +class UnloadedRequirement < Formula + depends_on KandeloFormulaSupport::BinaryenRequirement => :build +end +RUBY +if KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ + ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core unloaded-requirement \ + --host-dependencies-json >"$TMP_ROOT/unloaded-requirement.out" \ + 2>"$TMP_ROOT/unloaded-requirement.err"; then + echo "test-homebrew-formula-runtime-closure.sh: accepted an unloaded native Requirement" >&2 + exit 1 +fi +grep -F 'native Requirement requires the canonical tap-local Formula support require' \ + "$TMP_ROOT/unloaded-requirement.err" >/dev/null + +cp "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" \ + "$TMP_ROOT/canonical-native-requirements.rb" +sed -i.bak 's/which("wasm-opt")/which("forged-wasm-opt")/' \ + "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" +rm "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb.bak" +if KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ + ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core native-requirements \ + --host-dependencies-json >"$TMP_ROOT/forged-requirement.out" \ + 2>"$TMP_ROOT/forged-requirement.err"; then + echo "test-homebrew-formula-runtime-closure.sh: accepted a forged native Requirement" >&2 + exit 1 +fi +grep -F 'Kandelo Formula support contains an unsupported native Requirement class' \ + "$TMP_ROOT/forged-requirement.err" >/dev/null +cp "$TMP_ROOT/canonical-native-requirements.rb" \ + "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" + +sed -i.bak 's/KANDELO_NATIVE_SENTINEL = "wasm-opt"/KANDELO_NATIVE_SENTINEL = "forged-wasm-opt"/' \ + "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" +rm "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb.bak" +if KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ + ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core native-requirements \ + --host-dependencies-json >"$TMP_ROOT/altered-sentinel.out" \ + 2>"$TMP_ROOT/altered-sentinel.err"; then + echo "test-homebrew-formula-runtime-closure.sh: accepted altered native Requirement sentinel metadata" >&2 + exit 1 +fi +grep -F 'Kandelo Formula support contains an unsupported native Requirement class' \ + "$TMP_ROOT/altered-sentinel.err" >/dev/null +cp "$TMP_ROOT/canonical-native-requirements.rb" \ + "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" + +sed -i.bak 's/KANDELO_NATIVE_FORMULA = "binaryen"/KANDELO_NATIVE_FORMULA = "wabt"/' \ + "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" +rm "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb.bak" +if KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$PRIMARY_RESOLVED_TAPS" \ + ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core native-requirements \ + --host-dependencies-json >"$TMP_ROOT/altered-formula.out" \ + 2>"$TMP_ROOT/altered-formula.err"; then + echo "test-homebrew-formula-runtime-closure.sh: accepted altered native Requirement Formula metadata" >&2 + exit 1 +fi +grep -F 'Kandelo Formula support contains an unsupported native Requirement class' \ + "$TMP_ROOT/altered-formula.err" >/dev/null +mv "$TMP_ROOT/canonical-native-requirements.rb" \ + "$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" + write_valid_bridge_formula() { cat >"$TAP_ROOT/Formula/bridge.rb" <<'RUBY' require (Tap.fetch("kandelo-dev", "tap-core").path/"Kandelo/formula_support/kandelo_formula_support").to_s @@ -540,7 +715,7 @@ jq -e ' } ' <<<"$bridge_plan" >/dev/null [ "$(jq -r '.support_runtime_sha256' <<<"$bridge_plan")" = \ - "f4268a4e34b7fc2fc3ec46466e656eb6b917bd451d77cbfffdafe2a08e8924a4" ] + "4c0156a88618f0f30f388884ffc08a67c6ea16b0fe64c7e325adfc9b14f40994" ] [ "$bridge_plan" = "$(ruby "$resolver" "$TAP_ROOT" kandelo-dev/tap-core bridge --tier2-bridge-json)" ] rm "$TAP_ROOT/Kandelo/formula_support/a-runtime.txt" \ "$TAP_ROOT/Kandelo/formula_support/z-runtime.txt" diff --git a/scripts/test-homebrew-inspect-bottle.sh b/scripts/test-homebrew-inspect-bottle.sh index abcfd03243..0bbfdd91d9 100755 --- a/scripts/test-homebrew-inspect-bottle.sh +++ b/scripts/test-homebrew-inspect-bottle.sh @@ -17,17 +17,9 @@ class Tool < Formula desc "Archive inspector fixture" end RUBY -cat >"$TMP_ROOT/tool.wat" <"$TMP_ROOT/make-archive.py" <<'PY' import io @@ -266,9 +258,9 @@ make_wasm fork-import-missing <>"$TMP_ROOT/relocatable-fork-import.wasm" diff --git a/scripts/test-homebrew-main-shell-closure.sh b/scripts/test-homebrew-main-shell-closure.sh index e4735b5c19..5ab279da61 100755 --- a/scripts/test-homebrew-main-shell-closure.sh +++ b/scripts/test-homebrew-main-shell-closure.sh @@ -116,6 +116,31 @@ checker_line="$(grep -n 'node scripts/check-homebrew-main-shell-brewfile.mjs' "$ [ "$setup_node_line" -lt "$checker_line" ] || fail "pinned Node setup must precede the main-shell contract checker" +generation_block="$(sed -n \ + '/- name: Select one verified package generation/,/- name: Resolve current direct browser bundling inputs/p' \ + "$WORKFLOW")" +grep -Fq 'GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}' <<<"$generation_block" || + fail "package-generation validation needs only the workflow's read token" +grep -Fq 'staging-reuse expected \' <<<"$generation_block" && + grep -Fq 'validate-staging-release.sh \' <<<"$generation_block" && + grep -Fq -- '--mode current \' <<<"$generation_block" || + fail "main-shell CI must accept only a complete current PR package generation" +grep -Fq 'index-candidate seed \' <<<"$generation_block" && + grep -Fq 'selected_url="file://${frozen_index}"' <<<"$generation_block" || + fail "main-shell CI must freeze the validated mutable staging index locally" +grep -Fq 'env -u GH_TOKEN -u GITHUB_TOKEN \' <<<"$generation_block" && + grep -Fq -- '-u HOMEBREW_GITHUB_PACKAGES_TOKEN \' <<<"$generation_block" || + fail "local index freezing must run without GitHub credentials" +grep -Fq 'selected_url="$canonical_url"' <<<"$generation_block" || + fail "main-shell CI must retain the canonical/source-build fallback" +grep -Fq 'echo "WASM_POSIX_BINARY_INDEX_URL=$selected_url" >> "$GITHUB_ENV"' \ + <<<"$generation_block" || + fail "main-shell CI must pass the selected generation through the resolver contract" +grep -Fq 'GH_TOKEN:' <<<"$(sed -n \ + '/- name: Resolve current direct browser bundling inputs/,/- name: Build the exact lazy shell from public bottles/p' \ + "$WORKFLOW")" && + fail "browser package resolution must not retain the staging-validation token" + grep -Fq '(.selection.requested_packages | length) == $expected_root_count' "$BUILDER" || fail "$BUILDER does not bind the requested-root count to the migration lock" grep -Fq '(.packages | length) == $expected_closure_count' "$BUILDER" || @@ -179,7 +204,7 @@ grep -Fq 'test ! -e "$source_root/.git"' "$WORKFLOW" || grep -Fq 'test -z "$(git -C "$GITHUB_WORKSPACE/libc/musl" status --porcelain=v1 --untracked-files=all)"' "$WORKFLOW" || fail "main-shell proof must verify that sysroot preparation leaves package cache inputs clean" grep -Fq 'GH_TOKEN: ${{ github.token }}' "$WORKFLOW" && - fail "main-shell proof must not expose the workflow token to package composition" + fail "main-shell proof must not expose the implicit workflow token to package composition" grep -Fq 'scripts/homebrew-checkout-public-tap.sh' "$WORKFLOW" && fail "candidate proof must use its one explicit exact tap checkout" grep -Fq 'bash packages/registry/shell/build-shell.sh' "$WORKFLOW" && diff --git a/scripts/test-homebrew-patched-launcher.sh b/scripts/test-homebrew-patched-launcher.sh index b9f7cebc8e..97c4a17899 100755 --- a/scripts/test-homebrew-patched-launcher.sh +++ b/scripts/test-homebrew-patched-launcher.sh @@ -1222,7 +1222,7 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ 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","runtime_and_test":["ninja"],"schema":3,"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":4,"tap":"kandelo-dev/tap-core","target_taps":[{"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="$active_tier2_attestation_json" diff --git a/scripts/test-homebrew-publish-workflow.sh b/scripts/test-homebrew-publish-workflow.sh index de4e6dbf1c..1731cb23f2 100755 --- a/scripts/test-homebrew-publish-workflow.sh +++ b/scripts/test-homebrew-publish-workflow.sh @@ -250,6 +250,7 @@ make_formula_runner_fixture() { "$REPO_ROOT/scripts/homebrew-verify-poured-bottle.sh" \ "$REPO_ROOT/scripts/homebrew-formula-support-inputs.sh" \ "$REPO_ROOT/scripts/homebrew-formula-runtime-closure.rb" \ + "$REPO_ROOT/scripts/homebrew-validate-host-dependency-plan.sh" \ "$REPO_ROOT/scripts/homebrew-tap-identity.sh" \ "$FORMULA_RUNNER_FIXTURE_ROOT/scripts/" : >"$FORMULA_RUNNER_FIXTURE_ROOT/homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch" @@ -6967,6 +6968,7 @@ assert_matrix_skips_unchanged_cache_key assert_resolved_primary_override_is_bounded bash "$REPO_ROOT/scripts/test-homebrew-tap-identity.sh" bash "$REPO_ROOT/scripts/test-homebrew-publisher-overlay-patch.sh" +bash "$REPO_ROOT/scripts/test-homebrew-publisher-real-lifecycle.sh" bash "$REPO_ROOT/scripts/test-homebrew-oci-layout.sh" assert_index_artifact_download_topologies assert_publish_handoff_download_topologies @@ -7002,6 +7004,7 @@ bash "$REPO_ROOT/scripts/test-homebrew-sibling-bottle-policy.sh" bash "$REPO_ROOT/scripts/test-homebrew-patched-launcher.sh" bash "$REPO_ROOT/scripts/test-homebrew-inspect-bottle.sh" bash "$REPO_ROOT/scripts/test-homebrew-formula-runtime-closure.sh" +bash "$REPO_ROOT/scripts/test-homebrew-validate-host-dependency-plan.sh" bash "$REPO_ROOT/scripts/test-homebrew-bottle-runtime-evidence.sh" bash "$REPO_ROOT/scripts/test-publish-immutable-github-release.sh" bash "$REPO_ROOT/scripts/test-homebrew-vfs-release.sh" diff --git a/scripts/test-homebrew-publisher-overlay-patch.sh b/scripts/test-homebrew-publisher-overlay-patch.sh index 842ad9a135..e2ece00476 100755 --- a/scripts/test-homebrew-publisher-overlay-patch.sh +++ b/scripts/test-homebrew-publisher-overlay-patch.sh @@ -179,6 +179,15 @@ class Requirements end class Dependency + attr_reader :name, :tags + + def initialize(name, tags = []) + @name = name + @tags = Array(tags) + end + + def build? = tags.include?(:build) + def implicit? = tags.include?(:implicit) end module T @@ -214,6 +223,62 @@ class Build end RUBY +cat >"$TMPDIR/Library/Homebrew/test.rb" <<'RUBY' +# typed: strict +# frozen_string_literal: true + +raise "#{__FILE__} must not be loaded via `require`." if $PROGRAM_NAME != __FILE__ + +old_trap = trap("INT") { exit! 130 } + +require_relative "global" +require "extend/ENV" +require "timeout" +require "formula_assertions" +require "formula_free_port" +require "fcntl" +require "utils/socket" +require "cli/parser" +require "dev-cmd/test" +require "json/add/exception" +require "extend/pathname/write_mkpath_extension" + +DEFAULT_TEST_TIMEOUT_SECONDS = T.let(5 * 60, Integer) + +begin + # Undocumented opt-out for internal use. + # We need to allow formulae from paths here due to how we pass them through. + ENV["HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS"] = "1" + + args = Homebrew::DevCmd::Test.new.args + Context.current = args.context + + error_pipe = Utils::UNIXSocketExt.open(ENV.fetch("HOMEBREW_ERROR_PIPE"), &:recv_io) + error_pipe.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC) + + trap("INT", old_trap) + + if Homebrew::EnvConfig.developer? || ENV["CI"].present? + raise "Cannot find child processes without `pgrep`, please install!" unless which("pgrep") + raise "Cannot kill child processes without `pkill`, please install!" unless which("pkill") + end + + formula = args.named.to_resolved_formulae.fetch(0) + formula.extend(Homebrew::Assertions) + formula.extend(Homebrew::FreePort) + if args.debug? && !Homebrew::EnvConfig.disable_debrew? + require "debrew" + formula.extend(Debrew::Formula) + end + + ENV.extend(Stdenv) + ENV.setup_build_environment(formula:, testing_formula: true) + Pathname.activate_extensions! + + run_test = proc do |_| +end +RUBY + cat >"$TMPDIR/Library/Homebrew/extend/os/linux/formula.rb" <<'RUBY' # typed: strict # frozen_string_literal: true @@ -534,7 +599,7 @@ end plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 3, + "schema" => 4, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", @@ -545,6 +610,7 @@ plan = { }], "build" => [], "build_and_test" => [], + "native_requirements" => [], "runtime_and_test" => [], } plan_path.write(JSON.generate(plan)) @@ -690,6 +756,7 @@ RUBY ruby -I"$TMPDIR/Library/Homebrew" - "$TMPDIR" <<'RUBY' require "json" +require "open3" require "pathname" HOMEBREW_PREFIX = Pathname(ARGV.fetch(0))/"prefix" @@ -709,12 +776,60 @@ class FixtureDependency def implicit? = @implicit end +module KandeloFormulaSupport + class BinaryenRequirement + KANDELO_NATIVE_FORMULA = "binaryen" + KANDELO_NATIVE_SENTINEL = "wasm-opt" + + attr_reader :tags + + def initialize(tags) = @tags = tags + end + + class PkgconfRequirement + KANDELO_NATIVE_FORMULA = "pkgconf" + KANDELO_NATIVE_SENTINEL = "pkg-config" + + attr_reader :tags + + def initialize(tags) = @tags = tags + end + + class ForgedBinaryenRequirement + KANDELO_NATIVE_FORMULA = "binaryen" + KANDELO_NATIVE_SENTINEL = "wasm-opt" + + attr_reader :tags + + def initialize(tags) = @tags = tags + end + + class AlteredSentinelRequirement + KANDELO_NATIVE_FORMULA = "binaryen" + KANDELO_NATIVE_SENTINEL = "forged-wasm-opt" + + attr_reader :tags + + def initialize(tags) = @tags = tags + end + + class AlteredFormulaRequirement + KANDELO_NATIVE_FORMULA = "wabt" + KANDELO_NATIVE_SENTINEL = "wasm-opt" + + attr_reader :tags + + def initialize(tags) = @tags = tags + end +end + class FixtureFormula attr_accessor :build - attr_reader :deps, :full_name, :name, :options + attr_reader :deps, :full_name, :name, :options, :requirements - def initialize(deps) + def initialize(deps, requirements: []) @deps = deps + @requirements = requirements @name = "hello" @full_name = "kandelo-dev/tap-core/hello" @options = [] @@ -732,7 +847,7 @@ end plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 3, + "schema" => 4, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", @@ -750,6 +865,7 @@ plan = { ], "build" => ["binaryen", "wabt"], "build_and_test" => ["binaryen", "pkgconf", "wabt"], + "native_requirements" => [], "runtime_and_test" => ["pkgconf"], } plan_path.write(JSON.generate(plan)) @@ -769,6 +885,246 @@ unless build.deps.map(&:name) == ["binaryen", "wabt"] raise "publisher build did not activate exactly the authorized direct native build dependencies" end +# Requirement-backed tools are reconstructed as build-only Dependency objects +# so Homebrew's normal Build/Superenv path receives the same inputs as literal +# string dependencies, while the evaluated class, metadata, and tags must all +# match the protected static plan. +native_plan = plan.merge( + "build" => ["binaryen", "pkgconf", "wabt"], + "build_and_test" => ["binaryen", "pkgconf", "wabt"], + "native_requirements" => [ + { + "class" => "KandeloFormulaSupport::BinaryenRequirement", + "formula" => "binaryen", + "sentinel" => "wasm-opt", + "tags" => ["build"], + }, + { + "class" => "KandeloFormulaSupport::PkgconfRequirement", + "formula" => "pkgconf", + "sentinel" => "pkg-config", + "tags" => ["build", "test"], + }, + ], + "runtime_and_test" => ["pkgconf"], +) +plan_path.chmod(0o644) +plan_path.write(JSON.generate(native_plan)) +plan_path.chmod(0o444) +native_formula = FixtureFormula.new( + [FixtureDependency.new("wabt", build: true)], + requirements: [ + KandeloFormulaSupport::PkgconfRequirement.new([:build, :test]), + KandeloFormulaSupport::BinaryenRequirement.new([:build]), + ], +) +native_build = Build.new(native_formula, [], args: FixtureArgs.new) +unless native_build.deps.map(&:name) == ["binaryen", "pkgconf", "wabt"] + raise "publisher build did not reconstruct native Requirement Formula inputs" +end +reconstructed = native_build.deps.reject { |dependency| dependency.is_a?(FixtureDependency) } +unless reconstructed.map(&:tags) == [[:build], [:build]] + raise "publisher native Requirement inputs did not populate the build-only Superenv dependency path" +end + +class FixtureTestEnvironment + attr_reader :paths, :values + + def initialize + @paths = Hash.new { |hash, key| hash[key] = [] } + @values = Hash.new { |hash, key| hash[key] = [] } + end + + def prepend_path(key, path) = paths[key].unshift(path) + def prepend(key, value) = values[key].unshift(value) +end + +pkgconf_opt = HOMEBREW_PREFIX/"opt/pkgconf" +[pkgconf_opt/"bin", pkgconf_opt/"lib/pkgconfig", pkgconf_opt/"share/aclocal", + pkgconf_opt/"include"].each(&:mkpath) +pkgconf_sentinel = pkgconf_opt/"bin/pkg-config" +pkgconf_sentinel.write("#!/bin/sh\nprintf 'sealed-native-test-sentinel\\n'\n") +pkgconf_sentinel.chmod(0o555) +test_env = FixtureTestEnvironment.new +KandeloPublisher.activate_native_test_requirements!(native_formula, test_env) +unless test_env.paths.fetch("PATH") == [(pkgconf_opt/"bin").to_s] && + test_env.paths.fetch("PKG_CONFIG_PATH") == [(pkgconf_opt/"lib/pkgconfig").to_s] && + test_env.paths.fetch("ACLOCAL_PATH") == [(pkgconf_opt/"share/aclocal").to_s] && + test_env.paths.fetch("CMAKE_PREFIX_PATH") == [pkgconf_opt.to_s] && + test_env.values.fetch("LDFLAGS") == ["-L#{pkgconf_opt}/lib"] && + test_env.values.fetch("CPPFLAGS") == ["-I#{pkgconf_opt}/include"] + raise "publisher test environment did not expose exactly the sealed test Requirement paths" +end +sentinel_output, sentinel_error, sentinel_status = Open3.capture3( + { "PATH" => test_env.paths.fetch("PATH").join(":") }, + "/usr/bin/env", + "pkg-config", +) +unless sentinel_status.success? && sentinel_output == "sealed-native-test-sentinel\n" && sentinel_error.empty? + raise "publisher test environment did not execute the sealed Requirement sentinel by name" +end +pkgconf_sentinel.delete +begin + KandeloPublisher.activate_native_test_requirements!(native_formula, FixtureTestEnvironment.new) + raise "publisher test environment accepted a missing native Requirement sentinel" +rescue RuntimeError => e + raise unless e.message.include?("sentinel is unavailable") +end + +begin + Build.new( + FixtureFormula.new( + [FixtureDependency.new("wabt", build: true)], + requirements: [KandeloFormulaSupport::BinaryenRequirement.new([:build])], + ), + [], + args: FixtureArgs.new, + ) + raise "publisher accepted a missing evaluated native Requirement" +rescue RuntimeError => e + raise unless e.message.include?("differ from the sealed dependency plan") +end + +begin + Build.new( + FixtureFormula.new( + [FixtureDependency.new("wabt", build: true)], + requirements: [ + KandeloFormulaSupport::BinaryenRequirement.new([:build]), + KandeloFormulaSupport::PkgconfRequirement.new([:build]), + ], + ), + [], + args: FixtureArgs.new, + ) + raise "publisher accepted evaluated native Requirement tags that differ from the sealed plan" +rescue RuntimeError => e + raise unless e.message.include?("differ from the sealed dependency plan") +end + +begin + Build.new( + FixtureFormula.new( + [FixtureDependency.new("wabt", build: true)], + requirements: [ + KandeloFormulaSupport::ForgedBinaryenRequirement.new([:build]), + KandeloFormulaSupport::PkgconfRequirement.new([:build, :test]), + ], + ), + [], + args: FixtureArgs.new, + ) + raise "publisher accepted a forged evaluated native Requirement class" +rescue RuntimeError => e + raise unless e.message.include?("differ from the sealed dependency plan") +end + +altered_sentinel_plan = native_plan.merge( + "native_requirements" => [ + { + "class" => "KandeloFormulaSupport::AlteredSentinelRequirement", + "formula" => "binaryen", + "sentinel" => "wasm-opt", + "tags" => ["build"], + }, + ], + "build" => ["binaryen", "wabt"], + "build_and_test" => ["binaryen", "wabt"], + "runtime_and_test" => [], +) +plan_path.chmod(0o644) +plan_path.write(JSON.generate(altered_sentinel_plan)) +plan_path.chmod(0o444) +begin + Build.new( + FixtureFormula.new( + [FixtureDependency.new("wabt", build: true)], + requirements: [KandeloFormulaSupport::AlteredSentinelRequirement.new([:build])], + ), + [], + args: FixtureArgs.new, + ) + raise "publisher accepted altered evaluated native Requirement metadata" +rescue RuntimeError => e + raise unless e.message.include?("differ from the sealed dependency plan") +end + + +altered_formula_plan = altered_sentinel_plan.merge( + "native_requirements" => [ + { + "class" => "KandeloFormulaSupport::AlteredFormulaRequirement", + "formula" => "binaryen", + "sentinel" => "wasm-opt", + "tags" => ["build"], + }, + ], +) +plan_path.chmod(0o644) +plan_path.write(JSON.generate(altered_formula_plan)) +plan_path.chmod(0o444) +begin + Build.new( + FixtureFormula.new( + [FixtureDependency.new("wabt", build: true)], + requirements: [KandeloFormulaSupport::AlteredFormulaRequirement.new([:build])], + ), + [], + args: FixtureArgs.new, + ) + raise "publisher accepted altered evaluated native Requirement Formula metadata" +rescue RuntimeError => e + raise unless e.message.include?("differ from the sealed dependency plan") +end + +invalid_tags_plan = native_plan.merge( + "native_requirements" => [native_plan.fetch("native_requirements").first.merge("tags" => ["test"])], + "build" => ["binaryen", "wabt"], + "build_and_test" => ["binaryen", "wabt"], + "runtime_and_test" => ["binaryen"], +) +plan_path.chmod(0o644) +plan_path.write(JSON.generate(invalid_tags_plan)) +plan_path.chmod(0o444) +begin + KandeloPublisher.dependency_plan(native_formula) + raise "publisher accepted a native Requirement without a build tag" +rescue RuntimeError => e + raise unless e.message.include?("invalid native Requirements") +end + +legacy_plan = plan.merge("schema" => 3) +plan_path.chmod(0o644) +plan_path.write(JSON.generate(legacy_plan)) +plan_path.chmod(0o444) +begin + KandeloPublisher.dependency_plan(native_formula) + raise "publisher accepted ambiguous schema-3 native dependency data" +rescue RuntimeError => e + raise unless e.message.include?("invalid identity") +end + +oversized_dependencies = (0...129).map { |index| format("tool%03d", index) } +oversized_plan = plan.merge( + "build" => oversized_dependencies, + "build_and_test" => oversized_dependencies, + "native_requirements" => [], + "runtime_and_test" => oversized_dependencies, +) +plan_path.chmod(0o644) +plan_path.write(JSON.generate(oversized_plan)) +plan_path.chmod(0o444) +begin + KandeloPublisher.dependency_plan(native_formula) + raise "publisher accepted oversized host dependency arrays" +rescue RuntimeError => e + raise unless e.message.include?("invalid build names") +end + +plan_path.chmod(0o644) +plan_path.write(JSON.generate(plan)) +plan_path.chmod(0o444) + pour = Build.new(FixtureFormula.new(deps), [], args: FixtureArgs.new(build_bottle: false)) raise "staged publisher plan changed an ignored-dependency bottle pour" unless pour.deps.empty? @@ -776,6 +1132,11 @@ plan_path.delete raise "missing publisher plan remained active" if KandeloPublisher.active? empty = Build.new(FixtureFormula.new(deps), [], args: FixtureArgs.new) raise "ordinary ignored-dependency build changed" unless empty.deps.empty? +inactive_test_env = FixtureTestEnvironment.new +KandeloPublisher.activate_native_test_requirements!(native_formula, inactive_test_env) +unless inactive_test_env.paths.empty? && inactive_test_env.values.empty? + raise "ordinary Homebrew test environment changed without a protected publisher plan" +end plan["build"] = ["binaryen", "missing", "wabt"] plan["build_and_test"] = ["binaryen", "missing", "pkgconf", "wabt"] @@ -863,7 +1224,7 @@ end plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 3, + "schema" => 4, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", @@ -881,6 +1242,7 @@ plan = { ], "build" => ["wabt"], "build_and_test" => ["wabt"], + "native_requirements" => [], "runtime_and_test" => [], } plan_path.write(JSON.generate(plan)) @@ -989,7 +1351,7 @@ HOMEBREW_PREFIX = Pathname(ARGV.fetch(0))/"sandbox-prefix" HOMEBREW_PREFIX.mkpath plan_path = HOMEBREW_PREFIX/".kandelo-publisher-build-dependencies.json" plan = { - "schema" => 3, + "schema" => 4, "tap" => "kandelo-dev/tap-core", "formula" => "hello", "full_name" => "kandelo-dev/tap-core/hello", @@ -1000,6 +1362,7 @@ plan = { }], "build" => [], "build_and_test" => [], + "native_requirements" => [], "runtime_and_test" => [], } plan_path.write(JSON.generate(plan)) diff --git a/scripts/test-homebrew-publisher-real-lifecycle.sh b/scripts/test-homebrew-publisher-real-lifecycle.sh new file mode 100755 index 0000000000..d2ec42659b --- /dev/null +++ b/scripts/test-homebrew-publisher-real-lifecycle.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +BREW_COMMIT="34c40c18ffa2029b611b61c73273e32c003d0842" +EXPECTED_BUILD_BLOB="be833176c02f78cd5b3502aac968b5a733cb7af8" +EXPECTED_MAC_SANDBOX_BLOB="b81da0fd8878e6a6de1171e0cb7a08a86b4be561" +BREW_SOURCE="" +TMP_ROOT="" +BREW_ROOT="" +BUNDLE_ROOT="" +BUNDLE_RUBY_ROOT="" +NETWORK_PROFILE="" +NETWORK_PROBE_PID="" +OFFLINE_RUNNER=() + +fail() { + echo "test-homebrew-publisher-real-lifecycle.sh: $*" >&2 + exit 1 +} + +consider_brew_source() { + local candidate="$1" + [ -n "$candidate" ] || return 0 + [ -d "$candidate" ] || return 0 + [ "$(git -C "$candidate" rev-parse --is-inside-work-tree 2>/dev/null || true)" = true ] || return 0 + [ "$(git -C "$candidate" cat-file -t "$BREW_COMMIT" 2>/dev/null || true)" = commit ] || return 0 + [ "$(git -C "$candidate" rev-parse "$BREW_COMMIT:Library/Homebrew/build.rb")" = \ + "$EXPECTED_BUILD_BLOB" ] || return 0 + [ "$(git -C "$candidate" rev-parse \ + "$BREW_COMMIT:Library/Homebrew/extend/os/mac/sandbox.rb")" = \ + "$EXPECTED_MAC_SANDBOX_BLOB" ] || return 0 + BREW_SOURCE="$(cd "$candidate" && pwd -P)" +} + +cleanup() { + local status=$? + if [ -n "$NETWORK_PROBE_PID" ]; then + kill "$NETWORK_PROBE_PID" >/dev/null 2>&1 || true + wait "$NETWORK_PROBE_PID" >/dev/null 2>&1 || true + fi + if [ -n "$TMP_ROOT" ] && [ -d "$TMP_ROOT" ]; then + chmod -R u+w "$TMP_ROOT" >/dev/null 2>&1 || true + fi + if [ -n "$BREW_ROOT" ] && [ -n "$BREW_SOURCE" ] && [ -e "$BREW_ROOT" ]; then + git -C "$BREW_SOURCE" worktree remove --force "$BREW_ROOT" >/dev/null 2>&1 || true + fi + if [ -n "$TMP_ROOT" ] && [ -d "$TMP_ROOT" ]; then + find "$TMP_ROOT" -depth -mindepth 1 -delete >/dev/null 2>&1 || true + rmdir "$TMP_ROOT" >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup EXIT + +consider_brew_source "${KANDELO_HOMEBREW_SOURCE_REPOSITORY:-}" +for candidate in /opt/homebrew /home/linuxbrew/.linuxbrew/Homebrew /usr/local/Homebrew; do + [ -z "$BREW_SOURCE" ] || break + consider_brew_source "$candidate" +done +[ -n "$BREW_SOURCE" ] || fail \ + "the pinned Homebrew commit is unavailable; set KANDELO_HOMEBREW_SOURCE_REPOSITORY" + +TMP_ROOT="$(mktemp -d)" +TMP_ROOT="$(cd "$TMP_ROOT" && pwd -P)" +BREW_ROOT="$TMP_ROOT/brew" +git -C "$BREW_SOURCE" worktree add --detach "$BREW_ROOT" "$BREW_COMMIT" >/dev/null +git -C "$BREW_ROOT" apply "$REPO_ROOT/homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch" +git -C "$BREW_ROOT" apply "$REPO_ROOT/homebrew/patches/0002-support-isolated-publisher.patch" + +# The whole lifecycle is wrapped in a stronger outer macOS network sandbox. +# Teach this disposable test worktree to skip Homebrew's inner sandbox only +# when it can prove that outer sandbox is already active. macOS rejects nested +# sandbox-exec calls before Formula code runs; Linux can nest its namespaces +# and does not use this OS-specific seam. +git -C "$BREW_ROOT" apply - <<'PATCH' +diff --git a/Library/Homebrew/extend/os/mac/sandbox.rb b/Library/Homebrew/extend/os/mac/sandbox.rb +--- a/Library/Homebrew/extend/os/mac/sandbox.rb ++++ b/Library/Homebrew/extend/os/mac/sandbox.rb +@@ -65,4 +65,8 @@ module OS + sig { returns(T::Boolean) } + def available? ++ if ENV["HOMEBREW_KANDELO_HERMETIC_LIFECYCLE_TEST"] == "1" && nested_sandbox? ++ return false ++ end ++ + File.executable?(SANDBOX_EXEC) + end +PATCH + +# Seed a disposable copy of Homebrew's Ruby state before the publisher +# lifecycle begins. The real publisher has the same explicit seed-then-seal +# boundary: Formula build and test code may consume the selected gem group, +# but may neither provision nor mutate it. Never link the temporary worktree to +# the ambient Homebrew bundle because Bundler cleanup would then mutate shared +# developer or runner state. +SOURCE_VENDOR="$BREW_SOURCE/Library/Homebrew/vendor" +PORTABLE_RUBY_VERSION="$(<"$BREW_ROOT/Library/Homebrew/vendor/portable-ruby-version")" +if [ -d "$SOURCE_VENDOR/portable-ruby/$PORTABLE_RUBY_VERSION" ]; then + mkdir -p "$BREW_ROOT/Library/Homebrew/vendor/portable-ruby" + cp -R -p "$SOURCE_VENDOR/portable-ruby/$PORTABLE_RUBY_VERSION" \ + "$BREW_ROOT/Library/Homebrew/vendor/portable-ruby/" + ln -s "$PORTABLE_RUBY_VERSION" "$BREW_ROOT/Library/Homebrew/vendor/portable-ruby/current" +fi +if [ -d "$SOURCE_VENDOR/bundle" ]; then + # Keep the pinned commit's tracked standalone Bundler loader. Only the + # untracked gem payload is reusable across Homebrew worktrees; copying a + # newer loader would leave it naming newer gem versions after the pinned + # provisioning step correctly prunes them. + mkdir -p "$BREW_ROOT/Library/Homebrew/vendor/bundle" + cp -R -p "$SOURCE_VENDOR/bundle/ruby" \ + "$BREW_ROOT/Library/Homebrew/vendor/bundle/" +fi + +mkdir -p "$BREW_ROOT/.tmp" "$BREW_ROOT/.cache" "$BREW_ROOT/.config" \ + "$BREW_ROOT/.home" +BREW_ENV=( + HOME="$BREW_ROOT/.home" + PATH="$PATH" + HOMEBREW_CACHE="$BREW_ROOT/.cache" + HOMEBREW_NO_ANALYTICS=1 + HOMEBREW_NO_AUTO_UPDATE=1 + HOMEBREW_NO_ENV_HINTS=1 + HOMEBREW_NO_INSTALL_CLEANUP=1 + HOMEBREW_NO_INSTALL_FROM_API=1 + HOMEBREW_KANDELO_HERMETIC_LIFECYCLE_TEST=1 + HOMEBREW_TEMP="$BREW_ROOT/.tmp" + XDG_CONFIG_HOME="$BREW_ROOT/.config" +) + +PROVISION_LOG="$TMP_ROOT/bundler-provision.log" +if ! env "${BREW_ENV[@]}" "$BREW_ROOT/bin/brew" \ + install-bundler-gems --groups=formula_test >"$PROVISION_LOG" 2>&1; then + cat "$PROVISION_LOG" >&2 + fail "could not provision the disposable formula_test gem group" +fi + +BUNDLE_ROOT="$BREW_ROOT/Library/Homebrew/vendor/bundle" +BUNDLE_RUBY_ROOT="$BUNDLE_ROOT/ruby" +[ -d "$BUNDLE_ROOT" ] && [ ! -L "$BUNDLE_ROOT" ] || + fail "the provisioned Bundler vendor root is not a real directory" +[ -d "$BUNDLE_RUBY_ROOT" ] && [ ! -L "$BUNDLE_RUBY_ROOT" ] || + fail "the provisioned Bundler Ruby root is not a real directory" +UNSAFE_BUNDLE_ENTRY="$(find "$BUNDLE_RUBY_ROOT" -mindepth 1 \ + ! \( -type d -o -type f \) -print -quit)" +[ -z "$UNSAFE_BUNDLE_ENTRY" ] || + fail "the provisioned Bundler vendor tree contains a non-regular entry" +[ "$(LC_ALL=C sort "$BUNDLE_RUBY_ROOT/.homebrew_gem_groups")" = "formula_test" ] || + fail "the provisioned Bundler state does not contain exactly formula_test" +[ "$(find "$BUNDLE_RUBY_ROOT" -mindepth 2 -maxdepth 2 -type f \ + -name .homebrew_vendor_version -print | awk 'END { print NR + 0 }')" -eq 1 ] || + fail "the provisioned Bundler state has an ambiguous vendor version" +[ "$(find "$BUNDLE_RUBY_ROOT" -mindepth 2 -maxdepth 2 -type f \ + -name .homebrew_vendor_version -exec cat {} \;)" = "7" ] || + fail "the provisioned Bundler state has the wrong vendor version" +git -C "$BREW_ROOT" diff --quiet -- Library/Homebrew/vendor/bundle/bundler/setup.rb || + fail "Bundler provisioning rewrote the pinned standalone loader" + +# Seal the exact disposable bundle before any Formula-controlled process runs. +# A content digest after both commands proves that even harmless-looking +# Bundler cleanup did not rewrite or prune the sealed toolchain. +find "$BUNDLE_ROOT" -type d -exec chmod a-w {} + +find "$BUNDLE_ROOT" -type f -exec chmod a-w {} + +BUNDLE_DIGEST_BEFORE="$(find "$BUNDLE_ROOT" -type f -print0 | + LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')" + +# Network denial is an OS boundary, not a proxy-only convention. macOS uses +# sandbox-exec; Linux uses a private user/network namespace while preserving +# the caller's non-root uid so Homebrew does not observe a root invocation. +# Keep PATH explicit in BREW_ENV because sudo's secure_path policy otherwise +# hides the dev-shell publisher tools on the passwordless-sudo fallback. +case "$(uname -s)" in + Darwin) + [ -x /usr/bin/sandbox-exec ] || fail "sandbox-exec is unavailable" + NETWORK_PROFILE="$TMP_ROOT/no-network.sb" + cat >"$NETWORK_PROFILE" <"$NETWORK_PROBE_PORT" & +NETWORK_PROBE_PID=$! +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$NETWORK_PROBE_PORT" ] && break + sleep 0.1 +done +[ -s "$NETWORK_PROBE_PORT" ] || fail "the network-isolation probe did not start" +PROBE_PORT="$(cat "$NETWORK_PROBE_PORT")" +"$PYTHON_BIN" -c 'import socket, sys +connection = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=2) +connection.close()' "$PROBE_PORT" || fail "the network-isolation control connection failed" +if "${OFFLINE_RUNNER[@]}" "$PYTHON_BIN" -c 'import socket, sys +connection = socket.create_connection(("127.0.0.1", int(sys.argv[1])), timeout=2) +connection.close()' "$PROBE_PORT" >/dev/null 2>&1; then + fail "the network-isolation boundary allowed a reachable socket" +fi +kill "$NETWORK_PROBE_PID" >/dev/null 2>&1 || true +wait "$NETWORK_PROBE_PID" >/dev/null 2>&1 || true +NETWORK_PROBE_PID="" + +run_offline_brew() { + local phase="$1" + shift + local log="$TMP_ROOT/$phase.log" + if ! "${OFFLINE_RUNNER[@]}" env "${BREW_ENV[@]}" \ + http_proxy=http://127.0.0.1:9 \ + https_proxy=http://127.0.0.1:9 \ + all_proxy=http://127.0.0.1:9 \ + "$BREW_ROOT/bin/brew" "$@" >"$log" 2>&1; then + cat "$log" >&2 + fail "the offline Homebrew $phase lifecycle failed" + fi + cat "$log" + if grep -E "Fetching gem metadata|rubygems\\.org|Bundle complete!|Installing '[^']+' gem|install-bundler-gems|bundle install" \ + "$log" >/dev/null; then + fail "the offline Homebrew $phase lifecycle attempted a gem fetch or install" + fi +} + +TAP_ROOT="$BREW_ROOT/Library/Taps/kandelo-dev/homebrew-tap-core" +CORE_ROOT="$BREW_ROOT/Library/Taps/homebrew/homebrew-core" +SOURCE_ROOT="$TMP_ROOT/source" +mkdir -p "$TAP_ROOT/Formula" "$TAP_ROOT/Kandelo/formula_support" \ + "$CORE_ROOT/Formula/w" "$SOURCE_ROOT/fixture-1.0" \ + "$BREW_ROOT/Cellar/wabt/1.0/bin" "$BREW_ROOT/opt" + +printf 'fixture source\n' >"$SOURCE_ROOT/fixture-1.0/README" +tar -C "$SOURCE_ROOT" -czf "$SOURCE_ROOT/fixture-1.0.tar.gz" fixture-1.0 +SOURCE_SHA256="$(sha256sum "$SOURCE_ROOT/fixture-1.0.tar.gz" | awk '{print $1}')" + +cat >"$TAP_ROOT/Kandelo/formula_support/kandelo_formula_support.rb" <<'RUBY' +require "digest" +require "fileutils" +require "json" +require "pathname" +require "shellwords" +require "tempfile" + +if defined?(KandeloFormulaSupport) + unless KandeloFormulaSupport::KANDELO_FORMULA_SUPPORT_API_VERSION == 1 && + Digest::SHA256.file(Pathname(__FILE__).realpath).hexdigest == + KandeloFormulaSupport::KANDELO_TIER2_RUNTIME.fetch("support_sha256") + raise "loaded Kandelo Formula support copies are incompatible" + end +else +module KandeloFormulaSupport + KANDELO_FORMULA_SUPPORT_API_VERSION = 1 + + class WabtRequirement < Requirement + KANDELO_NATIVE_FORMULA = "wabt" + KANDELO_NATIVE_SENTINEL = "wasm-validate" + fatal true + satisfy(build_env: false) { which("wasm-validate") } + end + + def self.kandelo_load_tier2_runtime! + support_path = Pathname(__FILE__).realpath + { "support_sha256" => Digest::SHA256.file(support_path).hexdigest }.freeze + end + + KANDELO_TIER2_RUNTIME = kandelo_load_tier2_runtime! +end +end +RUBY + +cat >"$TAP_ROOT/Formula/fixture.rb" < [:build, :test] + + def install + system "wasm-validate", "--kandelo-build-probe" + (bin/"fixture").write <<~SH + #!/bin/sh + exit 0 + SH + end + + test do + system "wasm-validate", "--kandelo-test-probe" + end +end +RUBY + +cat >"$CORE_ROOT/Formula/w/wabt.rb" < "wasm-validate" + end +end +RUBY + +cat >"$BREW_ROOT/Cellar/wabt/1.0/bin/wasm-validate" <<'SH' +#!/bin/sh +set -eu +marker_root="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd -P)" +case "${1:-}" in + --kandelo-build-probe) : >"$marker_root/build-tool-used" ;; + --kandelo-test-probe) : >"$marker_root/test-tool-used" ;; + *) exit 64 ;; +esac +SH +chmod 0755 "$BREW_ROOT/Cellar/wabt/1.0/bin/wasm-validate" +ln -s ../Cellar/wabt/1.0 "$BREW_ROOT/opt/wabt" + +for repository in "$TAP_ROOT" "$CORE_ROOT"; do + git -C "$repository" init -q + git -C "$repository" config user.name "Kandelo tests" + git -C "$repository" config user.email "tests@kandelo.invalid" + git -C "$repository" add . + git -C "$repository" commit -qm "Test: create offline Formula fixture" +done +TAP_COMMIT="$(git -C "$TAP_ROOT" rev-parse HEAD)" + +RESOLVED_TAPS="$TMP_ROOT/resolved-taps.json" +HOST_DEPENDENCY_PLAN="$TMP_ROOT/host-dependencies.json" +cat >"$RESOLVED_TAPS" <"$HOST_DEPENDENCY_PLAN" +bash "$REPO_ROOT/scripts/homebrew-validate-host-dependency-plan.sh" \ + "$HOST_DEPENDENCY_PLAN" kandelo-dev/tap-core fixture "$RESOLVED_TAPS" +jq -e ' + .build == ["wabt"] and + .build_and_test == ["wabt"] and + .native_requirements == [{ + class: "KandeloFormulaSupport::WabtRequirement", + formula: "wabt", + sentinel: "wasm-validate", + tags: ["build", "test"] + }] and + .runtime_and_test == ["wabt"] +' "$HOST_DEPENDENCY_PLAN" >/dev/null +cp "$HOST_DEPENDENCY_PLAN" \ + "$BREW_ROOT/.kandelo-publisher-build-dependencies.json" +chmod 0444 "$BREW_ROOT/.kandelo-publisher-build-dependencies.json" + +run_offline_brew install install --build-bottle \ + --ignore-dependencies kandelo-dev/tap-core/fixture +[ -e "$BREW_ROOT/Cellar/wabt/1.0/build-tool-used" ] || fail \ + "the real Build/Superenv lifecycle did not execute the native Requirement tool" + +run_offline_brew test test kandelo-dev/tap-core/fixture +[ -e "$BREW_ROOT/Cellar/wabt/1.0/test-tool-used" ] || fail \ + "the real Formula test lifecycle did not execute the sealed native Requirement tool" + +BUNDLE_DIGEST_AFTER="$(find "$BUNDLE_ROOT" -type f -print0 | + LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | awk '{print $1}')" +[ "$BUNDLE_DIGEST_AFTER" = "$BUNDLE_DIGEST_BEFORE" ] || + fail "the real publisher lifecycle changed the sealed Bundler vendor tree" + +RECEIPT="$BREW_ROOT/Cellar/fixture/1.0/.brew/fixture.rb" +[ -f "$RECEIPT" ] || fail "the real pinned Homebrew lifecycle did not install the fixture" + +echo "test-homebrew-publisher-real-lifecycle.sh: ok" diff --git a/scripts/test-homebrew-tap-native-sidecars.sh b/scripts/test-homebrew-tap-native-sidecars.sh index d359acc839..bda72a61d3 100755 --- a/scripts/test-homebrew-tap-native-sidecars.sh +++ b/scripts/test-homebrew-tap-native-sidecars.sh @@ -332,18 +332,9 @@ make_tool_bottle() { local bottle_json="$TMPDIR/sidecar-tool--2.0_3.wasm32_kandelo.bottle.json" mkdir -p "$stage/bin" "$stage/include" "$stage/lib" "$stage/share/man/man1" \ "$stage/share/info" "$stage/.brew" - cat >"$TMPDIR/sidecar-tool.wat" <"$stage/include/sidecar-tool.h" @@ -413,18 +404,9 @@ make_tool_wasm64_bottle() { rm -rf "$stage_parent" mkdir -p "$stage_parent" tar -xzf "$source_archive" -C "$stage_parent" - cat >"$TMPDIR/sidecar-tool-wasm64.wat" <"$RESOLVED" <<'JSON' +{ + "schema": 1, + "primary": { + "tap_name": "kandelo-dev/tap-core", + "tap_repository": "kandelo-dev/homebrew-tap-core", + "tap_commit": "1111111111111111111111111111111111111111", + "root": "/tmp/unused-tap-root" + }, + "dependencies": [] +} +JSON + +cat >"$PLAN" <<'JSON' +{ + "schema": 4, + "tap": "kandelo-dev/tap-core", + "formula": "fixture", + "full_name": "kandelo-dev/tap-core/fixture", + "target_taps": [{ + "tap_name": "kandelo-dev/tap-core", + "tap_repository": "kandelo-dev/homebrew-tap-core", + "tap_commit": "1111111111111111111111111111111111111111" + }], + "build": ["binaryen", "pkgconf", "wabt"], + "build_and_test": ["binaryen", "pkgconf", "wabt"], + "native_requirements": [ + { + "class": "KandeloFormulaSupport::BinaryenRequirement", + "formula": "binaryen", + "sentinel": "wasm-opt", + "tags": ["build"] + }, + { + "class": "KandeloFormulaSupport::PkgconfRequirement", + "formula": "pkgconf", + "sentinel": "pkg-config", + "tags": ["build", "test"] + } + ], + "runtime_and_test": ["pkgconf"] +} +JSON + +bash "$VALIDATOR" "$PLAN" kandelo-dev/tap-core fixture "$RESOLVED" + +assert_rejected() { + if bash "$VALIDATOR" "$MUTATED" kandelo-dev/tap-core fixture "$RESOLVED" \ + >/dev/null 2>&1; then + echo "test-homebrew-validate-host-dependency-plan.sh: accepted $1" >&2 + exit 1 + fi +} + +mutate_and_reject() { + local label="$1" + local filter="$2" + jq "$filter" "$PLAN" >"$MUTATED" + assert_rejected "$label" +} + +mutate_and_reject "legacy schema 3" '.schema = 3' +mutate_and_reject "unsorted native Requirement records" '.native_requirements |= reverse' +mutate_and_reject "duplicate native Requirement class" \ + '.native_requirements += [.native_requirements[0]]' +mutate_and_reject "duplicate native Requirement Formula identity" ' + .native_requirements[1].formula = "binaryen" | + .runtime_and_test = ["binaryen"] +' +mutate_and_reject "test-only native Requirement tags" \ + '.native_requirements[0].tags = ["test"]' +mutate_and_reject "missing test-list membership" '.runtime_and_test = []' +mutate_and_reject "unexpected build-only runtime membership" \ + '.runtime_and_test = ["binaryen", "pkgconf"]' +mutate_and_reject "native Formula absent from the build list" \ + '.build = ["pkgconf", "wabt"]' +mutate_and_reject "unsafe sentinel executable" \ + '.native_requirements[0].sentinel = "../wasm-opt"' +mutate_and_reject "malformed evaluated class identity" \ + '.native_requirements[0].class = "BinaryenRequirement"' +mutate_and_reject "open native Requirement record" \ + '.native_requirements[0].unexpected = true' +mutate_and_reject "missing native Requirement plan" 'del(.native_requirements)' +mutate_and_reject "oversized host dependency arrays" ' + ([range(0; 129) | "tool\(.)"] | sort) as $tools | + .build = $tools | + .build_and_test = $tools | + .native_requirements = [] | + .runtime_and_test = $tools +' + +echo "test-homebrew-validate-host-dependency-plan.sh: ok" diff --git a/scripts/test-wasm-artifact-guards.sh b/scripts/test-wasm-artifact-guards.sh index 42561e3b5b..66c4a9a975 100755 --- a/scripts/test-wasm-artifact-guards.sh +++ b/scripts/test-wasm-artifact-guards.sh @@ -386,16 +386,28 @@ PY 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") (import "kernel" "kernel_fork" (func $kernel_fork)) - (func (export "wpk_fork_unwind_begin")) + (import "env" "__wpk_fork_frame_reserve" + (func $frame_reserve (param i32) (result i32))) + (import "env" "__wpk_fork_frame_commit" + (func $frame_commit (param i32))) + (import "env" "__wpk_fork_frame_next" + (func $frame_next (param i32) (result i32))) + (memory 1) + (func (export "wpk_fork_abort_begin") (param i32)) + (func (export "wpk_fork_abort_end")) + (func (export "wpk_fork_unwind_begin") (param i32)) (func (export "wpk_fork_unwind_end")) - (func (export "wpk_fork_rewind_begin")) + (func (export "wpk_fork_rewind_begin") (param i32)) (func (export "wpk_fork_rewind_end")) - (func (export "wpk_fork_state")) + (func (export "wpk_fork_state") (result i32) + i32.const 0) (func (export "_start") call $kernel_fork)) WAT -wat2wasm "$work/complete-fork.wat" -o "$work/complete-fork.wasm" +wat2wasm --enable-annotations "$work/complete-fork.wat" -o "$work/complete-fork.wasm" if ! wasm_has_complete_fork_instrumentation "$work/complete-fork.wasm"; then echo "ERROR: complete fork instrumentation was rejected" >&2 exit 1 @@ -406,17 +418,59 @@ if wasm_has_missing_fork_instrumentation "$work/complete-fork.wasm"; then fi wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" +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") + (import "kernel" "kernel_fork" (func $kernel_fork)) + (import "env" "__wpk_fork_frame_reserve" + (func $frame_reserve (param i64) (result i64))) + (import "env" "__wpk_fork_frame_commit" + (func $frame_commit (param i64))) + (import "env" "__wpk_fork_frame_next" + (func $frame_next (param i64) (result i64))) + (memory i64 1) + (func (export "wpk_fork_abort_begin") (param i64)) + (func (export "wpk_fork_abort_end")) + (func (export "wpk_fork_unwind_begin") (param i64)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i64)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) + i32.const 0) + (func (export "_start") + call $kernel_fork)) +WAT +wat2wasm --enable-annotations --enable-memory64 "$work/complete-fork-wasm64.wat" \ + -o "$work/complete-fork-wasm64.wasm" +if ! wasm_has_complete_fork_instrumentation "$work/complete-fork-wasm64.wasm"; then + echo "ERROR: complete wasm64 fork instrumentation was rejected" >&2 + exit 1 +fi +wasm_require_fork_instrumentation_if_needed "$work/complete-fork-wasm64.wasm" + 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") (import "kernel" "kernel_fork" (func $kernel_fork)) - (func (export "wpk_fork_unwind_begin")) + (import "env" "__wpk_fork_frame_reserve" + (func $frame_reserve (param i32) (result i32))) + (import "env" "__wpk_fork_frame_commit" + (func $frame_commit (param i32))) + (import "env" "__wpk_fork_frame_next" + (func $frame_next (param i32) (result i32))) + (memory 1) + (func (export "wpk_fork_abort_begin") (param i32)) + (func (export "wpk_fork_abort_end")) + (func (export "wpk_fork_unwind_begin") (param i32)) (func (export "wpk_fork_unwind_end")) - (func (export "wpk_fork_rewind_begin")) + (func (export "wpk_fork_rewind_begin") (param i32)) (func (export "wpk_fork_rewind_end")) (func (export "_start") call $kernel_fork)) WAT -wat2wasm "$work/partial-fork.wat" -o "$work/partial-fork.wasm" +wat2wasm --enable-annotations "$work/partial-fork.wat" -o "$work/partial-fork.wasm" partial_fork_error="$work/partial-fork.error" if wasm_require_fork_instrumentation_if_needed \ "$work/partial-fork.wasm" 2>"$partial_fork_error"; then @@ -429,10 +483,131 @@ grep -Fqx ' missing: wpk_fork_state' "$partial_fork_error" || { exit 1 } +# 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" +wat2wasm --enable-annotations "$work/missing-fork-descriptor.wat" \ + -o "$work/missing-fork-descriptor.wasm" +if wasm_require_fork_instrumentation_if_needed \ + "$work/missing-fork-descriptor.wasm" >/dev/null 2>&1; then + echo "ERROR: fork instrumentation without its descriptor was accepted" >&2 + exit 1 +fi + +sed 's/\\03\\00\\20/\\01\\00\\20/' \ + "$work/complete-fork.wat" >"$work/malformed-fork-descriptor.wat" +wat2wasm --enable-annotations "$work/malformed-fork-descriptor.wat" \ + -o "$work/malformed-fork-descriptor.wasm" +if wasm_require_fork_instrumentation_if_needed \ + "$work/malformed-fork-descriptor.wasm" >/dev/null 2>&1; then + echo "ERROR: fork instrumentation with incomplete descriptor flags was accepted" >&2 + exit 1 +fi + +sed \ + 's/\\04\\08\\03\\00\\20\\00\\00\\00\\18/\\08\\08\\03\\00\\38\\00\\00\\00\\20/' \ + "$work/complete-fork.wat" >"$work/mismatched-memory-descriptor.wat" +grep -F '\08\08\03\00\38\00\00\00\20' \ + "$work/mismatched-memory-descriptor.wat" >/dev/null || { + echo "ERROR: failed to construct descriptor/memory drift fixture" >&2 + exit 1 +} +wat2wasm --enable-annotations "$work/mismatched-memory-descriptor.wat" \ + -o "$work/mismatched-memory-descriptor.wasm" +[ "$(wasm_linked_frame_descriptor_pointer_width \ + "$work/mismatched-memory-descriptor.wasm")" = 8 ] || { + echo "ERROR: descriptor/memory drift fixture did not contain a wasm64 descriptor" >&2 + exit 1 +} +mismatched_memory_error="$work/mismatched-memory-descriptor.error" +if wasm_require_fork_instrumentation_if_needed \ + "$work/mismatched-memory-descriptor.wasm" 2>"$mismatched_memory_error"; then + echo "ERROR: fork descriptor whose pointer width disagrees with memory was accepted" >&2 + exit 1 +fi +grep -F 'descriptor declares an 8-byte pointer but module memory uses 4-byte addresses' \ + "$mismatched_memory_error" >/dev/null || { + echo "ERROR: descriptor/memory pointer-width drift was not reported" >&2 + cat "$mismatched_memory_error" >&2 + exit 1 +} + +sed \ + 's/(func (export "wpk_fork_abort_begin") (param i32))/(func (export "wpk_fork_abort_begin") (param i64))/' \ + "$work/complete-fork.wat" >"$work/mismatched-fork-signature.wat" +wat2wasm --enable-annotations "$work/mismatched-fork-signature.wat" \ + -o "$work/mismatched-fork-signature.wasm" +mismatched_signature_error="$work/mismatched-fork-signature.error" +if wasm_require_fork_instrumentation_if_needed \ + "$work/mismatched-fork-signature.wasm" 2>"$mismatched_signature_error"; then + echo "ERROR: fork export with the wrong pointer signature was accepted" >&2 + exit 1 +fi +grep -F 'signatures do not match module memory' "$mismatched_signature_error" >/dev/null || { + echo "ERROR: fork signature drift was not reported" >&2 + cat "$mismatched_signature_error" >&2 + exit 1 +} + +sed '/__wpk_fork_frame_reserve/,+1d' \ + "$work/complete-fork.wat" >"$work/missing-frame-reserve.wat" +wat2wasm --enable-annotations "$work/missing-frame-reserve.wat" \ + -o "$work/missing-frame-reserve.wasm" +missing_import_error="$work/missing-frame-reserve.error" +if wasm_require_fork_instrumentation_if_needed \ + "$work/missing-frame-reserve.wasm" 2>"$missing_import_error"; then + echo "ERROR: fork instrumentation with a partial frame transaction was accepted" >&2 + exit 1 +fi +grep -F 'env.__wpk_fork_frame_reserve' "$missing_import_error" >/dev/null || { + echo "ERROR: partial frame transaction did not report its missing reserve hook" >&2 + cat "$missing_import_error" >&2 + exit 1 +} +if ! wasm_has_any_fork_instrumentation "$work/missing-frame-reserve.wasm"; then + echo "ERROR: partial frame transaction was mistaken for a clean input" >&2 + exit 1 +fi + +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") + (memory 1) + (func (export "wpk_fork_abort_begin") (param i32)) + (func (export "wpk_fork_abort_end")) + (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_state") (result i32) + i32.const 0) + (func (export "_start"))) +WAT +wat2wasm --enable-annotations "$work/inert-fork.wat" -o "$work/inert-fork.wasm" +wasm_require_fork_instrumentation_if_needed "$work/inert-fork.wasm" +if wasm_require_no_fork_instrumentation "$work/inert-fork.wasm" >/dev/null 2>&1; then + echo "ERROR: disabled fork policy accepted an inert instrumented runtime" >&2 + exit 1 +fi + +cat >"$work/wasm64-linked-frame-descriptor.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")) +WAT +wat2wasm --enable-annotations "$work/wasm64-linked-frame-descriptor.wat" \ + -o "$work/wasm64-linked-frame-descriptor.wasm" +[ "$(wasm_linked_frame_descriptor_pointer_width \ + "$work/wasm64-linked-frame-descriptor.wasm")" = 8 ] || { + echo "ERROR: valid wasm64 linked-frame descriptor was rejected" >&2 + exit 1 +} + mkdir "$work/counting-bin" cat >"$work/counting-bin/wasm-objdump" <<'SH' #!/usr/bin/env bash -printf 'decode\n' >> "$WASM_OBJDUMP_COUNT_FILE" +printf '%s\n' "${1:-}" >> "$WASM_OBJDUMP_COUNT_FILE" exec "$REAL_WASM_OBJDUMP" "$@" SH chmod +x "$work/counting-bin/wasm-objdump" @@ -444,8 +619,11 @@ count_file="$work/wasm-objdump.count" export WASM_OBJDUMP_COUNT_FILE="$count_file" wasm_require_fork_instrumentation_if_needed "$work/complete-fork.wasm" ) -[ "$(wc -l <"$count_file" | tr -d ' ')" = 1 ] || { - echo "ERROR: complete fork validation decoded the Wasm more than once" >&2 +[ "$(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 + cat "$count_file" >&2 exit 1 } diff --git a/scripts/verify-homebrew-bootstrap-source-lock.mjs b/scripts/verify-homebrew-bootstrap-source-lock.mjs new file mode 100644 index 0000000000..35afc26c8f --- /dev/null +++ b/scripts/verify-homebrew-bootstrap-source-lock.mjs @@ -0,0 +1,343 @@ +import { createHash } from "node:crypto"; +import { + lstatSync, + readFileSync, +} from "node:fs"; +import { pathToFileURL } from "node:url"; + +const SHA256 = /^[0-9a-f]{64}$/; +const GIT_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9]+-g[0-9a-f]{7,40})?$/; +const PORTABLE_RUBY_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(?:_[0-9]+)?$/; +const TOOL_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+$/; + +function fail(message) { + throw new Error(`homebrew-bootstrap source lock: ${message}`); +} + +function exactKeys(value, expected, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + fail(`${label} must be an object`); + } + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + fail(`${label} fields must be exactly ${wanted.join(", ")}; got ${actual.join(", ")}`); + } +} + +function regularFile(path, label) { + const stat = lstatSync(path); + if (!stat.isFile() || stat.isSymbolicLink()) { + fail(`${label} must be a regular non-symlink file: ${path}`); + } + return stat; +} + +function stringField(value, pattern, label) { + if (typeof value !== "string" || !pattern.test(value)) { + fail(`${label} is invalid`); + } +} + +function positiveSafeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 1) { + fail(`${label} must be a positive safe integer`); + } +} + +export function loadHomebrewBootstrapSourceLock(path) { + regularFile(path, "lock"); + const lock = JSON.parse(readFileSync(path, "utf8")); + exactKeys( + lock, + ["schema", "kind", "package", "source", "patch", "license", "prepared", "output"], + "lock", + ); + if (lock.schema !== 1) fail(`unsupported schema ${lock.schema}`); + if (lock.kind !== "kandelo-homebrew-bootstrap-source-lock") { + fail(`unsupported kind ${JSON.stringify(lock.kind)}`); + } + + exactKeys(lock.package, ["name", "version", "arch"], "package"); + if (lock.package.name !== "homebrew-bootstrap") fail("package.name must be homebrew-bootstrap"); + stringField(lock.package.version, VERSION, "package.version"); + if (lock.package.arch !== "wasm32") fail("package.arch must be wasm32"); + + exactKeys( + lock.source, + ["repository", "revision", "archive_url", "archive_sha256"], + "source", + ); + if (lock.source.repository !== "https://github.com/Homebrew/brew.git") { + fail("source.repository must be the anonymous upstream Homebrew repository"); + } + stringField(lock.source.revision, /^[0-9a-f]{40}$/, "source.revision"); + const expectedArchiveUrl = + `https://github.com/Homebrew/brew/archive/${lock.source.revision}.tar.gz`; + if (lock.source.archive_url !== expectedArchiveUrl) { + fail(`source.archive_url must be ${expectedArchiveUrl}`); + } + stringField(lock.source.archive_sha256, SHA256, "source.archive_sha256"); + + exactKeys(lock.patch, ["path", "sha256"], "patch"); + if (lock.patch.path !== "homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch") { + fail("patch.path must name the reviewed guest Homebrew patch"); + } + stringField(lock.patch.sha256, SHA256, "patch.sha256"); + + exactKeys(lock.license, ["expression", "upstream", "kandelo_patch"], "license"); + if (lock.license.expression !== "BSD-2-Clause AND GPL-2.0-or-later") { + fail("license.expression must preserve both reviewed license boundaries"); + } + exactKeys( + lock.license.upstream, + ["spdx", "path", "sha256", "bytes"], + "license.upstream", + ); + if ( + lock.license.upstream.spdx !== "BSD-2-Clause" || + lock.license.upstream.path !== "LICENSE.txt" + ) { + fail("license.upstream must identify Homebrew's exact BSD-2-Clause LICENSE.txt"); + } + stringField(lock.license.upstream.sha256, SHA256, "license.upstream.sha256"); + positiveSafeInteger(lock.license.upstream.bytes, "license.upstream.bytes"); + exactKeys( + lock.license.kandelo_patch, + ["spdx", "evidence_path", "evidence_sha256"], + "license.kandelo_patch", + ); + if ( + lock.license.kandelo_patch.spdx !== "GPL-2.0-or-later" || + lock.license.kandelo_patch.evidence_path !== "homebrew/patches/README.md" + ) { + fail("license.kandelo_patch must identify the documented Kandelo project boundary"); + } + stringField( + lock.license.kandelo_patch.evidence_sha256, + SHA256, + "license.kandelo_patch.evidence_sha256", + ); + + exactKeys( + lock.prepared, + [ + "patched_tree_git_oid", + "patched_tree_sha256", + "portable_ruby_version", + "git_version", + ], + "prepared", + ); + stringField(lock.prepared.patched_tree_git_oid, GIT_OID, "prepared.patched_tree_git_oid"); + stringField(lock.prepared.patched_tree_sha256, SHA256, "prepared.patched_tree_sha256"); + stringField( + lock.prepared.portable_ruby_version, + PORTABLE_RUBY_VERSION, + "prepared.portable_ruby_version", + ); + stringField(lock.prepared.git_version, TOOL_VERSION, "prepared.git_version"); + + exactKeys( + lock.output, + ["path", "sha256", "bytes"], + "output", + ); + if (lock.output.path !== "homebrew-bootstrap.zip") { + fail("output.path must be homebrew-bootstrap.zip"); + } + stringField(lock.output.sha256, SHA256, "output.sha256"); + positiveSafeInteger(lock.output.bytes, "output.bytes"); + + return lock; +} + +function compareOption(options, name, expected, label = name) { + if (options.has(name) && options.get(name) !== expected) { + fail(`${label} mismatch: expected ${expected}, got ${options.get(name)}`); + } +} + +function verifySourceCheckout(lock, sourceCheckout) { + const checkoutStat = lstatSync(sourceCheckout); + if (!checkoutStat.isDirectory() || checkoutStat.isSymbolicLink()) { + fail(`source checkout must be a real directory: ${sourceCheckout}`); + } + const versionPath = + `${sourceCheckout}/Library/Homebrew/vendor/portable-ruby-version`; + regularFile(versionPath, "portable Ruby version"); + const bytes = readFileSync(versionPath); + const expected = `${lock.prepared.portable_ruby_version}\n`; + if (!bytes.equals(Buffer.from(expected))) { + fail(`source checkout portable Ruby version must be exactly ${JSON.stringify(expected)}`); + } + + const licensePath = `${sourceCheckout}/${lock.license.upstream.path}`; + const licenseStat = regularFile(licensePath, "upstream Homebrew license"); + if (licenseStat.size !== lock.license.upstream.bytes) { + fail( + `upstream Homebrew license has ${licenseStat.size} bytes, ` + + `expected ${lock.license.upstream.bytes}`, + ); + } + const licenseSha256 = createHash("sha256") + .update(readFileSync(licensePath)) + .digest("hex"); + if (licenseSha256 !== lock.license.upstream.sha256) { + fail("upstream Homebrew license SHA-256 mismatch"); + } +} + +function verifyLicenseEvidence(lock, evidencePath) { + regularFile(evidencePath, "Kandelo patch license evidence"); + const sha256 = createHash("sha256") + .update(readFileSync(evidencePath)) + .digest("hex"); + if (sha256 !== lock.license.kandelo_patch.evidence_sha256) { + fail("Kandelo patch license evidence SHA-256 mismatch"); + } +} + +function verifyProvenance(lock, provenancePath) { + regularFile(provenancePath, "source provenance"); + const provenance = JSON.parse(readFileSync(provenancePath, "utf8")); + exactKeys( + provenance, + [ + "schema", + "homebrew_repository", + "homebrew_revision", + "homebrew_patch_sha256", + "homebrew_patched_tree_git_oid", + "homebrew_patched_tree_sha256", + "homebrew_archive_sha256", + "homebrew_bottle_arch", + "homebrew_bottle_tag", + ], + "source provenance", + ); + const expected = { + schema: 1, + homebrew_repository: lock.source.repository, + homebrew_revision: lock.source.revision, + homebrew_patch_sha256: lock.patch.sha256, + homebrew_patched_tree_git_oid: lock.prepared.patched_tree_git_oid, + homebrew_patched_tree_sha256: lock.prepared.patched_tree_sha256, + homebrew_archive_sha256: lock.output.sha256, + homebrew_bottle_arch: lock.package.arch, + homebrew_bottle_tag: `${lock.package.arch}_kandelo`, + }; + for (const [field, value] of Object.entries(expected)) { + if (provenance[field] !== value) { + fail(`source provenance ${field} mismatch`); + } + } +} + +function verifyArchive(lock, archivePath) { + const stat = regularFile(archivePath, "output archive"); + if (stat.size !== lock.output.bytes) { + fail(`output archive has ${stat.size} bytes, expected ${lock.output.bytes}`); + } + const sha256 = createHash("sha256").update(readFileSync(archivePath)).digest("hex"); + if (sha256 !== lock.output.sha256) { + fail(`output archive SHA-256 ${sha256} does not match ${lock.output.sha256}`); + } +} + +export function verifyHomebrewBootstrapSourceLock(lock, options = new Map()) { + compareOption(options, "package-name", lock.package.name); + compareOption(options, "package-version", lock.package.version); + compareOption(options, "target-arch", lock.package.arch); + compareOption(options, "source-url", lock.source.archive_url); + compareOption(options, "source-sha256", lock.source.archive_sha256); + compareOption(options, "git-commit", lock.source.revision); + compareOption(options, "git-version", lock.prepared.git_version); + compareOption(options, "patch-path", lock.patch.path); + + if (options.has("source-checkout")) { + verifySourceCheckout(lock, options.get("source-checkout")); + } + if (options.has("license-evidence")) { + verifyLicenseEvidence(lock, options.get("license-evidence")); + } + if (options.has("provenance")) { + verifyProvenance(lock, options.get("provenance")); + } + if (options.has("archive")) { + verifyArchive(lock, options.get("archive")); + } +} + +const FIELDS = new Map([ + ["package.name", (lock) => lock.package.name], + ["package.version", (lock) => lock.package.version], + ["package.arch", (lock) => lock.package.arch], + ["source.repository", (lock) => lock.source.repository], + ["source.revision", (lock) => lock.source.revision], + ["patch.path", (lock) => lock.patch.path], + ["patch.sha256", (lock) => lock.patch.sha256], + [ + "license.kandelo_patch.evidence_path", + (lock) => lock.license.kandelo_patch.evidence_path, + ], +]); + +function usage() { + console.error( + "usage: node scripts/verify-homebrew-bootstrap-source-lock.mjs " + + "--lock [--field | verification options]", + ); +} + +function main(argv) { + const allowed = new Set([ + "lock", + "field", + "package-name", + "package-version", + "target-arch", + "source-url", + "source-sha256", + "git-commit", + "git-version", + "patch-path", + "license-evidence", + "source-checkout", + "provenance", + "archive", + ]); + const options = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const flag = argv[index]; + const value = argv[index + 1]; + const name = flag?.startsWith("--") ? flag.slice(2) : ""; + if (!allowed.has(name) || options.has(name) || value === undefined) { + usage(); + process.exitCode = 2; + return; + } + options.set(name, value); + } + const lockPath = options.get("lock"); + if (!lockPath) { + usage(); + process.exitCode = 2; + return; + } + const lock = loadHomebrewBootstrapSourceLock(lockPath); + if (options.has("field")) { + if (options.size !== 2) fail("--field cannot be combined with verification options"); + const read = FIELDS.get(options.get("field")); + if (!read) fail(`unsupported field ${JSON.stringify(options.get("field"))}`); + process.stdout.write(`${read(lock)}\n`); + return; + } + options.delete("lock"); + verifyHomebrewBootstrapSourceLock(lock, options); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main(process.argv.slice(2)); +} diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index c1af0d6c47..7c23233914 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -655,30 +655,192 @@ wasm_imports_kernel_fork() { # decoder failure from being misreported as one arbitrarily missing export. # # Output fields are, in order: -# relocatable, imports kernel.kernel_fork, unwind begin/end, -# rewind begin/end, and state export. +# relocatable, imports kernel.kernel_fork, frame reserve/commit/next imports, +# linked-frame descriptor count, 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() { local path="${1:-}" wasm_is_binary "$path" || return 1 command -v wasm-objdump >/dev/null 2>&1 || return 2 _wasm_stream_awk ' + function function_index(line, value) { + value = line + sub(/^ - func\[/, "", value) + sub(/\].*$/, "", value) + return value + 0 + } + function signature_index(line, value) { + value = line + sub(/^.* sig=/, "", value) + sub(/[^0-9].*$/, "", value) + return value + 0 + } /name: "(linking|reloc\.)/ { relocatable = 1 } - /<- kernel\.kernel_fork/ { imports_fork = 1 } - /-> "wpk_fork_unwind_begin"/ { unwind_begin = 1 } - /-> "wpk_fork_unwind_end"/ { unwind_end = 1 } - /-> "wpk_fork_rewind_begin"/ { rewind_begin = 1 } - /-> "wpk_fork_rewind_end"/ { rewind_end = 1 } - /-> "wpk_fork_state"/ { state = 1 } + /^ - type\[/ { + type_index = $0 + sub(/^ - type\[/, "", type_index) + sub(/\].*$/, "", type_index) + signature = $0 + sub(/^.*\] /, "", signature) + function_types[type_index + 0] = signature + next + } + /^ - func\[.* sig=[0-9]+/ { + function_signatures[function_index($0)] = function_types[signature_index($0)] + } + /^ - func\[.* <- kernel\.kernel_fork$/ { imports_fork = 1 } + /^ - func\[.* <- env\.__wpk_fork_frame_reserve$/ { + frame_reserve++ + frame_reserve_signatures[frame_reserve] = function_signatures[function_index($0)] + } + /^ - func\[.* <- env\.__wpk_fork_frame_commit$/ { + frame_commit++ + frame_commit_signatures[frame_commit] = function_signatures[function_index($0)] + } + /^ - func\[.* <- env\.__wpk_fork_frame_next$/ { + frame_next++ + frame_next_signatures[frame_next] = function_signatures[function_index($0)] + } + /^ - name: "kandelo\.wpk_fork\.linked_frames"$/ { linked_descriptor++ } + /^ - memory\[[0-9]+\] pages:/ { + memory_count++ + if ($0 ~ / i64( |$)/) memory64_count++ + } + /^ - func\[.* -> "wpk_fork_abort_begin"$/ { + abort_begin++ + abort_begin_signatures[abort_begin] = function_signatures[function_index($0)] + } + /^ - func\[.* -> "wpk_fork_abort_end"$/ { + abort_end++ + abort_end_signatures[abort_end] = function_signatures[function_index($0)] + } + /^ - func\[.* -> "wpk_fork_rewind_begin"$/ { + rewind_begin++ + rewind_begin_signatures[rewind_begin] = function_signatures[function_index($0)] + } + /^ - func\[.* -> "wpk_fork_rewind_end"$/ { + rewind_end++ + rewind_end_signatures[rewind_end] = function_signatures[function_index($0)] + } + /^ - func\[.* -> "wpk_fork_state"$/ { + state++ + state_signatures[state] = function_signatures[function_index($0)] + } + /^ - func\[.* -> "wpk_fork_unwind_begin"$/ { + unwind_begin++ + unwind_begin_signatures[unwind_begin] = function_signatures[function_index($0)] + } + /^ - func\[.* -> "wpk_fork_unwind_end"$/ { + unwind_end++ + unwind_end_signatures[unwind_end] = function_signatures[function_index($0)] + } END { - printf "%d\t%d\t%d\t%d\t%d\t%d\t%d\n", + pointer = memory_count == 1 && memory64_count == 1 ? "i64" : "i32" + pointer_to_pointer = "(" pointer ") -> " pointer + pointer_to_nil = "(" pointer ") -> nil" + nil_to_nil = "() -> nil" + for (i = 1; i <= frame_reserve; i++) + if (frame_reserve_signatures[i] != pointer_to_pointer) signature_mismatch++ + for (i = 1; i <= frame_commit; i++) + if (frame_commit_signatures[i] != pointer_to_nil) signature_mismatch++ + for (i = 1; i <= frame_next; i++) + if (frame_next_signatures[i] != pointer_to_pointer) signature_mismatch++ + for (i = 1; i <= abort_begin; i++) + if (abort_begin_signatures[i] != pointer_to_nil) signature_mismatch++ + for (i = 1; i <= abort_end; i++) + if (abort_end_signatures[i] != nil_to_nil) signature_mismatch++ + for (i = 1; i <= rewind_begin; i++) + if (rewind_begin_signatures[i] != pointer_to_nil) signature_mismatch++ + for (i = 1; i <= rewind_end; i++) + if (rewind_end_signatures[i] != nil_to_nil) signature_mismatch++ + for (i = 1; i <= state; i++) + if (state_signatures[i] != "() -> i32") signature_mismatch++ + for (i = 1; i <= unwind_begin; i++) + if (unwind_begin_signatures[i] != pointer_to_nil) signature_mismatch++ + 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", relocatable + 0, imports_fork + 0, + frame_reserve + 0, frame_commit + 0, frame_next + 0, + linked_descriptor + 0, + abort_begin + 0, abort_end + 0, + rewind_begin + 0, rewind_end + 0, state + 0, unwind_begin + 0, unwind_end + 0, - rewind_begin + 0, rewind_end + 0, state + 0 + memory_count + 0, memory64_count + 0, signature_mismatch + 0 } ' wasm-objdump -x "$path" } +_wasm_linked_frame_descriptor_hex() { + local path="${1:-}" + wasm_is_binary "$path" || return 2 + command -v wasm-objdump >/dev/null 2>&1 || return 2 + + # `wasm-objdump -x` reports a custom section's name but not its payload. + # Read only this 24-byte section in a second targeted pass instead of + # materializing another full detail dump for large programs. + _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.linked_frames "$path" +} + +# Print 4 or 8 for one strict version-1 descriptor. Any malformed field is a +# failure: a section name alone is not proof that host and artifact agree on +# transactional node layout. +wasm_linked_frame_descriptor_pointer_width() { + local path="${1:-}" + local section_hex descriptor_hex + section_hex="$(_wasm_linked_frame_descriptor_hex "$path")" || return $? + + # The raw custom-section payload begins with the one-byte length and + # 30-byte UTF-8 section name before the descriptor itself. + local name_prefix="1e6b616e64656c6f2e77706b5f666f726b2e6c696e6b65645f6672616d6573" + case "$section_hex" in + "$name_prefix"*) descriptor_hex="${section_hex#"$name_prefix"}" ;; + *) return 3 ;; + esac + [ "${#descriptor_hex}" -eq 48 ] || return 3 + [ "${descriptor_hex:0:8}" = "4b4c4346" ] || return 3 + [ "${descriptor_hex:8:4}" = "0100" ] || return 3 + [ "${descriptor_hex:12:4}" = "1800" ] || return 3 + [ "${descriptor_hex:18:2}" = "08" ] || return 3 + [ "${descriptor_hex:20:4}" = "0300" ] || return 3 + + case "${descriptor_hex:16:2}" in + 04) + [ "${descriptor_hex:24:8}" = "20000000" ] || return 3 + [ "${descriptor_hex:32:8}" = "18000000" ] || return 3 + printf '4\n' + ;; + 08) + [ "${descriptor_hex:24:8}" = "38000000" ] || return 3 + [ "${descriptor_hex:32:8}" = "20000000" ] || return 3 + printf '8\n' + ;; + *) + return 3 + ;; + esac +} + wasm_has_wpk_fork_export() { local path="${1:-}" local name="${2:-}" @@ -746,13 +908,24 @@ wasm_require_exports() { wasm_has_complete_fork_instrumentation() { local path="${1:-}" local inventory inventory_status=0 - local relocatable imports_fork unwind_begin unwind_end rewind_begin rewind_end state extra + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + 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 unwind_begin unwind_end \ - rewind_begin rewind_end state extra <<< "$inventory" + IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ + linked_descriptor 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 - [ "$unwind_begin$unwind_end$rewind_begin$rewind_end$state" = 11111 ] + [ "$frame_reserve$frame_commit$frame_next" = 111 ] || return 1 + [ "$linked_descriptor" = 1 ] || return 1 + [ "$abort_begin$abort_end$rewind_begin$rewind_end$state$unwind_begin$unwind_end" = 1111111 ] || + return 1 + [ "$memory_count" = 1 ] && [ "$signature_mismatch" = 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 + [ "$descriptor_pointer_width" = 4 ] && [ "$memory64_count" = 0 ] } wasm_is_relocatable_object() { @@ -790,23 +963,49 @@ wasm_memory_arch() { wasm_has_any_wpk_fork_export() { local path="${1:-}" local inventory inventory_status=0 - local relocatable imports_fork unwind_begin unwind_end rewind_begin rewind_end state extra + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + 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=$? + case "$inventory_status" in + 0) ;; + 1) return 1 ;; + *) 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 \ + 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 ] +} + +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 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=$? case "$inventory_status" in 0) ;; 1) return 1 ;; *) return 0 ;; # Decoder failure: classify as unsafe/present. esac - IFS=$'\t' read -r relocatable imports_fork unwind_begin unwind_end \ - rewind_begin rewind_end state extra <<< "$inventory" + IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ + linked_descriptor 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 - [ "$unwind_begin$unwind_end$rewind_begin$rewind_end$state" != 00000 ] + [ "$frame_reserve$frame_commit$frame_next" != 000 ] || + [ "$linked_descriptor" != 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 unwind_begin unwind_end rewind_begin rewind_end state extra + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + 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 if ! command -v wasm-objdump >/dev/null 2>&1; then @@ -818,15 +1017,35 @@ 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 unwind_begin unwind_end \ - rewind_begin rewind_end state extra <<< "$inventory" + IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ + linked_descriptor 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 - local exports="$unwind_begin$unwind_end$rewind_begin$rewind_end$state" - [ "$exports" = 11111 ] && return 1 - [ "$imports_fork" = 0 ] && [ "$exports" = 00000 ] && return 1 - return 0 + 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" = 1 ] || return 0 + 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 + if [ "$descriptor_pointer_width" = 8 ]; then + [ "$memory64_count" = 1 ] || return 0 + else + [ "$memory64_count" = 0 ] || return 0 + fi + + # No-seed instrumentation exports an inert runtime and descriptor without + # importing frame hooks. A real fork seed or any hook makes the complete + # three-import transaction mandatory. + if [ "$imports_fork" = 1 ] || [ "$frame_imports" != 000 ]; then + [ "$frame_imports" = 111 ] || return 0 + fi + return 1 } wasm_require_fork_instrumentation_if_needed() { @@ -843,15 +1062,18 @@ wasm_require_fork_instrumentation_if_needed() { fi local inventory inventory_status=0 - local relocatable imports_fork unwind_begin unwind_end rewind_begin rewind_end state extra + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + 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=$? if [ "$inventory_status" -ne 0 ]; then echo "ERROR: unable to inspect fork instrumentation: $path" >&2 echo " wasm-objdump failed with status $inventory_status." >&2 return 1 fi - IFS=$'\t' read -r relocatable imports_fork unwind_begin unwind_end \ - rewind_begin rewind_end state extra <<< "$inventory" + IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ + linked_descriptor 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 echo " wasm-objdump returned an invalid fork-contract inventory." >&2 @@ -859,19 +1081,86 @@ wasm_require_fork_instrumentation_if_needed() { fi [ "$relocatable" = 1 ] && return 0 - local exports="$unwind_begin$unwind_end$rewind_begin$rewind_end$state" - [ "$exports" = 11111 ] && return 0 - [ "$imports_fork" = 0 ] && [ "$exports" = 00000 ] && return 0 + 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 local missing=() - [ "$unwind_begin" = 1 ] || missing+=(wpk_fork_unwind_begin) - [ "$unwind_end" = 1 ] || missing+=(wpk_fork_unwind_end) - [ "$rewind_begin" = 1 ] || missing+=(wpk_fork_rewind_begin) - [ "$rewind_end" = 1 ] || missing+=(wpk_fork_rewind_end) - [ "$state" = 1 ] || missing+=(wpk_fork_state) - echo "ERROR: refusing wasm artifact with incomplete/missing fork instrumentation: $path" >&2 - printf ' missing: %s\n' "${missing[*]}" >&2 - echo " Binaries that import kernel.kernel_fork must be processed with scripts/run-wasm-fork-instrument.sh." >&2 + local duplicates=() + [ "$abort_begin" -ge 1 ] || missing+=(wpk_fork_abort_begin) + [ "$abort_end" -ge 1 ] || missing+=(wpk_fork_abort_end) + [ "$rewind_begin" -ge 1 ] || missing+=(wpk_fork_rewind_begin) + [ "$rewind_end" -ge 1 ] || missing+=(wpk_fork_rewind_end) + [ "$state" -ge 1 ] || missing+=(wpk_fork_state) + [ "$unwind_begin" -ge 1 ] || missing+=(wpk_fork_unwind_begin) + [ "$unwind_end" -ge 1 ] || missing+=(wpk_fork_unwind_end) + [ "$abort_begin" -le 1 ] || duplicates+=(wpk_fork_abort_begin) + [ "$abort_end" -le 1 ] || duplicates+=(wpk_fork_abort_end) + [ "$rewind_begin" -le 1 ] || duplicates+=(wpk_fork_rewind_begin) + [ "$rewind_end" -le 1 ] || duplicates+=(wpk_fork_rewind_end) + [ "$state" -le 1 ] || duplicates+=(wpk_fork_state) + [ "$unwind_begin" -le 1 ] || duplicates+=(wpk_fork_unwind_begin) + [ "$unwind_end" -le 1 ] || duplicates+=(wpk_fork_unwind_end) + + if [ "$imports_fork" = 1 ] || [ "$frame_imports" != 000 ]; then + [ "$frame_reserve" -ge 1 ] || missing+=(env.__wpk_fork_frame_reserve) + [ "$frame_commit" -ge 1 ] || missing+=(env.__wpk_fork_frame_commit) + [ "$frame_next" -ge 1 ] || missing+=(env.__wpk_fork_frame_next) + [ "$frame_reserve" -le 1 ] || duplicates+=(env.__wpk_fork_frame_reserve) + [ "$frame_commit" -le 1 ] || duplicates+=(env.__wpk_fork_frame_commit) + [ "$frame_next" -le 1 ] || duplicates+=(env.__wpk_fork_frame_next) + fi + + local descriptor_error="" + local descriptor_pointer_width="" + if [ "$linked_descriptor" = 0 ]; then + descriptor_error="missing kandelo.wpk_fork.linked_frames descriptor" + elif [ "$linked_descriptor" != 1 ]; then + descriptor_error="found $linked_descriptor kandelo.wpk_fork.linked_frames descriptors; expected exactly one" + elif ! descriptor_pointer_width="$(wasm_linked_frame_descriptor_pointer_width "$path")"; then + descriptor_error="kandelo.wpk_fork.linked_frames descriptor is malformed or unsupported" + fi + + local memory_error="" + if [ "$memory_count" != 1 ]; then + memory_error="ABI 42 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 + memory_width_mismatch=1 + elif [ "$descriptor_pointer_width" = 4 ] && [ "$memory64_count" != 0 ]; then + memory_width_mismatch=1 + fi + if [ "$memory_width_mismatch" = 1 ]; then + local memory_pointer_width=4 + local descriptor_article=a + [ "$memory64_count" = 0 ] || memory_pointer_width=8 + [ "$descriptor_pointer_width" != 8 ] || descriptor_article=an + # WHY: the host invokes continuation exports using the module memory's + # actual address type. A descriptor that claims another width would + # make an otherwise well-named artifact fail only after publication. + memory_error="descriptor declares ${descriptor_article} ${descriptor_pointer_width}-byte pointer but module memory uses ${memory_pointer_width}-byte addresses" + fi + fi + + local signature_error="" + [ "$signature_mismatch" = 0 ] || + signature_error="$signature_mismatch ABI 42 fork import/export signatures do not match module memory" + + if [ ${#missing[@]} -eq 0 ] && [ ${#duplicates[@]} -eq 0 ] && + [ -z "$descriptor_error" ] && [ -z "$memory_error" ] && + [ -z "$signature_error" ]; then + return 0 + fi + + echo "ERROR: refusing wasm artifact with incomplete ABI 42 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 "$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 return 1 } @@ -879,19 +1168,24 @@ wasm_require_no_fork_instrumentation() { local path="${1:-}" wasm_is_binary "$path" || return 0 local inventory inventory_status=0 - local relocatable imports_fork unwind_begin unwind_end rewind_begin rewind_end state extra + local relocatable imports_fork frame_reserve frame_commit frame_next linked_descriptor + 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=$? if [ "$inventory_status" -ne 0 ]; then echo "ERROR: unable to inspect fork instrumentation policy: $path" >&2 return 1 fi - IFS=$'\t' read -r relocatable imports_fork unwind_begin unwind_end \ - rewind_begin rewind_end state extra <<< "$inventory" + IFS=$'\t' read -r relocatable imports_fork frame_reserve frame_commit frame_next \ + linked_descriptor 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 return 1 fi - if [ "$unwind_begin$unwind_end$rewind_begin$rewind_end$state" != 00000 ]; then + if [ "$frame_reserve$frame_commit$frame_next" != 000 ] || + [ "$linked_descriptor" != 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 return 1 diff --git a/tests/package-system/bash-package.test.ts b/tests/package-system/bash-package.test.ts new file mode 100644 index 0000000000..ea38cd2b8b --- /dev/null +++ b/tests/package-system/bash-package.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(import.meta.dirname, "../.."); +const bashBuilder = join( + repoRoot, + "packages/registry/bash/build-bash.sh", +); + +describe("Bash package contract", () => { + it("builds the programmable-completion builtins required by Homebrew", () => { + const source = readFileSync(bashBuilder, "utf8"); + + expect(source).toContain("--enable-progcomp"); + expect(source).not.toContain("--disable-progcomp"); + }); +}); diff --git a/tests/package-system/build-input-import-closure.test.ts b/tests/package-system/build-input-import-closure.test.ts index f1e7d2e570..be3679fa14 100644 --- a/tests/package-system/build-input-import-closure.test.ts +++ b/tests/package-system/build-input-import-closure.test.ts @@ -92,6 +92,57 @@ describe("package build input import closure", () => { )).toBe(true); }); + it("keeps local WordPress setup aliases outside product VFS builds", () => { + for (const [packageName, buildScript] of [ + ["lamp", "packages/registry/lamp/build-lamp.sh"], + ["wordpress", "packages/registry/wordpress/build-wordpress.sh"], + ] as const) { + const executableLines = readFileSync(join(repoRoot, buildScript), "utf8") + .split(/\r?\n/) + .filter((line) => line.trim() !== "" && !line.trimStart().startsWith("#")); + expect(executableLines.join("\n")).not.toContain("setup.sh"); + expect(readFileSync( + join(repoRoot, "packages", "registry", packageName, "build.toml"), + "utf8", + )).not.toContain("packages/registry/wordpress/setup.sh"); + } + + for (const imageBuilder of [ + "images/vfs/scripts/build-lamp-vfs-image.ts", + "images/vfs/scripts/build-wp-vfs-image.ts", + ]) { + const source = readFileSync(join(repoRoot, imageBuilder), "utf8"); + expect(source).toContain("resolveWordPressCoreSource(REPO_ROOT)"); + expect(source).toContain("copyWordPressCoreSource(fs, WP_DIR)"); + } + + const sqliteImageBuilder = readFileSync( + join(repoRoot, "images/vfs/scripts/build-wp-vfs-image.ts"), + "utf8", + ); + expect(sqliteImageBuilder).toContain( + "resolveWordPressSqlitePluginSource()", + ); + expect(sqliteImageBuilder).toContain( + "materializeWordPressSqlitePlugin(fs, SQLITE_DIR)", + ); + + 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"', + ); + } + }); + for (const packageName of packages) { it(`${packageName} declares every repository-local relative import`, () => { const buildTomlPath = join( diff --git a/tests/package-system/homebrew-bootstrap-package.test.ts b/tests/package-system/homebrew-bootstrap-package.test.ts new file mode 100644 index 0000000000..225e45d5db --- /dev/null +++ b/tests/package-system/homebrew-bootstrap-package.test.ts @@ -0,0 +1,221 @@ +import { createHash } from "node:crypto"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + loadHomebrewBootstrapSourceLock, + verifyHomebrewBootstrapSourceLock, +} from "../../scripts/verify-homebrew-bootstrap-source-lock.mjs"; + +const repoRoot = resolve(import.meta.dirname, "../.."); +const packageDir = join(repoRoot, "packages/registry/homebrew-bootstrap"); +const lockPath = join(repoRoot, "homebrew/homebrew-bootstrap-source-lock.json"); +const projectionPath = join(repoRoot, "packages/registry/program-packages.json"); +const temporaryRoots: string[] = []; + +function temporaryRoot(): string { + const root = mkdtempSync(join(tmpdir(), "kandelo-homebrew-bootstrap-package.")); + temporaryRoots.push(root); + return root; +} + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function parseMultilineStringArray(source: string, field: string): string[] { + const match = source.match(new RegExp(`^${field} = \\[\\n([\\s\\S]*?)^\\]$`, "m")); + if (!match) throw new Error(`missing multiline ${field} array`); + return [...match[1].matchAll(/^\s*"([^"]+)",$/gm)].map((entry) => entry[1]); +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("homebrew-bootstrap package contract", () => { + it("pins one exact portable recipe, output, license boundary, and sealed Git input", () => { + const manifest = readFileSync(join(packageDir, "package.toml"), "utf8"); + const build = readFileSync(join(packageDir, "build.toml"), "utf8"); + const lock = loadHomebrewBootstrapSourceLock(lockPath); + + expect(manifest).toContain('kind = "program"'); + expect(manifest).toContain(`name = "${lock.package.name}"`); + expect(manifest).toContain(`version = "${lock.package.version}"`); + expect(manifest).toContain(`url = "${lock.source.archive_url}"`); + expect(manifest).toContain(`sha256 = "${lock.source.archive_sha256}"`); + expect(manifest).toContain(`spdx = "${lock.license.expression}"`); + expect(manifest).toContain('name = "homebrew-bootstrap"\nwasm = "homebrew-bootstrap.zip"'); + expect(manifest).toContain('fork_instrumentation = "disabled"'); + + expect(build).toContain('name = "homebrew_brew"'); + expect(build).toContain(`repository = "${lock.source.repository}"`); + expect(build).toContain(`commit = "${lock.source.revision}"`); + expect(build).toContain('commit = "UNPUBLISHED"'); + + const patch = readFileSync(join(repoRoot, lock.patch.path)); + expect(sha256(patch)).toBe(lock.patch.sha256); + const licenseEvidence = readFileSync( + join(repoRoot, lock.license.kandelo_patch.evidence_path), + ); + expect(sha256(licenseEvidence)).toBe(lock.license.kandelo_patch.evidence_sha256); + expect(lock.license.upstream.spdx).toBe("BSD-2-Clause"); + expect(lock.license.kandelo_patch.spdx).toBe("GPL-2.0-or-later"); + }); + + it("declares every byte-producing local file and external commit as cache-key input", () => { + const build = readFileSync(join(packageDir, "build.toml"), "utf8"); + expect(parseMultilineStringArray(build, "inputs")).toEqual([ + "packages/registry/homebrew-bootstrap/build-homebrew-bootstrap.sh", + "scripts/package-build-roots.sh", + "scripts/prepare-homebrew-bootstrap-source.sh", + "scripts/verify-homebrew-bootstrap-source-lock.mjs", + "homebrew/homebrew-bootstrap-source-lock.json", + "homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch", + "homebrew/patches/README.md", + ]); + expect(build.match(/\[\[git_inputs\]\]/g)).toHaveLength(1); + expect(build).toMatch( + /\[\[git_inputs\]\]\nname = "homebrew_brew"\nrepository = "https:\/\/github\.com\/Homebrew\/brew\.git"\ncommit = "[0-9a-f]{40}"/, + ); + }); + + it("projects the non-Wasm package output through the ordinary resolver policy", () => { + const projection = JSON.parse(readFileSync(projectionPath, "utf8")); + expect(projection.format).toBe("kandelo-program-packages-v2"); + expect(projection.identities["homebrew-bootstrap"]).toBeDefined(); + expect(projection.packages["homebrew-bootstrap"]).toMatchObject({ + arches: ["wasm32"], + dependencyClosures: { wasm32: [] }, + members: [{ + kind: "output", + sourceArtifact: "homebrew-bootstrap.zip", + mirrorPath: "homebrew-bootstrap.zip", + outputName: "homebrew-bootstrap", + forkInstrumentation: "disabled", + }], + }); + }); + + it("rejects source, patch, prepared-tree, and output lock drift", () => { + const original = JSON.parse(readFileSync(lockPath, "utf8")); + const mutations: Array<[string, (lock: any) => void]> = [ + ["source archive digest", (lock) => { lock.source.archive_sha256 = "not-a-digest"; }], + ["patch path", (lock) => { lock.patch.path = "homebrew/patches/other.patch"; }], + ["license expression", (lock) => { lock.license.expression = "BSD-2-Clause"; }], + ["upstream license", (lock) => { lock.license.upstream.sha256 = "not-a-digest"; }], + ["patch license evidence", (lock) => { lock.license.kandelo_patch.evidence_path = "COPYING"; }], + ["patched tree", (lock) => { lock.prepared.patched_tree_git_oid = "not-an-oid"; }], + ["portable Ruby", (lock) => { lock.prepared.portable_ruby_version = "../ruby"; }], + ["Git version", (lock) => { lock.prepared.git_version = "latest"; }], + ["output byte count", (lock) => { lock.output.bytes = 0; }], + ]; + + for (const [label, mutate] of mutations) { + const root = temporaryRoot(); + const candidate = structuredClone(original); + mutate(candidate); + const candidatePath = join(root, "lock.json"); + writeJson(candidatePath, candidate); + expect( + () => loadHomebrewBootstrapSourceLock(candidatePath), + label, + ).toThrow(/homebrew-bootstrap source lock/); + } + }); + + it("verifies checkout runtime identity, prepared provenance, output bytes, and caller inputs", () => { + const root = temporaryRoot(); + const archive = Buffer.from("deterministic bootstrap fixture\n"); + const upstreamLicense = Buffer.from("BSD-2-Clause fixture\n"); + const patchLicenseEvidence = Buffer.from("GPL-2.0-or-later fixture\n"); + const lock = JSON.parse(readFileSync(lockPath, "utf8")); + lock.output.sha256 = sha256(archive); + lock.output.bytes = archive.byteLength; + lock.license.upstream.sha256 = sha256(upstreamLicense); + lock.license.upstream.bytes = upstreamLicense.byteLength; + lock.license.kandelo_patch.evidence_sha256 = sha256(patchLicenseEvidence); + + const candidateLockPath = join(root, "lock.json"); + const archivePath = join(root, "homebrew-bootstrap.zip"); + const provenancePath = join(root, "homebrew-source.json"); + const licenseEvidencePath = join(root, "patch-license.md"); + const checkout = join(root, "source"); + const portableRubyPath = join( + checkout, + "Library/Homebrew/vendor/portable-ruby-version", + ); + writeJson(candidateLockPath, lock); + mkdirSync(dirname(portableRubyPath), { recursive: true }); + writeFileSync(portableRubyPath, `${lock.prepared.portable_ruby_version}\n`); + writeFileSync(join(checkout, lock.license.upstream.path), upstreamLicense); + writeFileSync(licenseEvidencePath, patchLicenseEvidence); + writeFileSync(archivePath, archive); + writeJson(provenancePath, { + schema: 1, + homebrew_repository: lock.source.repository, + homebrew_revision: lock.source.revision, + homebrew_patch_sha256: lock.patch.sha256, + homebrew_patched_tree_git_oid: lock.prepared.patched_tree_git_oid, + homebrew_patched_tree_sha256: lock.prepared.patched_tree_sha256, + homebrew_archive_sha256: lock.output.sha256, + homebrew_bottle_arch: lock.package.arch, + homebrew_bottle_tag: `${lock.package.arch}_kandelo`, + }); + + const validated = loadHomebrewBootstrapSourceLock(candidateLockPath); + const options = new Map([ + ["package-name", lock.package.name], + ["package-version", lock.package.version], + ["target-arch", lock.package.arch], + ["source-url", lock.source.archive_url], + ["source-sha256", lock.source.archive_sha256], + ["git-commit", lock.source.revision], + ["git-version", lock.prepared.git_version], + ["patch-path", lock.patch.path], + ["license-evidence", licenseEvidencePath], + ["source-checkout", checkout], + ["provenance", provenancePath], + ["archive", archivePath], + ]); + expect(() => verifyHomebrewBootstrapSourceLock(validated, options)).not.toThrow(); + + const wrongGit = new Map(options); + wrongGit.set("git-version", "0.0.0"); + expect(() => verifyHomebrewBootstrapSourceLock(validated, wrongGit)).toThrow( + /git-version mismatch/, + ); + + writeFileSync(archivePath, "changed\n"); + expect(() => verifyHomebrewBootstrapSourceLock(validated, options)).toThrow( + /output archive has .* bytes|output archive SHA-256/, + ); + writeFileSync(archivePath, archive); + + writeFileSync(portableRubyPath, "0.0.0\n"); + expect(() => verifyHomebrewBootstrapSourceLock(validated, options)).toThrow( + /portable Ruby version/, + ); + writeFileSync(portableRubyPath, `${lock.prepared.portable_ruby_version}\n`); + + writeFileSync(licenseEvidencePath, "changed\n"); + expect(() => verifyHomebrewBootstrapSourceLock(validated, options)).toThrow( + /patch license evidence SHA-256/, + ); + }); +}); diff --git a/tests/package-system/wasm-artifact-guards.test.ts b/tests/package-system/wasm-artifact-guards.test.ts index 1b13ee8b30..c9593464f2 100644 --- a/tests/package-system/wasm-artifact-guards.test.ts +++ b/tests/package-system/wasm-artifact-guards.test.ts @@ -451,7 +451,13 @@ describe("wasm artifact ABI guards", () => { expect(foldedStale.status, foldedStale.stderr).toBe(0); }); - it("extracts only a constant ABI through the primary and fallback paths", () => { + it( + "extracts only a constant ABI through the primary and fallback paths", + // WHY: this is an integration probe that invokes Wabt repeatedly and also + // exercises the large-artifact streaming path. Its cold-tool cost is not + // bounded by Vitest's unit-test default, especially on shared CI runners. + { timeout: 30_000 }, + () => { const output = execFileSync( "bash", [path.resolve(repoRoot, "scripts", "test-wasm-artifact-guards.sh")], @@ -459,5 +465,6 @@ describe("wasm artifact ABI guards", () => { ); expect(output).toContain("test-wasm-artifact-guards.sh: ok"); - }); + }, + ); }); diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index ab271243ea..0988c5d057 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -4280,13 +4280,6 @@ fn rewrite_dir(dir: &Path, needle: &str, replacement: &str) -> Result<(), String } const WASM_MAGIC: &[u8; 4] = b"\0asm"; -const WPK_FORK_EXPORTS: [&str; 5] = [ - "wpk_fork_unwind_begin", - "wpk_fork_unwind_end", - "wpk_fork_rewind_begin", - "wpk_fork_rewind_end", - "wpk_fork_state", -]; const EXECUTABLE_PROGRAM_REQUIRED_EXPORTS: [&str; 2] = ["__abi_version", "_start"]; fn bytes_contain(haystack: &[u8], needle: &[u8]) -> bool { @@ -4301,47 +4294,138 @@ fn is_wasm_bytes(bytes: &[u8]) -> bool { struct WasmArtifactFacts { imports_kernel_fork: bool, exports: BTreeSet, + function_imports: BTreeMap<(String, String), Vec>, + function_exports: BTreeMap>, + memory_pointer_widths: Vec, + linked_frame_descriptors: Vec>, is_relocatable_object: bool, } +fn record_wasm_function_import( + module: &str, + name: &str, + ty: wasmparser::TypeRef, + function_type_indices: &mut Vec, + function_import_type_indices: &mut BTreeMap<(String, String), Vec>, + imports_kernel_fork: &mut bool, +) { + let type_index = match ty { + wasmparser::TypeRef::Func(type_index) | wasmparser::TypeRef::FuncExact(type_index) => { + type_index + } + _ => return, + }; + function_type_indices.push(type_index); + function_import_type_indices + .entry((module.to_string(), name.to_string())) + .or_default() + .push(type_index); + if module == "kernel" && name == "kernel_fork" { + *imports_kernel_fork = true; + } +} + +fn record_wasm_memory(memory: wasmparser::MemoryType, pointer_widths: &mut Vec) { + pointer_widths.push(if memory.memory64 { 8 } else { 4 }); +} + fn wasm_artifact_facts(bytes: &[u8]) -> Result { - use wasmparser::{Imports, Parser, Payload}; + use wasmparser::{CompositeInnerType, ExternalKind, FuncType, Imports, Parser, Payload}; let mut facts = WasmArtifactFacts::default(); + let mut func_types: Vec = Vec::new(); + let mut function_type_indices: Vec = Vec::new(); + let mut function_import_type_indices: BTreeMap<(String, String), Vec> = BTreeMap::new(); + let mut function_exports: Vec<(String, u32)> = Vec::new(); + for payload in Parser::new(0).parse_all(bytes) { match payload.map_err(|e| format!("parse wasm: {e}"))? { + Payload::TypeSection(r) => { + for rec in r { + let rec = rec.map_err(|e| format!("type section: {e}"))?; + for subtype in rec.types() { + match &subtype.composite_type.inner { + CompositeInnerType::Func(function) => func_types.push(function.clone()), + // Preserve type-index arithmetic for GC types even + // though they cannot satisfy a function contract. + _ => func_types.push(FuncType::new([], [])), + } + } + } + } Payload::ImportSection(r) => { for group in r { let group = group.map_err(|e| format!("import section: {e}"))?; match group { Imports::Single(_, imp) => { - if imp.module == "kernel" && imp.name == "kernel_fork" { - facts.imports_kernel_fork = true; + if let wasmparser::TypeRef::Memory(memory) = imp.ty { + record_wasm_memory(memory, &mut facts.memory_pointer_widths); } + record_wasm_function_import( + imp.module, + imp.name, + imp.ty, + &mut function_type_indices, + &mut function_import_type_indices, + &mut facts.imports_kernel_fork, + ); } Imports::Compact1 { module, items } => { for item in items { let item = item.map_err(|e| format!("import section: {e}"))?; - if module == "kernel" && item.name == "kernel_fork" { - facts.imports_kernel_fork = true; + if let wasmparser::TypeRef::Memory(memory) = item.ty { + record_wasm_memory(memory, &mut facts.memory_pointer_widths); } + record_wasm_function_import( + module, + item.name, + item.ty, + &mut function_type_indices, + &mut function_import_type_indices, + &mut facts.imports_kernel_fork, + ); } } - Imports::Compact2 { module, names, .. } => { + Imports::Compact2 { module, names, ty } => { for name in names { let name = name.map_err(|e| format!("import section: {e}"))?; - if module == "kernel" && name == "kernel_fork" { - facts.imports_kernel_fork = true; + if let wasmparser::TypeRef::Memory(memory) = ty { + record_wasm_memory(memory, &mut facts.memory_pointer_widths); } + record_wasm_function_import( + module, + name, + ty, + &mut function_type_indices, + &mut function_import_type_indices, + &mut facts.imports_kernel_fork, + ); } } } } } + Payload::MemorySection(r) => { + for memory in r { + record_wasm_memory( + memory.map_err(|e| format!("memory section: {e}"))?, + &mut facts.memory_pointer_widths, + ); + } + } + Payload::FunctionSection(r) => { + for type_index in r { + function_type_indices + .push(type_index.map_err(|e| format!("function section: {e}"))?); + } + } Payload::ExportSection(r) => { for export in r { let export = export.map_err(|e| format!("export section: {e}"))?; - facts.exports.insert(export.name.to_string()); + if matches!(export.kind, ExternalKind::Func | ExternalKind::FuncExact) { + facts.exports.insert(export.name.to_string()); + function_exports.push((export.name.to_string(), export.index)); + } } } Payload::CustomSection(c) => { @@ -4349,13 +4433,163 @@ fn wasm_artifact_facts(bytes: &[u8]) -> Result { if name == "linking" || name.starts_with("reloc.") { facts.is_relocatable_object = true; } + if name == wasm_posix_shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION { + facts.linked_frame_descriptors.push(c.data().to_vec()); + } } _ => {} } } + + for (identity, type_indices) in function_import_type_indices { + let mut signatures = Vec::with_capacity(type_indices.len()); + for type_index in type_indices { + signatures.push( + func_types + .get(type_index as usize) + .ok_or_else(|| { + format!( + "function import {}.{} has invalid type index {type_index}", + identity.0, identity.1 + ) + })? + .clone(), + ); + } + facts.function_imports.insert(identity, signatures); + } + + for (name, function_index) in function_exports { + let type_index = function_type_indices + .get(function_index as usize) + .ok_or_else(|| format!("function export {name} has invalid index {function_index}"))?; + let signature = func_types + .get(*type_index as usize) + .ok_or_else(|| format!("function export {name} has invalid type index {type_index}"))? + .clone(); + facts + .function_exports + .entry(name) + .or_default() + .push(signature); + } Ok(facts) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct LinkedFrameDescriptorFacts { + pointer_width: u8, +} + +fn validate_linked_frame_descriptor( + descriptor: &[u8], +) -> Result { + use wasm_posix_shared::abi; + + if descriptor.len() != abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE as usize { + return Err(format!( + "linked-frame descriptor has {} bytes, expected {}", + descriptor.len(), + abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE + )); + } + if descriptor[0..4] != abi::WPK_FORK_LINKED_FRAME_FORMAT_MAGIC { + return Err("linked-frame descriptor has invalid magic".to_string()); + } + let version = u16::from_le_bytes([descriptor[4], descriptor[5]]); + if version != abi::WPK_FORK_LINKED_FRAME_FORMAT_VERSION { + return Err(format!( + "linked-frame descriptor version {version} is unsupported" + )); + } + let declared_size = u16::from_le_bytes([descriptor[6], descriptor[7]]); + if declared_size != abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE { + return Err(format!( + "linked-frame descriptor declares size {declared_size}, expected {}", + abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE + )); + } + let pointer_width = descriptor[8]; + if !abi::WPK_FORK_LINKED_FRAME_POINTER_WIDTHS.contains(&pointer_width) { + return Err(format!( + "linked-frame descriptor pointer width {pointer_width} is unsupported" + )); + } + if descriptor[9] != abi::WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT { + return Err(format!( + "linked-frame descriptor alignment {} is unsupported", + descriptor[9] + )); + } + let flags = u16::from_le_bytes([descriptor[10], descriptor[11]]); + if flags != abi::WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS { + return Err(format!( + "linked-frame descriptor flags 0x{flags:04x} do not equal required flags 0x{:04x}", + abi::WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS + )); + } + let chunk_header_size = u32::from_le_bytes(descriptor[12..16].try_into().unwrap()); + let node_header_size = u32::from_le_bytes(descriptor[16..20].try_into().unwrap()); + if chunk_header_size != abi::wpk_fork_linked_chunk_header_size(pointer_width).unwrap() + || node_header_size != abi::wpk_fork_linked_node_header_size(pointer_width).unwrap() + { + return Err(format!( + "linked-frame descriptor header sizes do not match its {pointer_width}-byte pointer width" + )); + } + + Ok(LinkedFrameDescriptorFacts { pointer_width }) +} + +fn program_artifact_signature_matches( + actual: &wasmparser::FuncType, + params: &[wasm_posix_shared::abi::ProgramArtifactValueType], + results: &[wasm_posix_shared::abi::ProgramArtifactValueType], + pointer_width: u8, +) -> bool { + use wasm_posix_shared::abi::ProgramArtifactValueType; + use wasmparser::ValType; + + let value_matches = |actual: &ValType, expected: &ProgramArtifactValueType| match expected { + ProgramArtifactValueType::Pointer => match pointer_width { + 4 => *actual == ValType::I32, + 8 => *actual == ValType::I64, + _ => false, + }, + ProgramArtifactValueType::I32 => *actual == ValType::I32, + }; + + actual.params().len() == params.len() + && actual.results().len() == results.len() + && actual + .params() + .iter() + .zip(params) + .all(|(actual, expected)| value_matches(actual, expected)) + && actual + .results() + .iter() + .zip(results) + .all(|(actual, expected)| value_matches(actual, expected)) +} + +fn program_artifact_signature_text( + params: &[wasm_posix_shared::abi::ProgramArtifactValueType], + results: &[wasm_posix_shared::abi::ProgramArtifactValueType], + pointer_width: u8, +) -> String { + use wasm_posix_shared::abi::ProgramArtifactValueType; + + let value_name = |value: &ProgramArtifactValueType| match value { + ProgramArtifactValueType::Pointer if pointer_width == 8 => "i64", + ProgramArtifactValueType::Pointer => "i32", + ProgramArtifactValueType::I32 => "i32", + }; + let params = params.iter().map(value_name).collect::>().join(","); + let results = results.iter().map(value_name).collect::>().join(","); + format!("({params}) -> ({results})") +} + #[cfg(test)] fn wasm_artifact_policy_failures( bytes: &[u8], @@ -4405,34 +4639,201 @@ fn wasm_artifact_policy_failures_for( )); } - let wpk_present: Vec<&str> = WPK_FORK_EXPORTS + let fork_exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS; + let fork_imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS; + let present_fork_exports = fork_exports .iter() - .copied() - .filter(|name| facts.exports.contains(*name)) - .collect(); + .filter(|requirement| facts.function_exports.contains_key(requirement.name)) + .count(); + let present_fork_imports = fork_imports + .iter() + .filter(|requirement| { + facts + .function_imports + .contains_key(&(requirement.module.to_string(), requirement.name.to_string())) + }) + .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; + if fork_instrumentation == ForkInstrumentationPolicy::Disabled { - if !wpk_present.is_empty() { + if has_fork_artifact_surface { failures.push( - "has wasm-fork-instrument exports but this output disables fork instrumentation" - .to_string(), + "has ABI 42 wasm-fork-instrument metadata, imports, or exports but this output disables fork instrumentation".to_string(), ); } return failures; } - if !wpk_present.is_empty() && wpk_present.len() != WPK_FORK_EXPORTS.len() { - let missing = WPK_FORK_EXPORTS + + if !has_fork_artifact_surface && !facts.imports_kernel_fork { + return failures; + } + + let contract_failure_start = failures.len(); + let missing_exports = fork_exports + .iter() + .filter(|requirement| !facts.function_exports.contains_key(requirement.name)) + .map(|requirement| requirement.name) + .collect::>(); + if !missing_exports.is_empty() { + failures.push(format!( + "has incomplete ABI 42 wasm-fork-instrument exports; missing {}", + missing_exports.join(", ") + )); + } + for requirement in fork_exports { + if facts + .function_exports + .get(requirement.name) + .is_some_and(|signatures| signatures.len() != 1) + { + failures.push(format!( + "has duplicate ABI 42 wasm-fork-instrument export {}", + requirement.name + )); + } + } + + let descriptor = match facts.linked_frame_descriptors.as_slice() { + [] => { + failures.push(format!( + "is missing required {} descriptor", + wasm_posix_shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION + )); + None + } + [descriptor] => match validate_linked_frame_descriptor(descriptor) { + Ok(descriptor) => Some(descriptor), + Err(error) => { + failures.push(error); + None + } + }, + descriptors => { + failures.push(format!( + "has {} {} descriptors, expected exactly one", + descriptors.len(), + wasm_posix_shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION + )); + None + } + }; + + // A no-seed instrumenter invocation deliberately leaves frame hooks + // unimported so an inert side module remains instantiable. Once a module + // imports kernel.kernel_fork or any linked-frame hook, however, all three + // hooks are one transactional ABI and publication must reject partial + // instrumentation before an archive can enter a resolver index. + let requires_linked_frame_imports = facts.imports_kernel_fork || present_fork_imports > 0; + if requires_linked_frame_imports { + let missing_imports = fork_imports .iter() - .copied() - .filter(|name| !wpk_present.contains(name)) + .filter(|requirement| { + !facts + .function_imports + .contains_key(&(requirement.module.to_string(), requirement.name.to_string())) + }) + .map(|requirement| format!("{}.{}", requirement.module, requirement.name)) .collect::>() .join(", "); - failures.push(format!( - "has incomplete wasm-fork-instrument exports; missing {missing}" - )); + if !missing_imports.is_empty() { + failures.push(format!( + "has incomplete ABI 42 linked-frame imports; missing {missing_imports}" + )); + } + for requirement in fork_imports { + let identity = (requirement.module.to_string(), requirement.name.to_string()); + if facts + .function_imports + .get(&identity) + .is_some_and(|signatures| signatures.len() != 1) + { + failures.push(format!( + "has duplicate ABI 42 linked-frame import {}.{}", + requirement.module, requirement.name + )); + } + } } - if facts.imports_kernel_fork && wpk_present.len() != WPK_FORK_EXPORTS.len() { + + if let Some(descriptor) = descriptor { + match facts.memory_pointer_widths.as_slice() { + [pointer_width] if *pointer_width == descriptor.pointer_width => {} + [pointer_width] => { + let article = if descriptor.pointer_width == 8 { + "an" + } else { + "a" + }; + failures.push(format!( + "ABI 42 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 {}", + pointer_widths.len() + )), + } + + for requirement in fork_exports { + let Some([signature]) = facts + .function_exports + .get(requirement.name) + .map(Vec::as_slice) + else { + continue; + }; + if !program_artifact_signature_matches( + signature, + requirement.params, + requirement.results, + descriptor.pointer_width, + ) { + failures.push(format!( + "ABI 42 wasm-fork-instrument export {} has the wrong signature; expected {}", + requirement.name, + program_artifact_signature_text( + requirement.params, + requirement.results, + descriptor.pointer_width, + ) + )); + } + } + if requires_linked_frame_imports { + for requirement in fork_imports { + let identity = (requirement.module.to_string(), requirement.name.to_string()); + let Some([signature]) = facts.function_imports.get(&identity).map(Vec::as_slice) + else { + continue; + }; + if !program_artifact_signature_matches( + signature, + requirement.params, + requirement.results, + descriptor.pointer_width, + ) { + failures.push(format!( + "ABI 42 linked-frame import {}.{} has the wrong signature; expected {}", + requirement.module, + requirement.name, + program_artifact_signature_text( + requirement.params, + requirement.results, + descriptor.pointer_width, + ) + )); + } + } + } + } + + if facts.imports_kernel_fork && failures.len() != contract_failure_start { failures.push( - "imports kernel.kernel_fork without complete wasm-fork-instrument exports".to_string(), + "imports kernel.kernel_fork without the complete ABI 42 wasm-fork-instrument contract" + .to_string(), ); } failures @@ -9312,6 +9713,182 @@ wasm = "second.wasm" out } + fn wasm_custom_section(name: &str, data: &[u8]) -> Vec { + let mut payload = wasm_name(name); + payload.extend_from_slice(data); + wasm_section(0, payload) + } + + fn linked_frame_descriptor(pointer_width: u8) -> Vec { + use wasm_posix_shared::abi; + + let mut descriptor = vec![0; abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE as usize]; + descriptor[0..4].copy_from_slice(&abi::WPK_FORK_LINKED_FRAME_FORMAT_MAGIC); + descriptor[4..6].copy_from_slice(&abi::WPK_FORK_LINKED_FRAME_FORMAT_VERSION.to_le_bytes()); + descriptor[6..8].copy_from_slice(&abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE.to_le_bytes()); + descriptor[8] = pointer_width; + descriptor[9] = abi::WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT; + descriptor[10..12] + .copy_from_slice(&abi::WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS.to_le_bytes()); + descriptor[12..16].copy_from_slice( + &abi::wpk_fork_linked_chunk_header_size(pointer_width) + .expect("test pointer width must be supported") + .to_le_bytes(), + ); + descriptor[16..20].copy_from_slice( + &abi::wpk_fork_linked_node_header_size(pointer_width) + .expect("test pointer width must be supported") + .to_le_bytes(), + ); + descriptor[20..24].copy_from_slice(&16u32.to_le_bytes()); + descriptor + } + + fn wasm_function_type(params: &[u8], results: &[u8]) -> Vec { + let mut ty = vec![0x60]; + ty.extend(uleb(params.len() as u32)); + ty.extend_from_slice(params); + ty.extend(uleb(results.len() as u32)); + ty.extend_from_slice(results); + ty + } + + 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; + + 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 descriptor in descriptors { + bytes.extend(wasm_custom_section( + abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION, + descriptor, + )); + } + + let types = [ + wasm_function_type(&[], &[0x7f]), + wasm_function_type(&[pointer_type], &[pointer_type]), + wasm_function_type(&[pointer_type], &[]), + wasm_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 { + import_section.extend(wasm_name(module)); + import_section.extend(wasm_name(name)); + import_section.push(0x00); // function import + import_section.extend(uleb(*type_index)); + } + 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)); + } + bytes.extend(wasm_section(3, function_section)); + + let memory_flags = match memory_pointer_width { + 4 => 0x00, + 8 => 0x04, + other => panic!("unsupported fixture memory pointer width {other}"), + }; + 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 + .iter() + .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 { + export_section.extend(wasm_name(name)); + export_section.push(0x00); // function export + export_section.extend(uleb(imports.len() as u32 + *local_index)); + } + 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] + }; + code_section.extend(uleb(body.len() as u32)); + code_section.extend(body); + } + bytes.extend(wasm_section(10, code_section)); + bytes + } + + fn complete_wasm_fork_artifact(pointer_width: u8) -> Vec { + let imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + wasm_fork_artifact( + pointer_width, + pointer_width, + pointer_width, + true, + &imports, + &exports, + &[linked_frame_descriptor(pointer_width)], + ) + } + fn wasm_importing_kernel_fork(custom_sections: &[&str]) -> Vec { let mut bytes = b"\0asm\x01\0\0\0".to_vec(); for name in custom_sections { @@ -9361,10 +9938,7 @@ wasm = "second.wasm" } fn wasm_importing_kernel_fork_with_wpk_exports() -> Vec { - let mut names = Vec::new(); - names.extend(EXECUTABLE_PROGRAM_REQUIRED_EXPORTS); - names.extend(WPK_FORK_EXPORTS); - wasm_importing_kernel_fork_exporting_names(&names) + complete_wasm_fork_artifact(4) } fn minimal_executable_wasm() -> Vec { @@ -15419,6 +15993,255 @@ wasm = "bad.wasm" assert!(err.contains("wasm-fork-instrument"), "got: {err}"); } + #[test] + fn program_artifact_policy_accepts_complete_abi42_fork_contracts() { + for pointer_width in [4, 8] { + let bytes = complete_wasm_fork_artifact(pointer_width); + let failures = wasm_artifact_policy_failures_for( + &bytes, + ForkInstrumentationPolicy::Auto, + &EXECUTABLE_PROGRAM_REQUIRED_EXPORTS, + ); + assert!( + failures.is_empty(), + "wasm{} contract failed: {failures:?}", + pointer_width * 8 + ); + } + } + + #[test] + fn program_artifact_policy_accepts_complete_inert_instrumentation() { + let exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let bytes = + wasm_fork_artifact(4, 4, 4, false, &[], &exports, &[linked_frame_descriptor(4)]); + let failures = wasm_artifact_policy_failures_for( + &bytes, + ForkInstrumentationPolicy::Auto, + &EXECUTABLE_PROGRAM_REQUIRED_EXPORTS, + ); + assert!(failures.is_empty(), "got: {failures:?}"); + } + + #[test] + fn program_artifact_policy_rejects_each_missing_abi42_fork_import() { + let all_imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let all_exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + + for missing in &all_imports { + let imports = all_imports + .iter() + .copied() + .filter(|name| name != missing) + .collect::>(); + let bytes = wasm_fork_artifact( + 4, + 4, + 4, + true, + &imports, + &all_exports, + &[linked_frame_descriptor(4)], + ); + let failures = wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| failure.contains(missing)), + "missing {missing} was not reported: {failures:?}" + ); + } + } + + #[test] + fn program_artifact_policy_rejects_each_missing_abi42_fork_export() { + let all_imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let all_exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + + for missing in &all_exports { + let exports = all_exports + .iter() + .copied() + .filter(|name| name != missing) + .collect::>(); + let bytes = wasm_fork_artifact( + 4, + 4, + 4, + true, + &all_imports, + &exports, + &[linked_frame_descriptor(4)], + ); + let failures = wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| failure.contains(missing)), + "missing {missing} was not reported: {failures:?}" + ); + } + } + + #[test] + fn program_artifact_policy_rejects_missing_duplicate_and_malformed_descriptors() { + let imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let good = linked_frame_descriptor(4); + + let cases: Vec<(&str, Vec>)> = vec![ + ("missing", vec![]), + ("exactly one", vec![good.clone(), good.clone()]), + ("bytes, expected", vec![good[..23].to_vec()]), + ( + "invalid magic", + vec![{ + let mut descriptor = good.clone(); + descriptor[0] ^= 0xff; + descriptor + }], + ), + ( + "version", + vec![{ + let mut descriptor = good.clone(); + descriptor[4..6].copy_from_slice(&2u16.to_le_bytes()); + descriptor + }], + ), + ( + "declares size", + vec![{ + let mut descriptor = good.clone(); + descriptor[6..8].copy_from_slice(&23u16.to_le_bytes()); + descriptor + }], + ), + ( + "pointer width", + vec![{ + let mut descriptor = good.clone(); + descriptor[8] = 16; + descriptor + }], + ), + ( + "alignment", + vec![{ + let mut descriptor = good.clone(); + descriptor[9] = 4; + descriptor + }], + ), + ( + "flags", + vec![{ + let mut descriptor = good.clone(); + descriptor[10..12].copy_from_slice(&1u16.to_le_bytes()); + descriptor + }], + ), + ( + "header sizes", + vec![{ + let mut descriptor = good.clone(); + descriptor[12..16].copy_from_slice(&64u32.to_le_bytes()); + descriptor + }], + ), + ]; + + for (expected, descriptors) in cases { + let bytes = wasm_fork_artifact(4, 4, 4, true, &imports, &exports, &descriptors); + let failures = wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| failure.contains(expected)), + "descriptor 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 + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let bytes = wasm_fork_artifact( + 8, + 4, + 8, + true, + &imports, + &exports, + &[linked_frame_descriptor(8)], + ); + let failures = wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| { + failure.contains("wpk_fork_abort_begin") && failure.contains("expected (i64) -> ()") + }), + "got: {failures:?}" + ); + assert!( + failures.iter().any(|failure| { + failure.contains("__wpk_fork_frame_reserve") + && failure.contains("expected (i64) -> (i64)") + }), + "got: {failures:?}" + ); + } + + #[test] + fn program_artifact_policy_rejects_descriptor_memory_pointer_width_drift() { + let imports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_IMPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let exports = wasm_posix_shared::abi::WPK_FORK_REQUIRED_EXPORTS + .iter() + .map(|requirement| requirement.name) + .collect::>(); + let bytes = wasm_fork_artifact( + 8, + 8, + 4, + true, + &imports, + &exports, + &[linked_frame_descriptor(8)], + ); + let failures = wasm_artifact_policy_failures(&bytes, ForkInstrumentationPolicy::Auto); + assert!( + failures.iter().any(|failure| { + failure.contains("descriptor declares an 8-byte pointer") + && failure.contains("module memory uses 4-byte addresses") + }), + "got: {failures:?}" + ); + } + #[test] fn program_output_validation_rejects_kernel_missing_host_adapter_exports() { let out = tempdir("prog-out-kernel-export-policy"); diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 03218e80fe..3fcd7681d7 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -9,7 +9,7 @@ //! * [`wasm_posix_shared::channel`] — channel header byte layout //! * Marshalled repr(C) structs — offsets via `core::mem::offset_of!` //! * [`wasm_posix_shared::abi`] — expected process globals, export -//! deny-lists, custom-section name +//! deny-lists, custom-section names, and program-artifact fork contract //! * [`wasm_posix_shared::abi::HOST_ADAPTER_MANIFEST`] — kernel/host //! adapter boot contract metadata //! * [`wasm_posix_shared::host_abi`] — host adapter syscall marshalling @@ -213,6 +213,63 @@ fn render_ts_module() -> String { "export const ABI_KERNEL_EXPORT = {:?} as const;\n\n", shared::abi::ABI_KERNEL_EXPORT )); + out.push_str(&format!( + "export const WPK_FORK_LINKED_FRAME_FORMAT_SECTION = {:?} as const;\n", + shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION + )); + out.push_str(&format!( + "export const WPK_FORK_LINKED_FRAME_FORMAT_VERSION = {} as const;\n", + shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_VERSION + )); + out.push_str(&format!( + "export const WPK_FORK_LINKED_FRAME_FORMAT_MAGIC = {:?} as const;\n", + shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_MAGIC + )); + out.push_str(&format!( + "export const WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE = {} as const;\n", + shared::abi::WPK_FORK_LINKED_FRAME_DESCRIPTOR_SIZE + )); + out.push_str(&format!( + "export const WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT = {} as const;\n", + shared::abi::WPK_FORK_LINKED_FRAME_RECORD_ALIGNMENT + )); + out.push_str(&format!( + "export const WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS = {} as const;\n", + shared::abi::WPK_FORK_LINKED_FRAME_REQUIRED_FLAGS + )); + out.push_str("export const WPK_FORK_LINKED_FRAME_POINTER_WIDTHS = [\n"); + for pointer_width in shared::abi::WPK_FORK_LINKED_FRAME_POINTER_WIDTHS { + out.push_str(&format!( + " {{ bytes: {}, chunkHeaderSize: {}, nodeHeaderSize: {} }},\n", + pointer_width, + shared::abi::wpk_fork_linked_chunk_header_size(*pointer_width) + .expect("supported pointer width must have a chunk header"), + shared::abi::wpk_fork_linked_node_header_size(*pointer_width) + .expect("supported pointer width must have a node header"), + )); + } + out.push_str("] 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!( + " {{ module: {:?}, name: {:?}, params: {}, results: {} }},\n", + requirement.module, + requirement.name, + render_ts_program_artifact_types(requirement.params), + render_ts_program_artifact_types(requirement.results), + )); + } + 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!( + " {{ name: {:?}, params: {}, results: {} }},\n", + requirement.name, + render_ts_program_artifact_types(requirement.params), + render_ts_program_artifact_types(requirement.results), + )); + } + out.push_str("] as const;\n\n"); out.push_str(&format!( "export const SCHED_AFFINITY_MASK_SIZE = {} as const;\n\n", shared::SCHED_AFFINITY_MASK_SIZE @@ -614,6 +671,20 @@ fn render_ts_module() -> String { out } +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\"", + }) + .collect::>() + .join(", "); + format!("[{values}]") +} + fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { let mut s = format!( "{{ argIndex: {}, direction: {:?}, size: {}", @@ -805,6 +876,7 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { "process_expected_globals".into(), process_expected_globals(), ); + root.insert("program_artifact".into(), program_artifact()); root.insert("export_deny".into(), export_deny()); @@ -1800,7 +1872,12 @@ fn channel_status_codes() -> Value { } fn custom_sections() -> Value { - json!([shared::abi::ABI_CUSTOM_SECTION]) + let mut sections = vec![ + shared::abi::ABI_CUSTOM_SECTION, + shared::abi::WPK_FORK_LINKED_FRAME_FORMAT_SECTION, + ]; + sections.sort(); + Value::Array(sections.into_iter().map(Value::from).collect()) } fn process_expected_globals() -> Value { @@ -1809,6 +1886,135 @@ fn process_expected_globals() -> Value { Value::Array(list.into_iter().map(Value::from).collect()) } +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, + }; + + let value_types = |values: &[ProgramArtifactValueType]| { + Value::Array( + values + .iter() + .map(|value| { + Value::from(match value { + ProgramArtifactValueType::Pointer => "ptr", + ProgramArtifactValueType::I32 => "i32", + }) + }) + .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 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 mut fork: JsonMap = BTreeMap::new(); + fork.insert( + "linked_frame_descriptor".into(), + Value::Object(descriptor.into_iter().collect()), + ); + fork.insert("required_exports".into(), Value::Array(exports)); + fork.insert("required_imports".into(), Value::Array(imports)); + + let mut artifact: JsonMap = BTreeMap::new(); + artifact.insert( + "fork_instrumentation".into(), + Value::Object(fork.into_iter().collect()), + ); + Value::Object(artifact.into_iter().collect()) +} + fn export_deny() -> Value { let mut prefixes: Vec<&str> = shared::abi::EXPORT_DENY_PREFIXES.to_vec(); let mut exact: Vec<&str> = shared::abi::EXPORT_DENY_EXACT.to_vec(); @@ -2326,6 +2532,70 @@ mod tests { assert_eq!(names.as_object().unwrap().len(), 24); } + #[test] + fn program_artifact_snapshot_captures_complete_abi42_fork_contract() { + let artifact = program_artifact(); + let fork = &artifact["fork_instrumentation"]; + let descriptor = &fork["linked_frame_descriptor"]; + assert_eq!( + descriptor["section"], + json!("kandelo.wpk_fork.linked_frames") + ); + assert_eq!(descriptor["magic_bytes"], json!([75, 76, 67, 70])); + assert_eq!(descriptor["version"], json!(1)); + assert_eq!(descriptor["descriptor_size"], json!(24)); + assert_eq!(descriptor["required_flags"], json!(3)); + assert_eq!( + descriptor["pointer_widths"], + json!([ + {"bytes": 4, "chunk_header_size": 32, "node_header_size": 24}, + {"bytes": 8, "chunk_header_size": 56, "node_header_size": 32} + ]) + ); + + let imports = fork["required_imports"].as_array().unwrap(); + assert_eq!(imports.len(), 3); + assert_eq!( + imports[0], + json!({ + "kind": "func", + "module": "env", + "name": "__wpk_fork_frame_commit", + "params": ["ptr"], + "results": [] + }) + ); + + let exports = fork["required_exports"].as_array().unwrap(); + assert_eq!(exports.len(), 7); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("wpk_fork_abort_begin") + && entry["params"] == json!(["ptr"]) + && entry["results"] == json!([]) + })); + assert!(exports.iter().any(|entry| { + entry["name"] == json!("wpk_fork_state") + && entry["params"] == json!([]) + && entry["results"] == json!(["i32"]) + })); + + assert_eq!( + custom_sections(), + json!(["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;", + "name: \"__wpk_fork_frame_reserve\", params: [\"ptr\"], results: [\"ptr\"]", + "name: \"wpk_fork_abort_end\", params: [], results: []", + ] { + assert!( + rendered.contains(expected), + "missing generated TS: {expected}" + ); + } + } + #[test] fn generated_wait_abi_metadata_matches_shared_layouts() { let rendered = render_ts_module(); @@ -2409,7 +2679,7 @@ mod tests { }, "kernel_exports": [ {"name": "__abi_version", "kind": "func", "signature": "() -> (i32)"}, - {"name": "kernel_set_current_pid", "kind": "func", "signature": "(i32) -> ()"} + {"name": "kernel_existing_helper", "kind": "func", "signature": "(i32) -> ()"} ], "marshalled_structs": { "WasmStat": {"size": 96, "fields": []} @@ -2547,7 +2817,7 @@ mod tests { let report = classify_compat_change(&old, &new).unwrap(); assert_eq!( report.breaking, - vec!["changed kernel_exports entry \"kernel_set_current_pid\""] + vec!["changed kernel_exports entry \"kernel_existing_helper\""] ); } diff --git a/web-libs/kandelo-session/test/kandelo-session.test.ts b/web-libs/kandelo-session/test/kandelo-session.test.ts index 75ffcc6fb3..3a262adb9e 100644 --- a/web-libs/kandelo-session/test/kandelo-session.test.ts +++ b/web-libs/kandelo-session/test/kandelo-session.test.ts @@ -365,7 +365,7 @@ describe("LiveKernelHost: process events", () => { const offA = host.subscribeProcessEvents(a); host.subscribeProcessEvents(b); offA(); - host.emitProcessEvent({ kind: "spawn", pid: 1 }); + host.emitProcessEvent({ kind: "spawn", pid: 100 }); expect(a).not.toHaveBeenCalled(); expect(b).toHaveBeenCalledOnce(); }); @@ -691,9 +691,9 @@ describe("LiveKernelHost: process listing", () => { kernel: { fs, enumProcs: async () => [ - { pid: 1, ppid: 0, uid: 0, gid: 0, vsizeBytes: 1024, state: "S", comm: "dinit", cmdline: "/sbin/dinit" }, - { pid: 2, ppid: 1, uid: 33, gid: 33, vsizeBytes: 2048, state: "S", comm: "php-fpm", cmdline: "php-fpm: pool www" }, - { pid: 3, ppid: 1, uid: 4242, gid: 4242, vsizeBytes: 4096, state: "S", comm: "worker", cmdline: "worker" }, + { pid: 100, ppid: 0, uid: 0, gid: 0, vsizeBytes: 1024, state: "S", comm: "dinit", cmdline: "/sbin/dinit" }, + { pid: 101, ppid: 100, uid: 33, gid: 33, vsizeBytes: 2048, state: "S", comm: "php-fpm", cmdline: "php-fpm: pool www" }, + { pid: 102, ppid: 100, uid: 4242, gid: 4242, vsizeBytes: 4096, state: "S", comm: "worker", cmdline: "worker" }, ], } as any, }); @@ -820,7 +820,7 @@ describe("LiveKernelHost: shell command queue", () => { const host = new LiveKernelHost({ kernel: { fs: makeFs({ "/etc/passwd": "" }), - spawnFromVfs: async () => ({ pid: 1, exit: new Promise(() => {}) }), + spawnFromVfs: async () => ({ pid: 100, exit: new Promise(() => {}) }), onPtyOutput(_pid: number, callback: (data: Uint8Array) => void) { onOutput = callback; callback(encoder.encode("kandelo$ ")); @@ -868,7 +868,7 @@ describe("LiveKernelHost: shell command queue", () => { const host = new LiveKernelHost({ kernel: { fs: makeFs({ "/etc/passwd": "" }), - spawnFromVfs: async () => ({ pid: 1, exit: new Promise(() => {}) }), + spawnFromVfs: async () => ({ pid: 100, exit: new Promise(() => {}) }), onPtyOutput(_pid: number, callback: (data: Uint8Array) => void) { onOutput = callback; callback(encoder.encode("kandelo$ ")); @@ -915,7 +915,7 @@ describe("LiveKernelHost: shell command queue", () => { releaseSpawn = resolve; }); let spawnCalls = 0; - let nextPid = 1; + const allocatedPids = [100]; const host = new LiveKernelHost({ kernel: { @@ -923,7 +923,7 @@ describe("LiveKernelHost: shell command queue", () => { spawnFromVfs: async () => { spawnCalls++; await spawnGate; - const pid = nextPid++; + const pid = allocatedPids.shift()!; return { pid, exit: new Promise(() => {}) }; }, onPtyOutput(pid: number, callback: (data: Uint8Array) => void) { @@ -961,8 +961,8 @@ describe("LiveKernelHost: shell command queue", () => { visibleText += decoder.decode(bytes); }); expect(spawnCalls).toBe(1); - expect(writes).toEqual([{ pid: 1, text: "printf guide-visible\n" }]); - expect(visibleText).toContain("spawned:1"); + expect(writes).toEqual([{ pid: 100, text: "printf guide-visible\n" }]); + expect(visibleText).toContain("spawned:100"); expect(visibleText).toContain("printf guide-visible"); expect(visibleText).toContain("done"); }); @@ -973,21 +973,21 @@ describe("LiveKernelHost: shell command queue", () => { const callbacks = new Map void>(); const livePids = new Set(); const writes: number[] = []; - let nextPid = 1; + const allocatedPids = [101, 102]; const host = new LiveKernelHost({ kernel: { fs: makeFs({ "/etc/passwd": "" }), spawnFromVfs: async () => { - const pid = nextPid++; + const pid = allocatedPids.shift()!; livePids.add(pid); return { pid, exit: new Promise(() => {}) }; }, enumProcs: async () => [ - { pid: 99, ppid: 0, uid: 0, gid: 0, vsizeBytes: 1024, state: "S", comm: "dinit", cmdline: "dinit" }, + { pid: 100, ppid: 0, uid: 0, gid: 0, vsizeBytes: 1024, state: "S", comm: "dinit", cmdline: "dinit" }, ...Array.from(livePids).map((pid) => ({ pid, - ppid: 99, + ppid: 100, uid: 1000, gid: 1000, vsizeBytes: 1024, @@ -1020,18 +1020,18 @@ describe("LiveKernelHost: shell command queue", () => { firstHandle.onData((bytes) => { seen += decoder.decode(bytes); }); - expect(seen).toContain("spawned:1"); + expect(seen).toContain("spawned:101"); - livePids.delete(1); + livePids.delete(101); const secondHandle = await host.attachPty("/dev/pts/0", { cols: 80, rows: 24 }); secondHandle.write("echo second\n"); - expect(writes).toEqual([2]); - expect(seen).toContain("spawned:2"); - expect(seen).toContain("write:2:echo second"); + expect(writes).toEqual([102]); + expect(seen).toContain("spawned:102"); + expect(seen).toContain("write:102:echo second"); firstHandle.write("echo first\n"); - expect(writes).toEqual([2, 2]); - expect(seen).toContain("write:2:echo first"); + expect(writes).toEqual([102, 102]); + expect(seen).toContain("write:102:echo first"); }); it("keeps PTY listeners connected when an exited shell respawns", async () => { @@ -1040,13 +1040,13 @@ describe("LiveKernelHost: shell command queue", () => { const callbacks = new Map void>(); const exitResolvers = new Map void>(); const writes: number[] = []; - let nextPid = 1; + const allocatedPids = [100, 101]; const host = new LiveKernelHost({ kernel: { fs: makeFs({ "/etc/passwd": "" }), spawnFromVfs: async () => { - const pid = nextPid++; + const pid = allocatedPids.shift()!; const exit = new Promise((resolve) => { exitResolvers.set(pid, resolve); }); @@ -1076,21 +1076,21 @@ describe("LiveKernelHost: shell command queue", () => { firstHandle.onData((bytes) => { seen += decoder.decode(bytes); }); - expect(seen).toContain("spawned:1"); + expect(seen).toContain("spawned:100"); - exitResolvers.get(1)?.(0); + exitResolvers.get(100)?.(0); await Promise.resolve(); await Promise.resolve(); const secondHandle = await host.attachPty("/dev/pts/0", { cols: 80, rows: 24 }); secondHandle.write("echo after-exit\n"); - expect(writes).toEqual([2]); - expect(seen).toContain("spawned:2"); - expect(seen).toContain("write:2:echo after-exit"); + expect(writes).toEqual([101]); + expect(seen).toContain("spawned:101"); + expect(seen).toContain("write:101:echo after-exit"); firstHandle.write("echo old-handle\n"); - expect(writes).toEqual([2, 2]); - expect(seen).toContain("write:2:echo old-handle"); + expect(writes).toEqual([101, 101]); + expect(seen).toContain("write:101:echo old-handle"); }); });