From 1055d2b1e066f07a3fde3405da25cfc64a3fe090 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 24 Jul 2026 11:10:31 -0700 Subject: [PATCH] feat: run uploaded ELF programs in browser --- PLAN.md | 2 +- README.md | 4 +- crates/browser-runtime/src/lib.rs | 136 +++++++++++++++++++++++++++--- web/index.html | 24 +++++- web/src/main.ts | 74 ++++++++++++++-- web/src/probe.ts | 12 ++- web/src/probe.worker.ts | 48 ++++++++--- web/src/styles.css | 18 ++++ web/tests/probe.spec.ts | 23 +++++ 9 files changed, 301 insertions(+), 40 deletions(-) diff --git a/PLAN.md b/PLAN.md index 261cada..932c906 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1857,7 +1857,7 @@ The Phase 2 control checkpoint is complete: the Worker accepts explicit C, Rust, Phase 2 is complete. `binarrow-memory-fs` supplies a bounded ephemeral `/tmp` regular-file store behind the project host filesystem trait, while `binarrow-linux-runtime` owns guest descriptors and the observed `openat`, `close`, `lseek`, `read`, and file `write` ABI. A freestanding C fixture creates, writes, seeks, reads, and closes `/tmp/roundtrip.txt` before printing the recovered bytes; native, CLI, and Chromium tests all run the identical ELF. ADR-0002 records the interpreter's interim browser memory design: project-owned sparse 64-bit guest mappings backed by bounded Wasm32 allocations, with Memory64 retained as a required feature gate for the future translator. Every Phase 2 deliverable and acceptance criterion now has an automated regression. Phase 3 begins with a mountable VFS, directory and metadata syscalls, OPFS persistence, terminal input, clock/random services, and the suspension model needed by CPython. -The first CPython checkpoint is complete and reproducible in the native runtime. `guest-tests/cpython/build.sh` checksum-pins CPython 3.12.13, isolates all build and Zig caches under the repository's ignored `.tmp` directory, cross-compiles a static AArch64 musl interpreter, links the configured dependency-free extension modules into the executable, and packages the standard library with its empty pre-dynamic-linking `lib-dynload` landmark. `guest-tests/cpython/verify.sh` checks the version and runs the bounded `-c 'print(6 * 7)'` regression with a deterministic CLI random seed. The freshly built broader module profile added focused semantics for NEON table lookup, unsigned variable shifts, extraction, and 64-bit-group reversal. Running this packaged runtime in Chromium is the next CPython acceptance task; shared native extensions remain deferred to Phase 7. +The first CPython checkpoint is complete and reproducible in the native runtime. `guest-tests/cpython/build.sh` checksum-pins CPython 3.12.13, isolates all build and Zig caches under the repository's ignored `.tmp` directory, cross-compiles a static AArch64 musl interpreter, links the configured dependency-free extension modules into the executable, and packages the standard library with its empty pre-dynamic-linking `lib-dynload` landmark. `guest-tests/cpython/verify.sh` checks the version and runs the bounded `-c 'print(6 * 7)'` regression with a deterministic CLI random seed. The freshly built broader module profile added focused semantics for NEON table lookup, unsigned variable shifts, extraction, and 64-bit-group reversal. The browser UI and Worker now accept externally built ELF bytes, `argv[0]`, and additional arguments, with a Chromium regression covering the upload path. Running the packaged CPython runtime and filesystem image through that path is the next acceptance task; shared native extensions remain deferred to Phase 7. Do not begin the full web IDE before item 30 passes. diff --git a/README.md b/README.md index cd55077..904fbcf 100644 --- a/README.md +++ b/README.md @@ -70,11 +70,11 @@ under `.tmp`; `guest-tests/cpython/verify.sh` runs the bounded version and [`guest-tests/cpython/README.md`](guest-tests/cpython/README.md) for prerequisites and packaging details. -The browser build generates its Memory64, JSPI, and P-code `.wasm` probes before starting Vite. Generated artifacts are not committed. +The browser build generates its Memory64, JSPI, and P-code `.wasm` probes before starting Vite. Generated artifacts are not committed. Select **Uploaded AArch64 ELF** to run an external static executable with a chosen `argv[0]` and one argument per line; the executable is transferred directly to the runtime Worker. ## Scope -The Phase 2 browser controller can start the checked-in freestanding C, musl C, Rust `std`, filesystem, and infinite-loop fixtures with explicit instruction, syscall, output, committed-memory, and filesystem limits. Execution errors return stable diagnostic codes instead of rejected JavaScript calls. The Stop action terminates the active Worker, so even a guest that never reaches a syscall or yield point can be interrupted and the runtime restarted. Chromium verifies the C/Rust outputs, in-memory file round trip, deterministic counters, resource-limit diagnostic, and manual infinite-loop termination. The interpreter's interim browser memory design is recorded in [ADR-0002](docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md). See [PLAN.md](PLAN.md) for the roadmap and [docs/architecture.md](docs/architecture.md) for the current boundaries. +The browser controller can start the checked-in freestanding C, musl C, Rust `std`, filesystem, and infinite-loop fixtures or a user-supplied static AArch64 ELF with explicit instruction, syscall, output, committed-memory, and filesystem limits. Execution errors return stable diagnostic codes instead of rejected JavaScript calls. The Stop action terminates the active Worker, so even a guest that never reaches a syscall or yield point can be interrupted and the runtime restarted. Chromium verifies the C/Rust outputs, uploaded-ELF transfer, in-memory file round trip, deterministic counters, resource-limit diagnostic, and manual infinite-loop termination. The interpreter's interim browser memory design is recorded in [ADR-0002](docs/decisions/0002-use-sparse-memory-for-browser-interpreter.md). See [PLAN.md](PLAN.md) for the roadmap and [docs/architecture.md](docs/architecture.md) for the current boundaries. ## License diff --git a/crates/browser-runtime/src/lib.rs b/crates/browser-runtime/src/lib.rs index 9fec88c..eb067b3 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -311,29 +311,106 @@ pub fn start_fixture( max_filesystem_bytes: u64, filesystem_snapshot: &[u8], ) -> BrowserGuestSession { + let request = fixture(fixture_name) + .map(|(elf, argv0)| (elf, vec![argv0.to_vec()])) + .ok_or_else(|| { + BrowserExecution::diagnostic( + "request.unknown_fixture", + format!("unknown embedded fixture {fixture_name:?}"), + ) + }); + start_guest_session( + request, + session_config( + instruction_budget, + syscall_budget, + max_output_bytes, + max_memory_bytes, + max_filesystem_bytes, + ), + filesystem_snapshot, + ) +} + +/// Create a stateful session from browser-supplied ELF bytes and arguments. +#[wasm_bindgen] +#[must_use] +#[allow(clippy::too_many_arguments)] +pub fn start_program( + elf: &[u8], + argv0: &str, + nul_separated_arguments: &str, + instruction_budget: u64, + syscall_budget: u64, + max_output_bytes: u64, + max_memory_bytes: u64, + max_filesystem_bytes: u64, + filesystem_snapshot: &[u8], +) -> BrowserGuestSession { + let request = program_arguments(argv0, nul_separated_arguments) + .map(|arguments| (elf, arguments)) + .map_err(|message| BrowserExecution::diagnostic("request.invalid_arguments", message)); + start_guest_session( + request, + session_config( + instruction_budget, + syscall_budget, + max_output_bytes, + max_memory_bytes, + max_filesystem_bytes, + ), + filesystem_snapshot, + ) +} + +fn program_arguments(argv0: &str, nul_separated_arguments: &str) -> Result>, String> { + if argv0.is_empty() || argv0.contains('\0') { + return Err("argv[0] must be non-empty and must not contain a NUL byte".to_owned()); + } + let mut arguments = vec![argv0.as_bytes().to_vec()]; + if !nul_separated_arguments.is_empty() { + arguments.extend( + nul_separated_arguments + .split('\0') + .map(|argument| argument.as_bytes().to_vec()), + ); + } + Ok(arguments) +} + +fn session_config( + instruction_budget: u64, + syscall_budget: u64, + max_output_bytes: u64, + max_memory_bytes: u64, + max_filesystem_bytes: u64, +) -> ProcessConfig { let mut config = ProcessConfig::default(); config.limits.instruction_budget = instruction_budget; config.limits.syscall_budget = syscall_budget; config.limits.max_output_bytes = max_output_bytes; config.limits.max_memory_bytes = max_memory_bytes; config.limits.max_filesystem_bytes = max_filesystem_bytes; + config +} + +fn start_guest_session( + request: Result<(&[u8], Vec>), BrowserExecution>, + config: ProcessConfig, + filesystem_snapshot: &[u8], +) -> BrowserGuestSession { + let max_filesystem_bytes = config.limits.max_filesystem_bytes; let filesystem = if filesystem_snapshot.is_empty() { Ok(MemoryFileSystem::new(max_filesystem_bytes)) } else { MemoryFileSystem::from_snapshot(max_filesystem_bytes, filesystem_snapshot) }; - let process = fixture(fixture_name) - .ok_or_else(|| { - BrowserExecution::diagnostic( - "request.unknown_fixture", - format!("unknown embedded fixture {fixture_name:?}"), - ) - }) - .and_then(|(elf, argv0)| { + let process = request + .and_then(|(elf, argv)| { load_process( elf, &ProcessParameters { - argv: vec![argv0.to_vec()], + argv, envp: Vec::new(), random_bytes: [0x42; 16], credentials: Credentials::default(), @@ -662,7 +739,7 @@ impl HostTerminal for CapturedTerminal { #[cfg(test)] mod tests { - use super::{execute_fixture, start_fixture}; + use super::{COMPILER_HELLO_ELF, execute_fixture, start_fixture, start_program}; const DEFAULT_INSTRUCTIONS: u64 = 10_000_000; const DEFAULT_SYSCALLS: u64 = 100_000; @@ -832,4 +909,43 @@ mod tests { assert_eq!(completed.dispatched_syscalls, 5); assert!(completed.trace.contains("read(fd=0")); } + + #[test] + fn stateful_browser_session_accepts_external_elf_bytes_and_arguments() { + let mut session = start_program( + COMPILER_HELLO_ELF, + "/uploaded-compiler-hello", + "first\0second argument", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + &[], + ); + + let completed = session.resume(&[], false); + assert_eq!(completed.outcome, "exited"); + assert_eq!(completed.exit_code, 0); + assert_eq!(completed.stdout, "compiled hello\n"); + } + + #[test] + fn external_program_rejects_an_empty_argv_zero() { + let mut session = start_program( + COMPILER_HELLO_ELF, + "", + "", + DEFAULT_INSTRUCTIONS, + DEFAULT_SYSCALLS, + DEFAULT_OUTPUT, + DEFAULT_MEMORY, + DEFAULT_FILESYSTEM, + &[], + ); + + let completed = session.resume(&[], false); + assert_eq!(completed.outcome, "diagnostic"); + assert_eq!(completed.diagnostic_code, "request.invalid_arguments"); + } } diff --git a/web/index.html b/web/index.html index 38d4ecd..d69e178 100644 --- a/web/index.html +++ b/web/index.html @@ -12,7 +12,7 @@
-

