From 781bc92aa29e747fc4c124f906a32983b5442442 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Fri, 24 Jul 2026 23:12:55 -0700 Subject: [PATCH] feat: persist browser-edited C source --- PLAN.md | 2 + README.md | 2 +- crates/browser-runtime/src/lib.rs | 27 +++++++++++ crates/memory-fs/src/lib.rs | 62 +++++++++++++++++++++++++ toolchains/clang-musl/verify-browser.sh | 10 ++-- web/index.html | 10 +++- web/src/main.ts | 33 ++++++++++++- web/src/probe.ts | 9 +++- web/src/probe.worker.ts | 17 +++++-- web/src/styles.css | 12 +++++ web/tests/clang-toolchain.spec.ts | 12 ++--- web/tests/probe.spec.ts | 20 ++++++++ 12 files changed, 196 insertions(+), 20 deletions(-) diff --git a/PLAN.md b/PLAN.md index 59d6c80..0fef210 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1883,6 +1883,8 @@ The native source-to-ELF acceptance path is now complete and reproducible throug The same source-to-ELF sequence now passes in a real Chromium Worker through the opt-in `toolchains/clang-musl/verify-browser.sh` regression. In one persisted browser session, Clang exits zero after 32,725,151 guest instructions and 507,877 translated-block executions, LLD exits zero after 11,026,753 interpreted guest instructions, and the generic launcher executes the browser-created ELF to exit 42 after 739 instructions. The measured end-to-end browser run completes in 19.2 minutes on the reference host. This satisfies the core local compile/link/execute path with staged process orchestration; the remaining Phase 6 work is editor-to-filesystem integration, compiler source diagnostics in the product UI, direct child-process and pipe semantics, cache policy, and interactive-performance improvements. +Editor-to-filesystem integration is complete for the initial C workflow. The browser presents editable `/project/main.c`, transfers its exact UTF-8 bytes to the Worker, atomically replaces the file under the configured filesystem quota, and persists the resulting canonical snapshot in OPFS. A fast Chromium regression saves distinct source, reloads the page, and confirms the exported project contains those bytes. The full Clang acceptance test now installs only the toolchain image and obtains its source through this editor path before compiling, linking, and executing it. The verified editor-driven run completes in 19.4 minutes: Clang exits zero after 32,725,151 instructions and 508,211 translated-block executions, LLD exits zero after 11,026,753 instructions, and the resulting ELF exits 42 after 739 instructions. Remaining Phase 6 work is compiler source diagnostics in the product UI, direct child-process and pipe semantics, cache policy, and interactive-performance improvements. + Do not begin the full web IDE before item 30 passes. --- diff --git a/README.md b/README.md index 3a43d2d..67d5097 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ loop count. ## Scope -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. Phase 4's bounded profiler offers hot blocks to the scalar Wasm backend; supported blocks compile and execute from a session-local module cache while unsupported blocks remain in the interpreter. The UI reports hotness, translated execution, fallback, compilation, cache-hit, and emitted-byte metrics. 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. Its source editor atomically saves `/project/main.c` into the same OPFS-backed snapshot consumed by browser-local Clang. 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, source and filesystem persistence, deterministic counters, resource-limit diagnostic, and manual infinite-loop termination. Phase 4's bounded profiler offers hot blocks to the scalar Wasm backend; supported blocks compile and execute from a session-local module cache while unsupported blocks remain in the interpreter. The UI reports hotness, translated execution, fallback, compilation, cache-hit, and emitted-byte metrics. 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 edbd71f..057d9a6 100644 --- a/crates/browser-runtime/src/lib.rs +++ b/crates/browser-runtime/src/lib.rs @@ -594,6 +594,33 @@ pub fn install_filesystem_snapshot( .map_err(|error| JsError::new(&error.to_string())) } +/// Replace a project file and return the resulting canonical snapshot. +/// +/// # Errors +/// +/// Returns a JavaScript error when the current snapshot is malformed, the +/// path cannot be written, or the new contents exceed the filesystem limit. +#[wasm_bindgen] +pub fn write_filesystem_file( + max_filesystem_bytes: u64, + current_snapshot: &[u8], + path: &str, + contents: &[u8], +) -> Result, JsError> { + let mut filesystem = if current_snapshot.is_empty() { + MemoryFileSystem::new(max_filesystem_bytes) + } else { + MemoryFileSystem::from_snapshot(max_filesystem_bytes, current_snapshot) + .map_err(|error| JsError::new(&error.to_string()))? + }; + filesystem + .replace_project_file_contents(path.as_bytes(), contents) + .map_err(|error| JsError::new(&format!("could not write {path}: {error:?}")))?; + filesystem + .export_snapshot() + .map_err(|error| JsError::new(&error.to_string())) +} + /// Stateful browser guest that can suspend and resume blocking terminal input. #[wasm_bindgen] pub struct BrowserGuestSession { diff --git a/crates/memory-fs/src/lib.rs b/crates/memory-fs/src/lib.rs index 2f38cbc..5df509f 100644 --- a/crates/memory-fs/src/lib.rs +++ b/crates/memory-fs/src/lib.rs @@ -105,6 +105,38 @@ impl MemoryFileSystem { self.files.get(&path).map(Vec::as_slice) } + /// Atomically replace one normalized project file with the supplied bytes. + /// + /// # Errors + /// + /// Returns a stable filesystem error when the path cannot be created or + /// replaced, or when the new contents exceed the filesystem byte limit. + pub fn replace_project_file_contents( + &mut self, + path: &[u8], + contents: &[u8], + ) -> Result<(), FileSystemError> { + let normalized = normalize_path(path)?; + if normalized != path || !path.starts_with(b"/project/") { + return Err(FileSystemError::PermissionDenied); + } + let mut replacement = self.clone(); + let handle = replacement.open( + path, + FileOpenOptions { + access: FileAccess::WriteOnly, + flags: FileOpenFlags::CREATE.union(FileOpenFlags::TRUNCATE), + }, + )?; + let written = replacement.write(handle, contents)?; + replacement.close(handle)?; + if written != contents.len() { + return Err(FileSystemError::NoSpace); + } + *self = replacement; + Ok(()) + } + /// Restore persistent regular files from a deterministic snapshot. /// Ephemeral `/tmp` files are never accepted from snapshots. /// @@ -998,6 +1030,36 @@ mod tests { assert_eq!(snapshot, restored.export_snapshot().unwrap()); } + #[test] + fn atomically_replaces_file_contents() { + let mut filesystem = MemoryFileSystem::new(12); + filesystem + .replace_project_file_contents(b"/project/main.c", b"old") + .unwrap(); + filesystem + .replace_project_file_contents(b"/project/main.c", b"return 42;") + .unwrap(); + + assert_eq!( + filesystem.read_file(b"/project/main.c"), + Some(&b"return 42;"[..]) + ); + assert_eq!(filesystem.stored_bytes(), 10); + + assert_eq!( + filesystem.replace_project_file_contents(b"/project/main.c", b"too many bytes"), + Err(FileSystemError::NoSpace) + ); + assert_eq!( + filesystem.read_file(b"/project/main.c"), + Some(&b"return 42;"[..]) + ); + assert_eq!( + filesystem.replace_project_file_contents(b"/project/../tmp/main.c", b"no"), + Err(FileSystemError::PermissionDenied) + ); + } + #[test] fn rejects_truncated_trailing_and_over_budget_snapshots() { let mut filesystem = MemoryFileSystem::new(64); diff --git a/toolchains/clang-musl/verify-browser.sh b/toolchains/clang-musl/verify-browser.sh index b0864f3..8893338 100755 --- a/toolchains/clang-musl/verify-browser.sh +++ b/toolchains/clang-musl/verify-browser.sh @@ -8,9 +8,8 @@ web_directory=$workspace_directory/web clang_runtime=$work_directory/package/toolchain/bin/clang lld_runtime=$work_directory/package/toolchain/bin/lld toolchain_image=$work_directory/clang-musl.bnfs -project_image=$work_directory/clang-project.bnfs -for artifact in "$clang_runtime" "$lld_runtime" "$toolchain_image" "$project_image"; do +for artifact in "$clang_runtime" "$lld_runtime" "$toolchain_image"; do if [ ! -f "$artifact" ]; then echo "missing toolchain artifact: $artifact; run toolchains/clang-musl/build.sh" >&2 exit 1 @@ -20,20 +19,21 @@ done mkdir -p \ "$workspace_directory/.tmp/cargo-home" \ "$workspace_directory/.tmp/cargo-target" \ + "$workspace_directory/.tmp/corepack" \ "$workspace_directory/.tmp/pnpm-home" \ "$workspace_directory/.tmp/playwright-browsers" \ "$workspace_directory/.tmp/rust-tmp" export CARGO_HOME=$workspace_directory/.tmp/cargo-home export CARGO_TARGET_DIR=$workspace_directory/.tmp/cargo-target +export COREPACK_HOME=$workspace_directory/.tmp/corepack export PNPM_HOME=$workspace_directory/.tmp/pnpm-home export PLAYWRIGHT_BROWSERS_PATH=$workspace_directory/.tmp/playwright-browsers export TMPDIR=$workspace_directory/.tmp/rust-tmp -env CARGO_NET_OFFLINE=true pnpm --dir "$web_directory" generate:probes +env CARGO_NET_OFFLINE=true corepack pnpm@10.33.0 --dir "$web_directory" generate:probes BINARROW_CLANG_RUNTIME=$clang_runtime \ BINARROW_LLD_RUNTIME=$lld_runtime \ BINARROW_CLANG_TOOLCHAIN_IMAGE=$toolchain_image \ -BINARROW_CLANG_PROJECT_IMAGE=$project_image \ - pnpm --dir "$web_directory" exec playwright test \ + corepack pnpm@10.33.0 --dir "$web_directory" exec playwright test \ tests/clang-toolchain.spec.ts \ --project chromium diff --git a/web/index.html b/web/index.html index 7e6c97b..08ac7df 100644 --- a/web/index.html +++ b/web/index.html @@ -116,7 +116,15 @@

