Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion host/src/browser-kernel-worker-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -116,6 +117,7 @@ const pendingLazyRegistrationMessages: LazyRegistrationMessage[] = [];
let lazyRegistrationTail: Promise<void> = Promise.resolve();
const rootfsSnapshotGate = new RootfsSnapshotGate();
const processMemoryCreators = new ProcessMemoryCreatorGate();
const executableModuleCache = new ExecutableModuleCache();

// Process tracking
interface ForkReplayContext {
Expand Down Expand Up @@ -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;
Expand All @@ -237,6 +239,20 @@ async function resolveExecutableForLaunch(
return resolveExecutableForLaunch(shebang.interpreter, scriptArgv, depth + 1);
}

async function compileInitialExecutableModule(
bytes: ArrayBuffer,
): Promise<WebAssembly.Module | undefined> {
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<number, WebAssembly.Module>();
Expand Down Expand Up @@ -847,6 +863,7 @@ async function createFreshProcessMemory(

async function handleInit(msg: Extract<MainToKernelMessage, { type: "init" }>) {
initReady = false;
executableModuleCache.clear();
initFailure = null;
maxPages = msg.config.maxMemoryPages;
defaultThreadSlots = msg.config.defaultThreadSlots ?? DEFAULT_PROCESS_THREAD_SLOTS;
Expand Down Expand Up @@ -1157,6 +1174,7 @@ async function handleSpawn(msg: Extract<MainToKernelMessage, { type: "spawn" }>)
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,
Expand Down Expand Up @@ -1215,6 +1233,7 @@ async function handleSpawn(msg: Extract<MainToKernelMessage, { type: "spawn" }>)
type: "centralized_init",
pid,
programBytes,
programModule,
memory,
channelOffset,
env: launchEnv,
Expand All @@ -1236,6 +1255,7 @@ async function handleSpawn(msg: Extract<MainToKernelMessage, { type: "spawn" }>)
memoryRetirementSafe: true,
framebufferExposed: false,
programBytes,
programModule,
worker,
argv: msg.argv,
channelOffset,
Expand Down Expand Up @@ -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();
Expand Down
155 changes: 155 additions & 0 deletions host/src/executable-module-cache.ts
Original file line number Diff line number Diff line change
@@ -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<T> = (bytes: ArrayBuffer) => Promise<T>;
type DigestBytes = (bytes: ArrayBuffer) => Promise<string>;

export interface ExecutableModuleCacheOptions<T> {
maxEntries?: number;
maxSourceBytes?: number;
compile?: CompileModule<T>;
digest?: DigestBytes;
}

interface CacheEntry<T> {
module: Promise<T>;
sourceBytes: number;
}

async function sha256Hex(bytes: ArrayBuffer): Promise<string> {
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<T = WebAssembly.Module> {
readonly maxEntries: number;
readonly maxSourceBytes: number;

private readonly compile: CompileModule<T>;
private readonly digest: DigestBytes;
private readonly entries = new Map<string, CacheEntry<T>>();
private cachedSourceBytes = 0;

constructor(options: ExecutableModuleCacheOptions<T> = {}) {
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<T> {
// 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<T> = {
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<T>): void {
this.entries.delete(key);
this.entries.set(key, entry);
}

private remove(key: string, entry: CacheEntry<T>): 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<T>] | undefined;
if (!oldest) break;
this.remove(oldest[0], oldest[1]);
}
}
}
27 changes: 25 additions & 2 deletions host/src/node-kernel-worker-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -194,6 +195,7 @@ const vmInterruptTimers = new VmInterruptTimerManager<ProcessInfo>(
const reportedExits = new Set<number>();
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
Expand Down Expand Up @@ -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;
Expand All @@ -741,6 +743,20 @@ async function resolveExecutableForLaunch(
return resolveExecutableForLaunch(shebang.interpreter, scriptArgv, depth + 1);
}

async function compileInitialExecutableModule(
bytes: ArrayBuffer,
): Promise<WebAssembly.Module | undefined> {
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 ---

/**
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -2233,6 +2255,7 @@ async function performDestroy() {
processTeardowns.clear();
reportedExits.clear();
threadModuleCache.clear();
executableModuleCache.clear();
threadWorkers.clear();
ptyByPid.clear();
if (gracefulDetachComplete) {
Expand Down
Loading
Loading