Skip to content
Open
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
67 changes: 67 additions & 0 deletions apps/browser-demos/test/fork-child-thread.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, test } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { tryResolveBinary } from "../../../host/src/binary-resolver";

const __dirname = dirname(fileURLToPath(import.meta.url));
const browserKernelModulePath = resolve(
__dirname,
"../../../host/src/browser-kernel-host.ts",
);
const programPath = tryResolveBinary("programs/p_10_fork_child_creates_thread.wasm");

test("fork child creates and joins a pthread in the browser host", async ({ page, baseURL }) => {
test.setTimeout(120_000);
test.skip(!programPath, "P-10 fixture is not built");
expect(baseURL).toBeTruthy();

await page.goto(new URL("/trap-signal-test.html", baseURL).href);
expect(await page.evaluate(() => crossOriginIsolated)).toBe(true);

const result = await page.evaluate(
async ({ moduleUrl, programUrl }) => {
const { BrowserKernel } = await import(/* @vite-ignore */ moduleUrl);
const programResponse = await fetch(programUrl);
if (!programResponse.ok) {
throw new Error(`failed to fetch P-10 fixture: ${programResponse.status}`);
}

let stdout = "";
let stderr = "";
const decoder = new TextDecoder();
const kernel = new BrowserKernel({
onStdout: (data: Uint8Array) => { stdout += decoder.decode(data); },
onStderr: (data: Uint8Array) => { stderr += decoder.decode(data); },
});

try {
await kernel.initFromImage({ vfsImage: "default" });
const programBytes = await programResponse.arrayBuffer();
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() => reject(new Error("P-10 browser run timed out")),
30_000,
);
});
const exitCode = await Promise.race([
kernel.spawn(programBytes, ["p_10_fork_child_creates_thread"]),
timeout,
]).finally(() => clearTimeout(timeoutId));
return { exitCode, stdout, stderr };
} finally {
await kernel.destroy();
}
},
{
moduleUrl: new URL(`/@fs/${browserKernelModulePath}`, baseURL).href,
programUrl: new URL(`/@fs/${programPath}`, baseURL).href,
},
);

expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0);
expect(result.stdout).toContain("CHILD_THREAD: ok");
expect(result.stdout).toContain("CHILD: joined");
expect(result.stdout).toContain("PASS: P-10");
expect(result.stderr).toBe("");
});
13 changes: 7 additions & 6 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,12 +300,13 @@ fell back to fork.

### clone() (threads)

1. User calls `clone(CLONE_VM | CLONE_THREAD, ...)` → kernel returns clone request
2. Host asks the kernel to reserve one dynamic pthread control slot in the same process address space
3. Host grows the process `WebAssembly.Memory` only far enough to cover that slot
4. Host spawns a new worker that shares the parent's `WebAssembly.Memory`
5. Thread worker runs `centralizedThreadWorkerMain`, calls `__wasm_thread_init` to set up TLS
6. Thread starts executing the given function pointer with the given argument
1. User calls `clone(CLONE_VM | CLONE_THREAD, ...)`; the kernel provisionally allocates a TID.
2. Host asks the kernel to reserve one dynamic pthread control slot in the same process address space.
3. Host grows the process `WebAssembly.Memory` only far enough to cover that slot.
4. Host builds the thread module: it removes `__wasm_init_memory`'s start section and only neutralizes `__wasm_call_ctors` when an exact export or the preserved `__abi_version` linker wrapper identifies that function. Non-semantic name-section metadata is never sufficient. A C module with no constructors is not otherwise rewritten.
5. Host spawns a new worker that shares the parent's `WebAssembly.Memory`.
6. Thread worker runs `centralizedThreadWorkerMain`, initializes TLS, stack, and channel state, resolves the requested table entry, and reports `thread_ready` without executing it yet.
7. The kernel worker revalidates the process generation, commits clone's TID and `CLONE_PARENT_SETTID` result, then releases the worker to execute the function pointer. Any failure before publication aborts the initialized Worker and reclaims the provisional kernel thread, channel, control slot, and host registry entry. After publication, failure is ordinary thread exit and cannot turn into a second clone completion.

Threads share memory with the parent (CLONE_VM) but have their own channel, fork-save scratch page, and TLS/control page.

Expand Down
7 changes: 5 additions & 2 deletions docs/porting-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ const kernelWorker = new CentralizedKernelWorker(
// 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
// Allocate the thread channel and initialize a Worker. Resolve only
// after it reports thread_ready, then return { tid, start, abort }:
// start posts thread_start after clone success is published; abort
// terminates the unpublished Worker and reclaims its channel and slot.
// See host/src/node-kernel-worker-entry.ts for the full lifecycle.
},
onExit: (pid, status) => {
// Handle process exit
Expand Down
Loading
Loading