diff --git a/docs/architecture.md b/docs/architecture.md index 20d0d13705..dc577738b8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -498,7 +498,22 @@ remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and 1. User calls `execve(path, argv, envp)` → kernel returns exec request to host 2. Host resolves `path` to a Wasm binary (via filesystem or program map) -3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries, with each string limited to one 64 KiB scratch transfer); oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross into the kernel one at a time, so the fixed host scratch allocation is never overrun and an empty environment explicitly clears the prior one. +3. The host compiles the replacement module, checks its ABI marker, and + preallocates its fresh `WebAssembly.Memory` before the irreversible + transition. Node and browser kernel workers share the same bounded compiled + executable cache. The key is the complete prepared-file snapshot's byte + length and SHA-256, not its path, inode timestamps, or size alone. Binary + aliases therefore reuse one `WebAssembly.Module`, while a write or + replacement with different bytes cannot execute the cached module. The + cache retains no source `ArrayBuffer`; each process Worker still receives + the current bytes. It keeps at most eight modules with 64 MiB of aggregate + source weight, and compiles a larger module without caching it. The host + 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 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 diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index ea6be65aed..87b7e8a7d0 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -90,6 +90,7 @@ import type { KernelToMainMessage, } from "./browser-kernel-protocol"; import { kernelRealmDestroyResult } from "./kernel-realm-destroy"; +import { ExecutableModuleCache } from "./executable-module-cache"; const PAGE_SIZE = 65536; // State @@ -116,6 +117,7 @@ const pendingLazyRegistrationMessages: LazyRegistrationMessage[] = []; let lazyRegistrationTail: Promise = Promise.resolve(); const rootfsSnapshotGate = new RootfsSnapshotGate(); const processMemoryCreators = new ProcessMemoryCreatorGate(); +const executableModuleCache = new ExecutableModuleCache(); // Process tracking interface ForkReplayContext { @@ -216,7 +218,7 @@ async function resolveExecutableForLaunch( if (!isWasmModuleBytes(bytes)) return { errno: ENOEXEC }; let programModule: WebAssembly.Module; try { - programModule = await WebAssembly.compile(bytes); + programModule = await executableModuleCache.getOrCompile(bytes); } catch (error) { if (error instanceof WebAssembly.CompileError) return { errno: ENOEXEC }; throw error; @@ -237,6 +239,20 @@ async function resolveExecutableForLaunch( return resolveExecutableForLaunch(shebang.interpreter, scriptArgv, depth + 1); } +async function compileInitialExecutableModule( + bytes: ArrayBuffer, +): Promise { + try { + return await executableModuleCache.getOrCompile(bytes); + } catch (error) { + // Keep malformed initial images on the established process-worker loader + // path so browser and Node hosts report the same trap. Exec and spawn + // preflight still translate this CompileError to ENOEXEC. + if (error instanceof WebAssembly.CompileError) return undefined; + throw error; + } +} + // Per-PID thread module cache: lazily compiled on first clone(), shared across // all threads of the same process. Keyed by PID of the process that spawned threads. const threadModuleCache = new Map(); @@ -847,6 +863,7 @@ async function createFreshProcessMemory( async function handleInit(msg: Extract) { initReady = false; + executableModuleCache.clear(); initFailure = null; maxPages = msg.config.maxMemoryPages; defaultThreadSlots = msg.config.defaultThreadSlots ?? DEFAULT_PROCESS_THREAD_SLOTS; @@ -1157,6 +1174,7 @@ async function handleSpawn(msg: Extract) respondError(msg.requestId, "ENOEXEC: program is not a WebAssembly module"); return; } + const programModule = await compileInitialExecutableModule(programBytes); const pid = kernelWorker.createProcess( msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, @@ -1215,6 +1233,7 @@ async function handleSpawn(msg: Extract) type: "centralized_init", pid, programBytes, + programModule, memory, channelOffset, env: launchEnv, @@ -1236,6 +1255,7 @@ async function handleSpawn(msg: Extract) memoryRetirementSafe: true, framebufferExposed: false, programBytes, + programModule, worker, argv: msg.argv, channelOffset, @@ -2882,6 +2902,7 @@ async function performDestroy() { let gracefulDetachComplete = processGenerationDetaches.pendingCount === 0 && processes.size === 0; threadModuleCache.clear(); + executableModuleCache.clear(); threadWorkers.clear(); threadedProcessPids.clear(); ptyByPid.clear(); diff --git a/host/src/executable-module-cache.ts b/host/src/executable-module-cache.ts new file mode 100644 index 0000000000..74a2f9b78f --- /dev/null +++ b/host/src/executable-module-cache.ts @@ -0,0 +1,155 @@ +// WHY: a package-manager launch burst commonly cycles through one +// interpreter, VCS transport, shell, utility, and install target set. The +// measured Kandelo/Homebrew set occupies about 54.4 MiB across eight Wasm +// modules, so these defaults retain that working set with bounded headroom. +// Source size is only the portable proxy available across engines; it does +// not claim to measure or cap engine-native compiled code. +export const DEFAULT_EXECUTABLE_MODULE_CACHE_ENTRIES = 8; +export const DEFAULT_EXECUTABLE_MODULE_CACHE_SOURCE_BYTES = 64 * 1024 * 1024; + +type CompileModule = (bytes: ArrayBuffer) => Promise; +type DigestBytes = (bytes: ArrayBuffer) => Promise; + +export interface ExecutableModuleCacheOptions { + maxEntries?: number; + maxSourceBytes?: number; + compile?: CompileModule; + digest?: DigestBytes; +} + +interface CacheEntry { + module: Promise; + sourceBytes: number; +} + +async function sha256Hex(bytes: ArrayBuffer): Promise { + const subtle = globalThis.crypto?.subtle; + if (!subtle) { + throw new Error( + "Executable module caching requires Web Crypto SHA-256 support", + ); + } + const digest = new Uint8Array(await subtle.digest("SHA-256", bytes)); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join( + "", + ); +} + +function requireCacheLimit(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} must be a non-negative safe integer`); + } + return value; +} + +/** + * Bounded cache for executable WebAssembly modules shared by Node and browser + * kernel workers. + * + * Callers still own and pass the exact current executable bytes to each + * process Worker. This cache retains only the compiled module and uses the + * complete byte snapshot's SHA-256 plus length as its identity. + */ +export class ExecutableModuleCache { + readonly maxEntries: number; + readonly maxSourceBytes: number; + + private readonly compile: CompileModule; + private readonly digest: DigestBytes; + private readonly entries = new Map>(); + private cachedSourceBytes = 0; + + constructor(options: ExecutableModuleCacheOptions = {}) { + this.maxEntries = requireCacheLimit( + options.maxEntries ?? DEFAULT_EXECUTABLE_MODULE_CACHE_ENTRIES, + "Executable module cache entry limit", + ); + this.maxSourceBytes = requireCacheLimit( + options.maxSourceBytes ?? DEFAULT_EXECUTABLE_MODULE_CACHE_SOURCE_BYTES, + "Executable module cache source-byte limit", + ); + this.compile = + options.compile ?? + (async (bytes) => (await WebAssembly.compile(bytes)) as T); + this.digest = options.digest ?? sha256Hex; + } + + get size(): number { + return this.entries.size; + } + + get sourceBytes(): number { + return this.cachedSourceBytes; + } + + clear(): void { + this.entries.clear(); + this.cachedSourceBytes = 0; + } + + async getOrCompile(bytes: ArrayBuffer): Promise { + // Skip both hashing and retention when caching is disabled or when one + // large executable would exceed the cache's declared weight by itself. + if (this.maxEntries === 0 || bytes.byteLength > this.maxSourceBytes) { + return await this.compile(bytes); + } + + // WHY: pathname, inode timestamps, and file size cannot prove immutable + // executable content. Aliases have different names, and a same-tick write + // can replace bytes without changing coarse metadata. Hash the prepared + // read snapshot so a hit always names the bytes being launched. + const digest = await this.digest(bytes); + const key = `${bytes.byteLength}:${digest}`; + const cached = this.entries.get(key); + if (cached) { + this.touch(key, cached); + return await cached.module; + } + + // Install the promise before compilation starts. Two spawn/exec requests + // that resolve the same bytes concurrently then await one compiler job. + const entry: CacheEntry = { + module: Promise.resolve().then(() => this.compile(bytes)), + sourceBytes: bytes.byteLength, + }; + this.entries.set(key, entry); + this.cachedSourceBytes += entry.sourceBytes; + this.evictToBounds(); + + try { + return await entry.module; + } catch (error) { + // A failed compiler promise must not poison later retries. Check object + // identity because this entry may have been evicted and replaced while + // its asynchronous compilation was still settling. + if (this.entries.get(key) === entry) this.remove(key, entry); + throw error; + } + } + + private touch(key: string, entry: CacheEntry): void { + this.entries.delete(key); + this.entries.set(key, entry); + } + + private remove(key: string, entry: CacheEntry): void { + if (!this.entries.delete(key)) return; + this.cachedSourceBytes -= entry.sourceBytes; + } + + private evictToBounds(): void { + // WebAssembly exposes no portable compiled-code byte count. Bound both the + // number of retained modules and the sum of their source byte sizes; the + // latter is the closest cross-engine weight available without retaining + // the source ArrayBuffers themselves. + while ( + this.entries.size > this.maxEntries || + this.cachedSourceBytes > this.maxSourceBytes + ) { + const oldest = this.entries.entries().next().value as + [string, CacheEntry] | undefined; + if (!oldest) break; + this.remove(oldest[0], oldest[1]); + } + } +} diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index a8275d278e..7c313f357d 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -106,6 +106,7 @@ import type { HttpRequestMessage, } from "./node-kernel-protocol"; import { kernelRealmDestroyResult } from "./kernel-realm-destroy"; +import { ExecutableModuleCache } from "./executable-module-cache"; if (!parentPort) { throw new Error("node-kernel-worker-entry must run in a worker_thread"); @@ -194,6 +195,7 @@ const vmInterruptTimers = new VmInterruptTimerManager( const reportedExits = new Set(); const rootfsSnapshotGate = new RootfsSnapshotGate(); const processMemoryCreators = new ProcessMemoryCreatorGate(); +const executableModuleCache = new ExecutableModuleCache(); // Workers terminated by the kernel-worker entry itself (handleExit / // handleExec / handleTerminate). The crash safety-net listener checks @@ -720,7 +722,7 @@ async function resolveExecutableForLaunch( if (!isWasmModuleBytes(bytes)) return { errno: ENOEXEC }; let programModule: WebAssembly.Module; try { - programModule = await WebAssembly.compile(bytes); + programModule = await executableModuleCache.getOrCompile(bytes); } catch (error) { if (error instanceof WebAssembly.CompileError) return { errno: ENOEXEC }; throw error; @@ -741,6 +743,20 @@ async function resolveExecutableForLaunch( return resolveExecutableForLaunch(shebang.interpreter, scriptArgv, depth + 1); } +async function compileInitialExecutableModule( + bytes: ArrayBuffer, +): Promise { + try { + return await executableModuleCache.getOrCompile(bytes); + } catch (error) { + // Preserve the initial-spawn failure contract: malformed Wasm reaches the + // process Worker, which reports the existing loader-trap diagnostic. Exec + // and posix_spawn reject that same CompileError during their preflight. + if (error instanceof WebAssembly.CompileError) return undefined; + throw error; + } +} + // --- Init --- /** @@ -840,6 +856,7 @@ function cleanupSessionDir(): void { async function handleInit(msg: InitMessage) { initReady = false; + executableModuleCache.clear(); maxPages = msg.config.maxPages ?? DEFAULT_MAX_PAGES; defaultThreadSlots = msg.config.defaultThreadSlots ?? DEFAULT_PROCESS_THREAD_SLOTS; processMemoryAllocator = new ProcessMemoryAllocator({ @@ -969,7 +986,6 @@ async function handleSpawn(msg: SpawnMessage) { } const programBytes = msg.programBytes ?? await readExecFromVfs(msg.programPath!); - const programModule = hasProgramBytes ? msg.programModule : undefined; if (programBytes === null) { respondError(msg.requestId, `ENOENT: ${msg.programPath}`); return; @@ -978,6 +994,12 @@ async function handleSpawn(msg: SpawnMessage) { respondError(msg.requestId, "ENOEXEC: program is not a WebAssembly module"); return; } + // Compile inside the kernel Worker so the resulting module crosses only + // the existing kernel-to-process boundary. This avoids the problematic + // main-to-kernel-to-process module clone while also seeding later execs of + // the same executable bytes. + const programModule = msg.programModule ?? + await compileInitialExecutableModule(programBytes); const pid = kernelWorker.createProcess( msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, @@ -2233,6 +2255,7 @@ async function performDestroy() { processTeardowns.clear(); reportedExits.clear(); threadModuleCache.clear(); + executableModuleCache.clear(); threadWorkers.clear(); ptyByPid.clear(); if (gracefulDetachComplete) { diff --git a/host/test/executable-module-cache.test.ts b/host/test/executable-module-cache.test.ts new file mode 100644 index 0000000000..d52e64f0f6 --- /dev/null +++ b/host/test/executable-module-cache.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_EXECUTABLE_MODULE_CACHE_ENTRIES, + DEFAULT_EXECUTABLE_MODULE_CACHE_SOURCE_BYTES, + ExecutableModuleCache, +} from "../src/executable-module-cache"; + +interface TestModule { + id: number; + source: number[]; +} + +function bytes(...values: number[]): ArrayBuffer { + return Uint8Array.from(values).buffer; +} + +function compiler() { + let calls = 0; + return { + get calls() { + return calls; + }, + compile: async (source: ArrayBuffer): Promise => ({ + id: ++calls, + source: [...new Uint8Array(source)], + }), + }; +} + +describe("ExecutableModuleCache", () => { + it("uses the bounded shared-runtime working-set defaults", () => { + const cache = new ExecutableModuleCache(); + + expect(DEFAULT_EXECUTABLE_MODULE_CACHE_ENTRIES).toBe(8); + expect(DEFAULT_EXECUTABLE_MODULE_CACHE_SOURCE_BYTES).toBe(64 * 1024 * 1024); + expect(cache.maxEntries).toBe(DEFAULT_EXECUTABLE_MODULE_CACHE_ENTRIES); + expect(cache.maxSourceBytes).toBe( + DEFAULT_EXECUTABLE_MODULE_CACHE_SOURCE_BYTES, + ); + }); + + it("reuses one module for aliases with identical executable bytes", async () => { + const compiled = compiler(); + const cache = new ExecutableModuleCache({ + compile: compiled.compile, + }); + const files = new Map([ + ["/bin/tool", bytes(0, 97, 115, 109, 1)], + ["/usr/bin/tool-alias", bytes(0, 97, 115, 109, 1)], + ]); + const resolve = (path: string) => cache.getOrCompile(files.get(path)!); + + const direct = await resolve("/bin/tool"); + const alias = await resolve("/usr/bin/tool-alias"); + + expect(alias).toBe(direct); + expect(compiled.calls).toBe(1); + }); + + it("does not reuse stale code after a same-length path replacement", async () => { + const compiled = compiler(); + const cache = new ExecutableModuleCache({ + compile: compiled.compile, + }); + const files = new Map([["/bin/tool", bytes(0, 1, 2, 3)]]); + const resolve = () => cache.getOrCompile(files.get("/bin/tool")!); + + const before = await resolve(); + // Model an in-place write that preserves path and length. Metadata-only + // cache keys could miss this replacement when timestamps are coarse. + files.set("/bin/tool", bytes(0, 1, 2, 4)); + const after = await resolve(); + + expect(after).not.toBe(before); + expect(after.source).toEqual([0, 1, 2, 4]); + expect(compiled.calls).toBe(2); + }); + + it("coalesces concurrent compilation of the same content", async () => { + let calls = 0; + const module = { id: 1, source: [7, 8, 9] }; + const cache = new ExecutableModuleCache({ + compile: async () => { + calls += 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + return module; + }, + }); + + const [first, second] = await Promise.all([ + cache.getOrCompile(bytes(7, 8, 9)), + cache.getOrCompile(bytes(7, 8, 9)), + ]); + + expect(first).toBe(module); + expect(second).toBe(module); + expect(calls).toBe(1); + }); + + it("evicts the least-recently-used module at the entry bound", async () => { + const compiled = compiler(); + const cache = new ExecutableModuleCache({ + maxEntries: 2, + maxSourceBytes: 100, + compile: compiled.compile, + }); + + const firstA = await cache.getOrCompile(bytes(1)); + const firstB = await cache.getOrCompile(bytes(2)); + expect(await cache.getOrCompile(bytes(1))).toBe(firstA); + await cache.getOrCompile(bytes(3)); + const secondB = await cache.getOrCompile(bytes(2)); + + expect(secondB).not.toBe(firstB); + expect(cache.size).toBe(2); + expect(cache.sourceBytes).toBe(2); + expect(compiled.calls).toBe(4); + }); + + it("bounds source weight and compiles oversized modules uncached", async () => { + const compiled = compiler(); + let digestCalls = 0; + const cache = new ExecutableModuleCache({ + maxEntries: 10, + maxSourceBytes: 3, + compile: compiled.compile, + digest: async (source) => { + digestCalls += 1; + return [...new Uint8Array(source)].join(","); + }, + }); + + await cache.getOrCompile(bytes(1, 1)); + await cache.getOrCompile(bytes(2, 2)); + expect(cache.size).toBe(1); + expect(cache.sourceBytes).toBe(2); + + const oversized = bytes(3, 3, 3, 3); + const first = await cache.getOrCompile(oversized); + const second = await cache.getOrCompile(oversized.slice(0)); + + expect(second).not.toBe(first); + expect(cache.size).toBe(1); + expect(cache.sourceBytes).toBe(2); + expect(compiled.calls).toBe(4); + expect(digestCalls).toBe(2); + }); + + it("removes failed compiler promises so a later launch can retry", async () => { + let calls = 0; + const cache = new ExecutableModuleCache({ + compile: async (source) => { + calls += 1; + if (calls === 1) throw new Error("compile failed"); + return { id: calls, source: [...new Uint8Array(source)] }; + }, + }); + + await expect(cache.getOrCompile(bytes(4, 5, 6))).rejects.toThrow( + "compile failed", + ); + await expect(cache.getOrCompile(bytes(4, 5, 6))).resolves.toEqual({ + id: 2, + source: [4, 5, 6], + }); + expect(cache.size).toBe(1); + }); + + it("rejects invalid cache bounds", () => { + expect(() => new ExecutableModuleCache({ maxEntries: -1 })).toThrow( + "entry limit", + ); + expect(() => new ExecutableModuleCache({ maxSourceBytes: 1.5 })).toThrow( + "source-byte limit", + ); + }); +}); diff --git a/host/test/node-rootfs-export.test.ts b/host/test/node-rootfs-export.test.ts index 3b687f908f..f68156f89f 100644 --- a/host/test/node-rootfs-export.test.ts +++ b/host/test/node-rootfs-export.test.ts @@ -1,10 +1,18 @@ -import { existsSync, readFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { tryResolveBinary } from "../src/binary-resolver"; +import type { HostDiagnostic } from "../src/host-diagnostic"; import { NodeKernelHost } from "../src/node-kernel-host"; import { MemoryFileSystem } from "../src/vfs/memory-fs"; @@ -16,6 +24,8 @@ const wasiHelloPath = join(here, "fixtures/wasi-hello.wasm"); const haveKernel = kernelPath !== null; const haveBlockForever = existsSync(blockForeverPath); const haveWasiHello = existsSync(wasiHelloPath); +const wasiHelloText = new TextEncoder().encode("Hello from WASI\n"); +const wasiReplacementText = new TextEncoder().encode("Fresh bytes OK!\n"); function asArrayBuffer(bytes: Uint8Array): ArrayBuffer { return bytes.buffer.slice( @@ -24,6 +34,31 @@ function asArrayBuffer(bytes: Uint8Array): ArrayBuffer { ) as ArrayBuffer; } +function replaceWasiHelloText(program: Uint8Array): Uint8Array { + const replacement = program.slice(); + let match = -1; + for ( + let offset = 0; + offset <= replacement.length - wasiHelloText.length; + offset += 1 + ) { + if ( + wasiHelloText.every( + (byte, index) => replacement[offset + index] === byte, + ) + ) { + if (match !== -1) throw new Error("WASI greeting appears more than once"); + match = offset; + } + } + if (match === -1) throw new Error("WASI greeting was not found"); + if (wasiReplacementText.byteLength !== wasiHelloText.byteLength) { + throw new Error("WASI replacement must preserve executable byte length"); + } + replacement.set(wasiReplacementText, match); + return replacement; +} + function writeFile( fs: MemoryFileSystem, path: string, @@ -118,6 +153,40 @@ describe("NodeKernelHost rootfs export contract", () => { }, ); + it.skipIf(!haveKernel)( + "keeps malformed initial Wasm on the process-worker loader path", + async () => { + // Valid magic/version followed by a truncated type section. The kernel + // Worker may precompile valid initial programs, but this CompileError + // must still reach the process Worker and preserve its loader diagnostic. + const malformed = Uint8Array.from([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x01, 0xff, + ]); + const diagnostics: HostDiagnostic[] = []; + const host = new NodeKernelHost({ + onHostDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + }); + try { + const kernel = new Uint8Array(readFileSync(kernelPath!)); + await host.init(asArrayBuffer(kernel)); + + await expect( + host.spawn(asArrayBuffer(malformed), ["malformed"]), + ).resolves.toBe(-1); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toMatchObject({ + source: "worker-main error message", + status: -1, + }); + expect(diagnostics[0].message).toContain("WebAssembly.compile"); + } finally { + await host.destroy(); + } + }, + ); + it.skipIf(!haveKernel)( "transfers exact bytes, preserves lazy descriptors, and reboots from the export", async () => { @@ -254,4 +323,50 @@ describe("NodeKernelHost rootfs export contract", () => { } }, ); + + it.skipIf(!haveKernel || !haveWasiHello)( + "executes replacement bytes when a mounted path keeps the same length", + async () => { + const mountDir = mkdtempSync(join(tmpdir(), "kandelo-module-cache-")); + const executablePath = join(mountDir, "wasi-tool"); + const original = new Uint8Array(readFileSync(wasiHelloPath)); + const replacement = replaceWasiHelloText(original); + expect(replacement.byteLength).toBe(original.byteLength); + writeFileSync(executablePath, original, { mode: 0o755 }); + + let stdout = ""; + const host = new NodeKernelHost({ + rootfsImage: await createRootfs(), + extraMounts: [{ mountPoint: "/tools", hostPath: mountDir }], + onStdout: (_pid, data) => { + stdout += new TextDecoder().decode(data); + }, + }); + try { + const kernel = new Uint8Array(readFileSync(kernelPath!)); + await host.init(asArrayBuffer(kernel)); + + const first = await host.spawnFromVfs( + "/tools/wasi-tool", + ["wasi-tool"], + ); + await expect(first.exit).resolves.toBe(0); + expect(stdout).toBe("Hello from WASI\n"); + + stdout = ""; + // Keep the inode, path, and length while changing executable content. + // The next launch must not reuse the module compiled from old bytes. + writeFileSync(executablePath, replacement, { mode: 0o755 }); + const second = await host.spawnFromVfs( + "/tools/wasi-tool", + ["wasi-tool"], + ); + await expect(second.exit).resolves.toBe(0); + expect(stdout).toBe("Fresh bytes OK!\n"); + } finally { + await host.destroy(); + rmSync(mountDir, { recursive: true, force: true }); + } + }, + ); });