From 2cbf8e53af1c6d40eb720359a5292d423f9103b4 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Mon, 17 Aug 2026 10:29:07 -0700 Subject: [PATCH 1/4] fix: disable unsupported C++ exceptions The bundled WASI libc++abi lacks the __cxa exception runtime. Disable exceptions by default so valid stream insertion of function return values links successfully, and cover it in the browser smoke test. --- scripts/smoke-browser.mjs | 50 ++++++++++++++++++++++++++++++++-- src/workers/compiler.worker.js | 6 +++- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/scripts/smoke-browser.mjs b/scripts/smoke-browser.mjs index 15bb97f..956beb7 100644 --- a/scripts/smoke-browser.mjs +++ b/scripts/smoke-browser.mjs @@ -287,6 +287,33 @@ async function evaluate(cdp, sessionId, expression, { awaitPromise = false } = { return result.result?.value; } +async function replaceEditorText(cdp, sessionId, source) { + const focused = await evaluate( + cdp, + sessionId, + `(() => { + const input = document.querySelector('.monaco-editor textarea.inputarea'); + if (!input) return false; + input.focus(); + return document.activeElement === input; + })()` + ); + assert(focused, 'Could not focus the Monaco editor input area'); + const selectAllModifier = await evaluate( + cdp, + sessionId, + `navigator.platform.includes('Mac') ? 4 : 2` + ); + + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyDown', key: 'a', code: 'KeyA', modifiers: selectAllModifier, + }, sessionId); + await cdp.send('Input.dispatchKeyEvent', { + type: 'keyUp', key: 'a', code: 'KeyA', modifiers: selectAllModifier, + }, sessionId); + await cdp.send('Input.insertText', { text: source }, sessionId); +} + async function openExtensionPage(cdp, extensionId) { const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', @@ -708,6 +735,10 @@ async function runHostedSmoke(cdp, { realRun = false } = {}) { await cdp.send('Runtime.enable', {}, sessionId); const runtimeProgram = `#include #include +#include + +int val() { return 5; } +std::string label() { return "stream"; } int main() { std::fstream out; @@ -718,7 +749,7 @@ int main() { } out << "hello from fstream\\n"; out.close(); - std::cout << "wrote output.txt\\n"; + std::cout << val() << ' ' << label() << " wrote output.txt\\n"; return 0; } `; @@ -819,6 +850,7 @@ int main() { const createdFileText = await evaluate(cdp, sessionId, `globalThis.__browserCppTestFs.readText('output.txt')`); assert(createdFileText === 'hello from fstream\n', `Expected output.txt to be created, got: ${JSON.stringify(createdFileText)}`); + assert(terminalText.includes('5 stream wrote output.txt'), `Expected stream-insertion output, got: ${JSON.stringify(terminalText)}`); const explorerPath = await waitFor(async () => { return evaluate( @@ -954,13 +986,25 @@ async function runSmoke(cdp, sessionId) { const hasEditor = await evaluate(cdp, sessionId, `!!document.querySelector('.monaco-editor')`); assert(hasEditor, 'Monaco editor did not render'); + await replaceEditorText(cdp, sessionId, `#include +#include + +int val() { return 5; } +std::string label() { return "stream"; } + +int main() { + std::cout << val() << ' ' << label() << std::endl; + return 0; +} +`); + await evaluate(cdp, sessionId, `document.getElementById('btn-compile-run').click()`); try { await waitFor(async () => { const text = await evaluate(cdp, sessionId, `document.body.textContent || ''`); - return text.includes('Compilation successful.') && text.includes('Hello, World!') ? text : null; - }, 'default C++ compile-and-run output', 120_000); + return text.includes('Compilation successful.') && text.includes('5 stream') ? text : null; + }, 'stream insertion compile-and-run output', 120_000); } catch (err) { const status = await evaluate(cdp, sessionId, `document.getElementById('status-compiler')?.textContent || ''`); const terminalText = await evaluate(cdp, sessionId, `document.getElementById('terminal-container')?.textContent || ''`); diff --git a/src/workers/compiler.worker.js b/src/workers/compiler.worker.js index fb6f4cc..e68ca72 100644 --- a/src/workers/compiler.worker.js +++ b/src/workers/compiler.worker.js @@ -376,7 +376,11 @@ async function compile(request) { if (sources.length === 0) return fail('No source files to compile.'); - const userFlags = [`-std=${std}`, '-Wall', '-Wextra', ...flags]; + // The bundled WASI libc++abi is built without C++ exception support. Clang + // otherwise enables exceptions for C++ sources, producing unresolved + // __cxa_* symbols when stream operations instantiate throwing paths. + // Keep user flags last so an explicit opt-in remains possible. + const userFlags = [`-std=${std}`, '-Wall', '-Wextra', '-fno-exceptions', ...flags]; // ── Step 1: Build-plan discovery ───────────────────────────────────────── let plan; From 3c75a3f154de86faf785e069e8dcd6ad8b08a858 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Mon, 17 Aug 2026 10:34:49 -0700 Subject: [PATCH 2/4] test: cover stream insertion linker regressions Exercise the bundled Clang and LLD artifacts for defined stream return values and expected undefined user symbols, and force the unsupported exception flag off after user options. --- package.json | 3 +- scripts/e2e-compiler-link.test.mjs | 132 +++++++++++++++++++++++++++++ src/workers/compiler.worker.js | 3 +- 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 scripts/e2e-compiler-link.test.mjs diff --git a/package.json b/package.json index 37ae02a..6b949e5 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "build": "npm run build:webpack && npm run build:targets", "build:firefox": "npm run build", "test:e2e": "node --experimental-detect-module --test scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs", + "test:e2e:compiler": "npm run test:preflight-clang && node --experimental-detect-module --test scripts/e2e-compiler-link.test.mjs", "test:preflight-clang": "node scripts/preflight-clang-artifacts.js", - "test:browser:chrome": "npm run test:preflight-clang && node scripts/smoke-browser.mjs chrome", + "test:browser:chrome": "npm run test:e2e:compiler && node scripts/smoke-browser.mjs chrome", "test:browser:edge": "npm run test:preflight-clang && node scripts/smoke-browser.mjs edge", "test:browser:brave": "npm run test:preflight-clang && node scripts/smoke-browser.mjs brave", "test:browser:chromium": "npm run test:preflight-clang && node scripts/smoke-browser.mjs chromium", diff --git a/scripts/e2e-compiler-link.test.mjs b/scripts/e2e-compiler-link.test.mjs new file mode 100644 index 0000000..dbd98d7 --- /dev/null +++ b/scripts/e2e-compiler-link.test.mjs @@ -0,0 +1,132 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { parseCompilePlan } from '../src/workers/compile-plan.mjs'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const clangDir = path.join(repoRoot, 'dist', 'clang'); + +globalThis.self = globalThis; +let toolsReady = null; + +function ensureTools() { + toolsReady ||= (async () => { + process.type = 'renderer'; + await import(pathToFileURL(path.join(clangDir, 'clang.js')).href); + await import(pathToFileURL(path.join(clangDir, 'lld.js')).href); + })(); + return toolsReady; +} + +function callMain(module, args) { + try { + return module.callMain(args); + } catch (error) { + if (error?.name === 'ExitStatus') return error.status; + throw error; + } +} + +function* tarContents(buffer) { + const data = new Uint8Array(buffer); + const decode = new TextDecoder(); + let offset = 0; + + while (offset + 512 <= data.length) { + const header = data.slice(offset, offset + 512); + const name = decode.decode(header.slice(0, 100)).replace(/\0.*$/, ''); + if (!name) return; + const size = parseInt(decode.decode(header.slice(124, 136)).replace(/\0.*$/, '').trim(), 8) || 0; + yield { name, content: data.slice(offset + 512, offset + 512 + size) }; + offset += 512 + Math.ceil(size / 512) * 512; + } +} + +const sysroot = fs.readFileSync(path.join(clangDir, 'sysroot.tar')); + +function setUpSysroot(module) { + for (const { name, content } of tarContents(sysroot)) { + if (name.endsWith('/')) continue; + const directory = name.split('/').slice(0, -1).join('/'); + if (directory && !module.FS.analyzePath(directory).exists) module.FS.mkdirTree(directory); + module.FS.writeFile(name, content); + } +} + +async function createTool(factory, wasmName, program, capture) { + return factory({ + thisProgram: program, + wasmBinary: fs.readFileSync(path.join(clangDir, wasmName)), + locateFile: (name) => path.join(clangDir, name), + print: capture, + printErr: capture, + }); +} + +async function compileAndLink(source) { + await ensureTools(); + let driverOutput = ''; + const driver = await createTool(globalThis.createClangModule, 'clang.wasm', 'clang++', (line) => { + driverOutput += `${line}\n`; + }); + driver.FS.writeFile('main.cpp', source); + driver.FS.mkdirTree('/lib/wasm32-wasi'); + driver.FS.mkdirTree('/include/c++/v1'); + driver.FS.writeFile('/lib/wasm32-wasi/crt1-command.o', new Uint8Array(0)); + driver.FS.writeFile('/lib/wasm32-wasi/crt1-reactor.o', new Uint8Array(0)); + assert.equal(callMain(driver, ['main.cpp', '-std=c++20', '-Wall', '-Wextra', '-fno-exceptions', '-###']), 0); + + const plan = parseCompilePlan(driverOutput); + let compilerOutput = ''; + const compiler = await createTool(globalThis.createClangModule, 'clang.wasm', 'clang++', (line) => { + compilerOutput += `${line}\n`; + }); + compiler.FS.writeFile('main.cpp', source); + setUpSysroot(compiler); + compiler.FS.mkdirTree('/tmp'); + assert.equal(callMain(compiler, plan.compileSteps[0].args), 0, compilerOutput); + + let linkerOutput = ''; + const linker = await createTool(globalThis.createLLDModule, 'lld.wasm', 'wasm-ld', (line) => { + linkerOutput += `${line}\n`; + }); + setUpSysroot(linker); + linker.FS.mkdirTree('/tmp'); + linker.FS.writeFile(plan.compileSteps[0].objectPath, compiler.FS.readFile(plan.compileSteps[0].objectPath)); + + return { status: callMain(linker, plan.linkStep.args), diagnostics: linkerOutput }; +} + +test('e2e: stream insertion of defined int and string return values links without C++ exception symbols', async () => { + const result = await compileAndLink(`#include +#include + +int val() { return 5; } +std::string label() { return "stream"; } + +int main() { + std::cout << val() << ' ' << label() << std::endl; +} +`); + + assert.equal(result.status, 0, result.diagnostics); + assert.doesNotMatch(result.diagnostics, /undefined symbol: __cxa_/); +}); + +test('e2e: an undefined streamed function reports the user symbol at link time', async () => { + const result = await compileAndLink(`#include + +int missing(); + +int main() { + std::cout << missing() << std::endl; +} +`); + + assert.notEqual(result.status, 0); + assert.match(result.diagnostics, /undefined symbol: .*missing/); + assert.doesNotMatch(result.diagnostics, /undefined symbol: __cxa_/); +}); diff --git a/src/workers/compiler.worker.js b/src/workers/compiler.worker.js index e68ca72..f06b5da 100644 --- a/src/workers/compiler.worker.js +++ b/src/workers/compiler.worker.js @@ -379,8 +379,7 @@ async function compile(request) { // The bundled WASI libc++abi is built without C++ exception support. Clang // otherwise enables exceptions for C++ sources, producing unresolved // __cxa_* symbols when stream operations instantiate throwing paths. - // Keep user flags last so an explicit opt-in remains possible. - const userFlags = [`-std=${std}`, '-Wall', '-Wextra', '-fno-exceptions', ...flags]; + const userFlags = [`-std=${std}`, '-Wall', '-Wextra', ...flags, '-fno-exceptions']; // ── Step 1: Build-plan discovery ───────────────────────────────────────── let plan; From 088bd328aaedf58964fe57d155e4c37965f954da Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Mon, 17 Aug 2026 10:36:41 -0700 Subject: [PATCH 3/4] ci: verify compiler linker regression Fetch the WASM toolchain in CI and execute the stream-insertion compiler E2E, including a real WASI run of the linked binary. --- .github/workflows/ci.yml | 6 ++++++ scripts/e2e-compiler-link.test.mjs | 29 ++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6ed7dc..20dc635 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Fetch Clang WASM artifacts + run: npm run fetch-clang + - name: Verify manifest-driven version sync run: npm run version:check @@ -38,5 +41,8 @@ jobs: - name: Run end-to-end session tests run: npm run test:e2e + - name: Run compiler linker end-to-end tests + run: npm run test:e2e:compiler + - name: Run Firefox packaging smoke run: npm run test:browser:firefox diff --git a/scripts/e2e-compiler-link.test.mjs b/scripts/e2e-compiler-link.test.mjs index dbd98d7..2febbb7 100644 --- a/scripts/e2e-compiler-link.test.mjs +++ b/scripts/e2e-compiler-link.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parseCompilePlan } from '../src/workers/compile-plan.mjs'; +import { createWasiRuntime } from '../src/workers/wasi-shim.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const clangDir = path.join(repoRoot, 'dist', 'clang'); @@ -97,7 +98,32 @@ async function compileAndLink(source) { linker.FS.mkdirTree('/tmp'); linker.FS.writeFile(plan.compileSteps[0].objectPath, compiler.FS.readFile(plan.compileSteps[0].objectPath)); - return { status: callMain(linker, plan.linkStep.args), diagnostics: linkerOutput }; + const status = callMain(linker, plan.linkStep.args); + return { + status, + diagnostics: linkerOutput, + output: status === 0 ? linker.FS.readFile(plan.linkStep.outputPath) : null, + }; +} + +async function run(binary) { + let stdout = ''; + const runtime = createWasiRuntime({ + stdin: { mode: 'none' }, + onStdout: (text) => { stdout += text; }, + }); + runtime.initRunVfs(); + const { instance } = await WebAssembly.instantiate(binary, { + wasi_snapshot_preview1: runtime.wasi, + }); + runtime.setMemory(instance.exports.memory); + try { + instance.exports._start(); + } catch (error) { + if (!error?.__wasi_exit__) throw error; + assert.equal(error.code, 0); + } + return stdout; } test('e2e: stream insertion of defined int and string return values links without C++ exception symbols', async () => { @@ -114,6 +140,7 @@ int main() { assert.equal(result.status, 0, result.diagnostics); assert.doesNotMatch(result.diagnostics, /undefined symbol: __cxa_/); + assert.match(await run(result.output), /5 stream/); }); test('e2e: an undefined streamed function reports the user symbol at link time', async () => { From 34a4830731752721759c9c2c07163c2ac01895bd Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Mon, 17 Aug 2026 11:30:17 -0700 Subject: [PATCH 4/4] docs: document unsupported C++ exceptions Explain the no-exception toolchain limitation and release version 0.4.6. --- README.md | 4 ++++ manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 0ce3ad1..00b121d 100644 --- a/README.md +++ b/README.md @@ -523,6 +523,10 @@ Copy the resulting `clang.js` and `clang.wasm` into `dist/clang/`. socket support. - **Standard library**: Only the subset of libc/libc++ compiled into the WASM sysroot is available. +- **C++ exceptions**: `try`, `catch`, and `throw` are not supported. The bundled + WASI C++ runtime has no exception-unwinding support, so use return values, + error-state checks (such as `stream.fail()`), or other non-throwing error + handling instead. - **Execution time**: Long-running programs may trigger the browser's "unresponsive script" dialog. The compiler runs in a dedicated Web Worker to avoid blocking the UI. diff --git a/manifest.json b/manifest.json index aafd69b..db5f739 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "browser.cpp", "short_name": "browser.cpp", "description": "In-browser C++20 IDE powered by Monaco Editor and WASM Clang", - "version": "0.4.2", + "version": "0.4.6", "minimum_chrome_version": "105", "icons": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index 60cd86a..edd59e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browser.cpp", - "version": "0.4.2", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser.cpp", - "version": "0.4.2", + "version": "0.4.6", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/package.json b/package.json index 6b949e5..7033771 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browser.cpp", - "version": "0.4.2", + "version": "0.4.6", "description": "In-browser C++20 IDE with WASM Clang toolchain", "private": true, "scripts": {