System call trace


-        

Project images

+

Project source and images

+
+ + + +
diff --git a/web/src/main.ts b/web/src/main.ts index 0586d81..36e99dd 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -65,6 +65,8 @@ const startButton = requiredElement("#start"); const stopButton = requiredElement("#stop"); const terminalInput = requiredElement("#terminal-input"); const sendInputButton = requiredElement("#send-input"); +const projectSource = requiredElement("#project-source"); +const saveSourceButton = requiredElement("#save-source"); const projectImage = requiredElement("#project-image"); const replaceImageButton = requiredElement("#replace-image"); const installImageButton = requiredElement("#install-image"); @@ -90,6 +92,8 @@ function setControls(): void { programEnvironment.disabled = busy; terminalInput.disabled = !waitingInput; sendInputButton.disabled = !waitingInput; + projectSource.disabled = !workerReady || busy; + saveSourceButton.disabled = !workerReady || busy; projectImage.disabled = !workerReady || busy; replaceImageButton.disabled = !workerReady || busy; installImageButton.disabled = !workerReady || busy; @@ -250,7 +254,9 @@ function createWorker(): void { status.textContent = event.data.operation === "replace" ? "Project snapshot imported" - : "Project image installed"; + : event.data.operation === "install" + ? "Project image installed" + : "Project source saved"; } status.dataset.state = "ready"; imageOperationPending = false; @@ -454,5 +460,30 @@ exportImageButton.addEventListener("click", () => { worker.postMessage(command); }); +saveSourceButton.addEventListener("click", () => { + imageOperationPending = true; + status.textContent = "Saving project source…"; + status.dataset.state = "running"; + setControls(); + imageRequestId += 1; + try { + const command: WorkerCommand = { + kind: "filesystem-write", + requestId: imageRequestId, + maxFilesystemBytes: parseLimit(filesystemLimit), + path: "/project/main.c", + contents: new TextEncoder().encode(projectSource.value), + }; + worker.postMessage(command, [command.contents.buffer as ArrayBuffer]); + } catch (error) { + imageOperationPending = false; + status.textContent = + "Cannot save project source: " + + (error instanceof Error ? error.message : String(error)); + status.dataset.state = "error"; + setControls(); + } +}); + updateProgramControls(); createWorker(); diff --git a/web/src/probe.ts b/web/src/probe.ts index dbb2e59..feb6aa7 100644 --- a/web/src/probe.ts +++ b/web/src/probe.ts @@ -113,6 +113,13 @@ export type WorkerCommand = mode: "replace" | "install"; snapshot: Uint8Array; } + | { + kind: "filesystem-write"; + requestId: number; + maxFilesystemBytes: bigint; + path: string; + contents: Uint8Array; + } | { kind: "translation-benchmark"; requestId: number; @@ -130,7 +137,7 @@ export type WorkerMessage = | { kind: "filesystem-image"; requestId: number; - operation: "export" | "replace" | "install"; + operation: "export" | "replace" | "install" | "write"; snapshot: Uint8Array; } | { diff --git a/web/src/probe.worker.ts b/web/src/probe.worker.ts index 7277ab3..dc65c1b 100644 --- a/web/src/probe.worker.ts +++ b/web/src/probe.worker.ts @@ -13,6 +13,7 @@ import initBrowserRuntime, { start_fixture_with_hot_threshold, start_program, translate_fixture_entry, + write_filesystem_file, } from "./generated/binarrow_browser_runtime.js"; import type { BrowserGuestSession } from "./generated/binarrow_browser_runtime.js"; @@ -315,18 +316,27 @@ async function execute( async function handleFilesystemCommand( command: Extract< WorkerCommand, - { kind: "filesystem-export" | "filesystem-import" } + { kind: "filesystem-export" | "filesystem-import" | "filesystem-write" } >, ): Promise { const current = await loadFilesystemSnapshot(); let snapshot: Uint8Array; - let operation: "export" | "replace" | "install"; + let operation: "export" | "replace" | "install" | "write"; if (command.kind === "filesystem-export") { snapshot = normalize_filesystem_snapshot( command.maxFilesystemBytes, current, ); operation = "export"; + } else if (command.kind === "filesystem-write") { + snapshot = write_filesystem_file( + command.maxFilesystemBytes, + current, + command.path, + command.contents, + ); + await saveFilesystemSnapshot(snapshot); + operation = "write"; } else if (command.mode === "replace") { snapshot = normalize_filesystem_snapshot( command.maxFilesystemBytes, @@ -465,7 +475,8 @@ self.addEventListener("message", (event: MessageEvent) => { } if ( command.kind === "filesystem-export" || - command.kind === "filesystem-import" + command.kind === "filesystem-import" || + command.kind === "filesystem-write" ) { await handleFilesystemCommand(command); return; diff --git a/web/src/styles.css b/web/src/styles.css index e3f4e4c..676c963 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -131,6 +131,7 @@ h2 { opacity: 0.55; } +#source-controls, #image-controls { display: flex; flex-wrap: wrap; @@ -140,6 +141,17 @@ h2 { border: 1px solid #aaa38c; } +#source-controls { + margin-bottom: 0.75rem; +} + +#source-controls textarea { + min-width: min(30rem, 100%); + flex: 1 1 30rem; +} + +#source-controls button, +#source-controls textarea, #image-controls button, #image-controls input { padding: 0.65rem; diff --git a/web/tests/clang-toolchain.spec.ts b/web/tests/clang-toolchain.spec.ts index a0940e0..e5aa36e 100644 --- a/web/tests/clang-toolchain.spec.ts +++ b/web/tests/clang-toolchain.spec.ts @@ -5,7 +5,6 @@ import { expect, test, type Page } from "@playwright/test"; const clangRuntime = process.env.BINARROW_CLANG_RUNTIME; const lldRuntime = process.env.BINARROW_LLD_RUNTIME; const toolchainImage = process.env.BINARROW_CLANG_TOOLCHAIN_IMAGE; -const projectImage = process.env.BINARROW_CLANG_PROJECT_IMAGE; const launcher = fileURLToPath( new URL( "../../guest-tests/exec-from-filesystem/exec-from-filesystem.aarch64.elf", @@ -56,10 +55,10 @@ async function runUploadedProgram( test("compiles, links, and runs C with packaged Clang", async ({ page }) => { test.skip( - !clangRuntime || !lldRuntime || !toolchainImage || !projectImage, + !clangRuntime || !lldRuntime || !toolchainImage, "set all BINARROW_CLANG and BINARROW_LLD artifact variables", ); - if (!clangRuntime || !lldRuntime || !toolchainImage || !projectImage) { + if (!clangRuntime || !lldRuntime || !toolchainImage) { return; } test.setTimeout(60 * 60 * 1_000); @@ -76,11 +75,8 @@ test("compiles, links, and runs C with packaged Clang", async ({ page }) => { "Project snapshot imported", { timeout: 3 * 60 * 1_000 }, ); - await page.getByLabel("Snapshot or package image").setInputFiles(projectImage); - await page.getByRole("button", { name: "Install image" }).click(); - await expect(page.getByRole("status")).toHaveText("Project image installed", { - timeout: 3 * 60 * 1_000, - }); + await page.getByRole("button", { name: "Save source" }).click(); + await expect(page.getByRole("status")).toHaveText("Project source saved"); await page.getByLabel("Fixture").selectOption("uploaded-program"); const clang = await runUploadedProgram( diff --git a/web/tests/probe.spec.ts b/web/tests/probe.spec.ts index 3a53f3e..3662406 100644 --- a/web/tests/probe.spec.ts +++ b/web/tests/probe.spec.ts @@ -1,4 +1,5 @@ import { expect, test } from "@playwright/test"; +import fs from "node:fs"; import path from "node:path"; test.beforeEach(async ({ page }) => { @@ -217,6 +218,25 @@ test("exports, replaces, and restores deterministic project images", async ({ ); }); +test("saves edited C source into the persistent project filesystem", async ({ + page, +}) => { + const source = "int main(void) {\n return 23;\n}\n"; + await page.getByLabel("C source at /project/main.c").fill(source); + await page.getByRole("button", { name: "Save source" }).click(); + await expect(page.getByRole("status")).toHaveText("Project source saved"); + + await page.reload(); + await expect(page.getByRole("status")).toHaveText("Browser runtime ready"); + const downloadPath = await Promise.all([ + page.waitForEvent("download"), + page.getByRole("button", { name: "Export project" }).click(), + ]).then(([download]) => download.path()); + + expect(downloadPath).not.toBeNull(); + expect(fs.readFileSync(downloadPath!).includes(Buffer.from(source))).toBe(true); +}); + test("creates, modifies, lists, renames, and deletes guest files", async ({ page, }) => { -- 2.51.2