Phase 2 browser interpreter

+

Phase 3 browser interpreter

binarrow browser runtime

This Worker loads a static AArch64 ELF, executes its real SLEIGH @@ -35,9 +35,27 @@ + +

- + @@ -45,7 +63,7 @@ - +
diff --git a/web/src/main.ts b/web/src/main.ts index 5ddb6ed..3917923 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -6,6 +6,7 @@ import type { ExecutionReport, FeatureResult, FixtureName, + RunTarget, WorkerCommand, WorkerMessage, } from "./probe"; @@ -29,6 +30,10 @@ const outputCount = requiredElement("#output-count"); const diagnosticCode = requiredElement("#diagnostic-code"); const diagnosticMessage = requiredElement("#diagnostic-message"); const fixture = requiredElement("#fixture"); +const programControls = requiredElement("#program-controls"); +const programElf = requiredElement("#program-elf"); +const programArgv0 = requiredElement("#program-argv0"); +const programArguments = requiredElement("#program-arguments"); const instructionBudget = requiredElement("#instruction-budget"); const syscallBudget = requiredElement("#syscall-budget"); const outputLimit = requiredElement("#output-limit"); @@ -55,6 +60,9 @@ function setControls(): void { startButton.disabled = !workerReady || running; stopButton.disabled = !running; fixture.disabled = running; + programElf.disabled = running; + programArgv0.disabled = running; + programArguments.disabled = running; terminalInput.disabled = !waitingInput; sendInputButton.disabled = !waitingInput; projectImage.disabled = !workerReady || running; @@ -63,6 +71,10 @@ function setControls(): void { exportImageButton.disabled = !workerReady || running; } +function updateProgramControls(): void { + programControls.hidden = fixture.value !== "uploaded-program"; +} + function renderFeatures(results: FeatureResult[]): void { report.replaceChildren(); for (const result of results) { @@ -207,28 +219,71 @@ function createWorker(): void { }); } +async function createRunCommand( + nextRunId: number, + limits: ExecutionLimits, +): Promise { + const target = fixture.value as RunTarget; + if (target !== "uploaded-program") { + return { + kind: "run", + runId: nextRunId, + fixture: target as FixtureName, + limits, + }; + } + const file = programElf.files?.[0]; + if (!file) { + throw new Error("choose an AArch64 ELF executable first"); + } + if (!programArgv0.value || programArgv0.value.includes("\0")) { + throw new Error("argv[0] must be non-empty and must not contain a NUL byte"); + } + const arguments_ = programArguments.value + .split(/\r?\n/) + .filter((argument) => argument.length > 0); + if (arguments_.some((argument) => argument.includes("\0"))) { + throw new Error("arguments must not contain NUL bytes"); + } + return { + kind: "run-program", + runId: nextRunId, + executable: new Uint8Array(await file.arrayBuffer()), + argv0: programArgv0.value, + arguments: arguments_, + limits, + }; +} + +fixture.addEventListener("change", updateProgramControls); + startButton.addEventListener("click", () => { + void (async () => { try { const limits = readLimits(); + const nextRunId = runId + 1; + const command = await createRunCommand(nextRunId, limits); preserveDiagnosticStatus = false; running = true; waitingInput = false; - runId += 1; + runId = nextRunId; clearExecution(); status.textContent = "Guest running…"; status.dataset.state = "running"; setControls(); - const command: WorkerCommand = { - kind: "run", - runId, - fixture: fixture.value as FixtureName, - limits, - }; - worker.postMessage(command); + if (command.kind === "run-program") { + worker.postMessage(command, [command.executable.buffer as ArrayBuffer]); + } else { + worker.postMessage(command); + } } catch (error) { - status.textContent = `Invalid limits: ${error instanceof Error ? error.message : String(error)}`; + running = false; + waitingInput = false; + status.textContent = `Cannot start guest: ${error instanceof Error ? error.message : String(error)}`; status.dataset.state = "error"; + setControls(); } + })(); }); stopButton.addEventListener("click", () => { @@ -309,4 +364,5 @@ exportImageButton.addEventListener("click", () => { worker.postMessage(command); }); +updateProgramControls(); createWorker(); diff --git a/web/src/probe.ts b/web/src/probe.ts index 8f5997c..277e851 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -24,6 +24,8 @@ export type FixtureName = | "system-services" | "terminal-input"; +export type RunTarget = FixtureName | "uploaded-program"; + export interface FeatureResult { name: FeatureName; supported: boolean; @@ -63,6 +65,14 @@ export type WorkerCommand = fixture: FixtureName; limits: ExecutionLimits; } + | { + kind: "run-program"; + runId: number; + executable: Uint8Array; + argv0: string; + arguments: string[]; + limits: ExecutionLimits; + } | { kind: "input"; runId: number; @@ -87,7 +97,7 @@ export type WorkerMessage = | { kind: "result"; runId: number; - fixture: FixtureName; + target: RunTarget; execution: ExecutionReport; } | { diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index 37b5227..4fd1fc1 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -10,6 +10,7 @@ import initBrowserRuntime, { install_filesystem_snapshot, normalize_filesystem_snapshot, start_fixture, + start_program, } from "./generated/binarrow_browser_runtime.js"; import type { BrowserGuestSession } from "./generated/binarrow_browser_runtime.js"; @@ -18,6 +19,7 @@ import type { FeatureName, FeatureResult, FixtureName, + RunTarget, WorkerCommand, WorkerMessage, } from "./probe"; @@ -205,7 +207,7 @@ function executionReport( let activeSession: BrowserGuestSession | undefined; let activeRunId = 0; -let activeFixture: FixtureName = "compiler-c"; +let activeTarget: RunTarget = "compiler-c"; async function continueSession( input: Uint8Array, @@ -229,21 +231,34 @@ async function continueSession( } async function execute( - command: Extract, + command: Extract, ): Promise { const filesystemSnapshot = await loadFilesystemSnapshot(); activeSession?.free(); - activeSession = start_fixture( - command.fixture, - command.limits.instructionBudget, - command.limits.syscallBudget, - command.limits.maxOutputBytes, - command.limits.maxMemoryBytes, - command.limits.maxFilesystemBytes, - filesystemSnapshot, - ); + activeSession = + command.kind === "run" + ? start_fixture( + command.fixture, + command.limits.instructionBudget, + command.limits.syscallBudget, + command.limits.maxOutputBytes, + command.limits.maxMemoryBytes, + command.limits.maxFilesystemBytes, + filesystemSnapshot, + ) + : start_program( + command.executable, + command.argv0, + command.arguments.join("\0"), + command.limits.instructionBudget, + command.limits.syscallBudget, + command.limits.maxOutputBytes, + command.limits.maxMemoryBytes, + command.limits.maxFilesystemBytes, + filesystemSnapshot, + ); activeRunId = command.runId; - activeFixture = command.fixture; + activeTarget = command.kind === "run" ? command.fixture : "uploaded-program"; return continueSession(new Uint8Array(), false); } @@ -317,7 +332,7 @@ self.addEventListener("message", (event: MessageEvent) => { return; } const execution = - command.kind === "run" + command.kind === "run" || command.kind === "run-program" ? await execute(command) : await continueSession( new TextEncoder().encode(command.input), @@ -326,7 +341,12 @@ self.addEventListener("message", (event: MessageEvent) => { const message: WorkerMessage = { kind: "result", runId: command.runId, - fixture: command.kind === "run" ? command.fixture : activeFixture, + target: + command.kind === "run" + ? command.fixture + : command.kind === "run-program" + ? "uploaded-program" + : activeTarget, execution, }; self.postMessage(message); diff --git a/web/src/styles.css b/web/src/styles.css index 3cb2a7a..e3f4e4c 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -83,6 +83,7 @@ h2 { #controls input, #controls select, +#controls textarea, #controls button { padding: 0.65rem; border: 1px solid #817a66; @@ -91,6 +92,19 @@ h2 { font: inherit; } +#program-controls { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(12rem, 2fr); + grid-column: 1 / -1; + gap: 0.75rem 1rem; + padding: 1rem; + border: 1px solid #aaa38c; +} + +#program-controls[hidden] { + display: none; +} + .actions { display: flex; grid-column: 2; @@ -176,4 +190,8 @@ dd[data-supported="false"] { .actions { grid-column: 1; } + + #program-controls { + grid-template-columns: 1fr; + } } diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 275f74a..179172f 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import path from "node:path"; test.beforeEach(async ({ page }) => { await page.goto("/"); @@ -51,6 +52,28 @@ test("runs the same static C and Rust fixtures in the Worker", async ({ page }) await expect(page.locator("#diagnostic-code")).toHaveText("—"); }); +test("runs an uploaded AArch64 ELF in the Worker", async ({ page }) => { + await page.getByLabel("Fixture").selectOption("uploaded-program"); + await page.getByLabel("AArch64 ELF executable").setInputFiles( + path.resolve( + process.cwd(), + "../guest-tests/compiler-hello/compiler-hello.aarch64.elf", + ), + ); + await page.getByLabel("argv[0]").fill("/uploaded-compiler-hello"); + await page.getByLabel("Arguments (one per line)").fill( + "first\nsecond argument", + ); + await page.getByRole("button", { name: "Start" }).click(); + + await expect(page.getByRole("status")).toHaveText("Guest exited"); + await expect(page.getByLabel("Guest terminal output")).toHaveText( + "compiled hello", + ); + await expect(page.locator("#exit-code")).toHaveText("0"); + await expect(page.locator("#diagnostic-code")).toHaveText("—"); +}); + test("surfaces instruction exhaustion as a structured diagnostic", async ({ page, }) => { -- 2.51.2