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/README.md b/README.md index 5392d6e..b39fb94 100644 --- a/README.md +++ b/README.md @@ -543,6 +543,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 cd72435..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.5", + "version": "0.4.6", "minimum_chrome_version": "105", "icons": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index ba77179..edd59e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browser.cpp", - "version": "0.4.5", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser.cpp", - "version": "0.4.5", + "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 cdadf6d..7033771 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browser.cpp", - "version": "0.4.5", + "version": "0.4.6", "description": "In-browser C++20 IDE with WASM Clang toolchain", "private": true, "scripts": { @@ -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..2febbb7 --- /dev/null +++ b/scripts/e2e-compiler-link.test.mjs @@ -0,0 +1,159 @@ +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'; +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'); + +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)); + + 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 () => { + 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_/); + assert.match(await run(result.output), /5 stream/); +}); + +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/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..f06b5da 100644 --- a/src/workers/compiler.worker.js +++ b/src/workers/compiler.worker.js @@ -376,7 +376,10 @@ 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. + const userFlags = [`-std=${std}`, '-Wall', '-Wextra', ...flags, '-fno-exceptions']; // ── Step 1: Build-plan discovery ───────────────────────────────────────── let plan;