diff --git a/README.md b/README.md index b2711fe..3079b9e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An in-browser **C++20 IDE** delivered as a Chrome / Chromium extension. | File access | File System Access API on Chromium; Firefox fallback flows are deprecated | | File I/O | `fstream` / `ifstream` / `ofstream` – read and write workspace files at runtime | | Standards | C++14 · C++17 · **C++20** (selectable in the toolbar) | +| Memory debugging | Opt-in UBSan diagnostics and hardened libc++ bounds checks | --- @@ -74,9 +75,11 @@ browser.cpp/ │ │ ├── editor.js Monaco editor setup & diagnostic API │ │ ├── terminal.js xterm.js terminal + shell emulator │ │ ├── filesystem.js File System Access API wrapper +│ │ ├── settings.mjs Extension-wide settings + Settings drawer │ │ └── toolbar.js Toolbar buttons & keyboard shortcuts │ └── workers/ -│ └── compiler.worker.js WASM Clang loader, compile, WASI run +│ ├── compiler.worker.js WASM Clang loader, compile, WASI run +│ └── memory-debug.mjs UBSan flags, imports, and diagnostics │ ├── scripts/ │ ├── generate-icons.js Generates PNG extension icons (prebuild) @@ -103,7 +106,8 @@ browser.cpp/ ``` Workspace sources (+ unsaved tab overlay) - │ postMessage {type:'compile', sourcePaths, files, std, flags, outputName} + │ postMessage {type:'compile', sourcePaths, files, std, flags, outputName, + │ memoryDebugMode} ▼ compiler.worker.js ──importScripts──▶ dist/clang/clang.js │ clang++ -### → multi-TU compile plan (one -cc1 per source + wasm-ld) @@ -115,7 +119,7 @@ compiler.worker.js ──importScripts──▶ dist/clang/clang.js │ WebAssembly.instantiate(output.wasm, { wasi_snapshot_preview1: … }) │ ▼ -WASI shim (built into compiler.worker.js) +WASI shim + optional UBSan handler imports (built into compiler.worker.js) │ stdout/stderr streamed back via postMessage ▼ terminal.js → xterm.js display @@ -178,6 +182,26 @@ shim. OS-dependent features are intentionally limited: operations. Locale databases and other platform-specific facilities are also not guaranteed. +### Memory Debug Mode + +Open **Settings** from the top-right toolbar to enable **Memory Debug Mode**. +The setting is extension-wide, saves automatically, defaults to off, and applies +to toolbar, keyboard-shortcut, and terminal `g++`/`clang++` compiles. + +When enabled, browser.cpp adds fatal UndefinedBehaviorSanitizer checks and +libc++ debug hardening. Supported failures print a source-aware diagnostic to +the terminal, preserve output written before the failure, and terminate the +program with a nonzero exit status. For example, statically knowable array +bounds violations are reported by UBSan, while hardened libc++ checks detect +invalid `std::vector::operator[]` access. + +Memory Debug Mode intentionally does not claim full AddressSanitizer coverage. +The current `wasm32-wasi` target does not support ASan, so arbitrary heap +overruns, use-after-free, and every invalid pointer access are not guaranteed to +be detected. Instrumented builds may also be larger and run more slowly. See +[Clang's official UBSan documentation](https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html) +for the sanitizer's checks and reporting behavior. + --- @@ -534,6 +558,9 @@ 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. +- **Memory Debug Mode scope**: UBSan and hardened libc++ catch supported + undefined behavior and standard-library precondition violations, but do not + provide ASan-style heap, use-after-free, or comprehensive pointer checking. - **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 diff --git a/manifest.json b/manifest.json index ba38a42..45240b7 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.11", + "version": "0.5.0", "minimum_chrome_version": "105", "icons": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index ac9dd68..c03a077 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browser.cpp", - "version": "0.4.11", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser.cpp", - "version": "0.4.11", + "version": "0.5.0", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/package.json b/package.json index f741f3e..f88e7bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browser.cpp", - "version": "0.4.11", + "version": "0.5.0", "description": "In-browser C++20 IDE with WASM Clang toolchain", "private": true, "scripts": { @@ -10,8 +10,8 @@ "lint": "eslint .", "build": "npm run build:webpack && npm run build:targets", "build:firefox": "npm run build", - "test:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs 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-workspace-scan-progress.test.mjs scripts/e2e-terminal-gpp-glob.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:e2e": "node --experimental-detect-module --test scripts/e2e-page-lifecycle.test.mjs 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-workspace-scan-progress.test.mjs scripts/e2e-terminal-gpp-glob.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-settings-memory-debug.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 scripts/e2e-memory-debug-toolchain.test.mjs", "test:preflight-clang": "node scripts/preflight-clang-artifacts.js", "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", diff --git a/scripts/e2e-memory-debug-toolchain.test.mjs b/scripts/e2e-memory-debug-toolchain.test.mjs new file mode 100644 index 0000000..cbbeb8c --- /dev/null +++ b/scripts/e2e-memory-debug-toolchain.test.mjs @@ -0,0 +1,227 @@ +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 { + createMemoryDebugImports, + isMemoryDebugRuntimeError, + memoryDebugFlags, + unsupportedMemoryDebugImports, +} from '../src/workers/memory-debug.mjs'; +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'); +const sysroot = fs.readFileSync(path.join(clangDir, 'sysroot.tar')); + +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; + } +} + +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, flags) { + 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', '-fno-exceptions', ...flags, '-###']), 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 runMemoryDebugBinary(binary) { + let stdout = ''; + let stderr = ''; + let instance = null; + let exitCode = 0; + const runtime = createWasiRuntime({ + stdin: { mode: 'none' }, + onStdout: (text) => { stdout += text; }, + onStderr: (text) => { stderr += text; }, + }); + runtime.initRunVfs(); + const module = await WebAssembly.compile(binary); + const memoryDebugImports = createMemoryDebugImports({ + module, + getMemory: () => instance?.exports.memory, + onStderr: (text) => { stderr += text; }, + }); + instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: runtime.wasi, + ...memoryDebugImports, + }); + runtime.setMemory(instance.exports.memory); + + try { + instance.exports._start(); + } catch (error) { + if (error?.__wasi_exit__) exitCode = error.code; + else if (isMemoryDebugRuntimeError(error)) exitCode = error.exitCode; + else if (error instanceof WebAssembly.RuntimeError) exitCode = 134; + else throw error; + } + return { stdout, stderr, exitCode }; +} + +test('memory debug profile enables fatal UBSan checks and libc++ debug hardening', () => { + const flags = memoryDebugFlags(true); + + assert.ok(flags.includes('-fsanitize=undefined')); + assert.ok(flags.includes('-fno-sanitize-recover=all')); + assert.ok(flags.includes('-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG')); + assert.ok(flags.includes('-Wl,--allow-undefined')); + assert.deepEqual(memoryDebugFlags(false), []); +}); + +test('UBSan reports a source-aware array bounds failure and exits nonzero', async () => { + const result = await compileAndLink(`#include + +int main() { + std::cout << "before\\n"; + int values[2] = {1, 2}; + volatile int index = 2; + std::cout << values[index] << '\\n'; +} +`, memoryDebugFlags(true)); + + assert.equal(result.status, 0, result.diagnostics); + const execution = await runMemoryDebugBinary(result.output); + assert.match(execution.stdout, /before/); + assert.match(execution.stderr, /main\.cpp:7:\d+: runtime error: out-of-bounds/); + assert.notEqual(execution.exitCode, 0); +}); + +test('libc++ hardening reports vector subscript failures before trapping', async () => { + const result = await compileAndLink(`#include +#include + +int main() { + std::cout << "before\\n"; + std::vector values = {1, 2}; + std::cout << values[2] << '\\n'; +} +`, memoryDebugFlags(true)); + + assert.equal(result.status, 0, result.diagnostics); + const execution = await runMemoryDebugBinary(result.output); + assert.match(execution.stdout, /before/); + assert.match(execution.stderr, /vector\[\] index out of bounds/); + assert.notEqual(execution.exitCode, 0); +}); + +test('Memory Debug Mode keeps ordinary unresolved user symbols as build failures', async () => { + const result = await compileAndLink(`extern "C" void __ubsan_handle_not_real(); +int missing(); + +int main() { + __ubsan_handle_not_real(); + return missing(); +} +`, memoryDebugFlags(true)); + + assert.equal(result.status, 0, result.diagnostics); + const module = await WebAssembly.compile(result.output); + const unsupported = unsupportedMemoryDebugImports(module); + assert.equal(unsupported.length, 2); + assert.ok(unsupported.some(({ name }) => /missing/.test(name))); + assert.ok(unsupported.some(({ name }) => name === '__ubsan_handle_not_real')); +}); + +test('Memory Debug Mode leaves a valid program runnable', async () => { + const result = await compileAndLink(`#include + +int main() { + std::cout << "safe\\n"; +} +`, memoryDebugFlags(true)); + + assert.equal(result.status, 0, result.diagnostics); + const module = await WebAssembly.compile(result.output); + assert.deepEqual(unsupportedMemoryDebugImports(module), []); + const execution = await runMemoryDebugBinary(result.output); + assert.match(execution.stdout, /safe/); + assert.equal(execution.stderr, ''); + assert.equal(execution.exitCode, 0); +}); diff --git a/scripts/e2e-session-persistence.test.mjs b/scripts/e2e-session-persistence.test.mjs index c467fd3..464cd96 100644 --- a/scripts/e2e-session-persistence.test.mjs +++ b/scripts/e2e-session-persistence.test.mjs @@ -763,6 +763,47 @@ test('e2e: fresh no-workspace state has no open file and cannot compile', async } }); +test('e2e: every assembled compile payload snapshots the extension Memory Debug Mode setting', async () => { + const originalDocument = global.document; + global.document = createFakeDocument(); + try { + const workspace = { + name: 'project', + entries: [{ path: 'main.cpp', kind: 'file' }], + }; + let memoryDebugMode = false; + initToolbar( + { onmessage: null, postMessage() {} }, + { + getValue: () => 'int main() { return 0; }\n', + setValue: () => {}, + clearDiagnostics: () => {}, + setLanguage: () => {}, + }, + { setWorkspace: () => {}, clearTerminal: () => {} }, + { + readWorkspaceFile: async () => 'int main() { return 0; }\n', + readAllWorkspaceFiles: async () => [{ + path: 'main.cpp', + bytes: new TextEncoder().encode('int main() { return 0; }\n'), + }], + }, + { + getCompileOptions: () => ({ memoryDebugMode }), + } + ); + await restoreToolbarWorkspace(workspace, ['main.cpp'], 'main.cpp', { + 'main.cpp': 'int main() { return 0; }\n', + }); + + assert.equal((await assembleCompilePayload({})).memoryDebugMode, false); + memoryDebugMode = true; + assert.equal((await assembleCompilePayload({})).memoryDebugMode, true); + } finally { + global.document = originalDocument; + } +}); + test('e2e: startup gate prevents pre-restore persistence from wiping workspace session', async () => { const storage = createStorageArea(); const handleStore = createHandleStore(); diff --git a/scripts/e2e-settings-memory-debug.test.mjs b/scripts/e2e-settings-memory-debug.test.mjs new file mode 100644 index 0000000..990f06f --- /dev/null +++ b/scripts/e2e-settings-memory-debug.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { + SETTINGS_STORAGE_KEY, + createExtensionSettings, + initSettingsPanel, +} from '../src/ui/settings.mjs'; + +function createPromiseStorage() { + const values = new Map(); + return { + async get(key) { + return { [key]: values.get(key) }; + }, + async set(entries) { + for (const [key, value] of Object.entries(entries)) values.set(key, value); + }, + }; +} + +function createCallbackStorage() { + const values = new Map(); + return { + get(key, callback) { + queueMicrotask(() => callback({ [key]: values.get(key) })); + }, + set(entries, callback) { + for (const [key, value] of Object.entries(entries)) values.set(key, value); + queueMicrotask(() => callback()); + }, + }; +} + +class FakeElement { + constructor() { + this.listeners = new Map(); + this.attributes = new Map(); + this.hidden = true; + this.checked = false; + this.focused = false; + } + + addEventListener(type, listener) { + this.listeners.set(type, listener); + } + + removeEventListener(type) { + this.listeners.delete(type); + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + getAttribute(name) { + return this.attributes.get(name); + } + + focus() { + this.focused = true; + } + + async dispatch(type, event = {}) { + return this.listeners.get(type)?.({ target: this, key: event.key }); + } +} + +function createFakeDocument() { + const ids = [ + 'btn-settings', + 'settings-backdrop', + 'settings-panel', + 'btn-close-settings', + 'setting-memory-debug', + ]; + const elements = new Map(ids.map((id) => [id, new FakeElement()])); + const listeners = new Map(); + return { + getElementById(id) { + return elements.get(id) ?? null; + }, + addEventListener(type, listener) { + listeners.set(type, listener); + }, + removeEventListener(type) { + listeners.delete(type); + }, + async dispatch(type, event = {}) { + return listeners.get(type)?.(event); + }, + }; +} + +for (const [name, createStorage] of [ + ['Promise-style', createPromiseStorage], + ['callback-style', createCallbackStorage], +]) { + test(`${name} settings default off and persist Memory Debug Mode globally`, async () => { + const storage = createStorage(); + const settings = createExtensionSettings({ storage }); + + assert.deepEqual(await settings.load(), { memoryDebugMode: false }); + await settings.setMemoryDebugMode(true); + + assert.deepEqual(settings.get(), { memoryDebugMode: true }); + assert.deepEqual((await new Promise((resolve) => { + const result = storage.get(SETTINGS_STORAGE_KEY, resolve); + if (result?.then) result.then(resolve); + }))[SETTINGS_STORAGE_KEY], { memoryDebugMode: true }); + }); +} + +test('Settings button controls an accessible right-side panel and restores focus', async () => { + const document = createFakeDocument(); + const settings = createExtensionSettings({ storage: createPromiseStorage() }); + await settings.load(); + const controller = initSettingsPanel({ document, settings }); + const button = document.getElementById('btn-settings'); + const panel = document.getElementById('settings-panel'); + const backdrop = document.getElementById('settings-backdrop'); + const closeButton = document.getElementById('btn-close-settings'); + + await button.dispatch('click'); + assert.equal(panel.hidden, false); + assert.equal(backdrop.hidden, false); + assert.equal(button.getAttribute('aria-expanded'), 'true'); + assert.equal(closeButton.focused, true); + + await document.dispatch('keydown', { key: 'Escape' }); + assert.equal(panel.hidden, true); + assert.equal(backdrop.hidden, true); + assert.equal(button.getAttribute('aria-expanded'), 'false'); + assert.equal(button.focused, true); + + controller.destroy(); +}); + +test('Memory Debug Mode toggle saves immediately without a Save button', async () => { + const document = createFakeDocument(); + const settings = createExtensionSettings({ storage: createPromiseStorage() }); + await settings.load(); + initSettingsPanel({ document, settings }); + const toggle = document.getElementById('setting-memory-debug'); + + toggle.checked = true; + await toggle.dispatch('change'); + + assert.equal(settings.get().memoryDebugMode, true); +}); + +test('Settings markup exposes the official UBSan help link in a protected new tab', async () => { + const html = await readFile(new URL('../src/ui/index.html', import.meta.url), 'utf8'); + + assert.match(html, /id="btn-settings"[\s\S]*settings-gear-icon[\s\S]*Settings/); + assert.match(html, /id="settings-panel"/); + assert.match(html, /id="setting-memory-debug"/); + assert.match(html, /href="https:\/\/clang\.llvm\.org\/docs\/UndefinedBehaviorSanitizer\.html"/); + assert.match(html, /target="_blank"/); + assert.match(html, /rel="noopener noreferrer"/); + assert.doesNotMatch(html, /settings-save|Save settings|Apply settings/i); +}); diff --git a/scripts/smoke-browser.mjs b/scripts/smoke-browser.mjs index 956beb7..5cb169c 100644 --- a/scripts/smoke-browser.mjs +++ b/scripts/smoke-browser.mjs @@ -326,7 +326,7 @@ async function openExtensionPage(cdp, extensionId) { await cdp.send('Page.enable', {}, sessionId); await cdp.send('Runtime.enable', {}, sessionId); const navigateResult = await cdp.send('Page.navigate', { - url: `chrome-extension://${extensionId}/index.html`, + url: `chrome-extension://${extensionId}/index.html?smoke-test=1`, }, sessionId); await cdp.waitForEvent( 'Page.loadEventFired', @@ -772,7 +772,7 @@ int main() { }, ])); - await cdp.send('Page.navigate', { url: `${baseUrl}index.html` }, sessionId); + await cdp.send('Page.navigate', { url: `${baseUrl}index.html?smoke-test=1` }, sessionId); await cdp.waitForEvent('Page.loadEventFired', (event) => event.sessionId === sessionId, 60_000); await waitFor(async () => { const href = await evaluate(cdp, sessionId, 'location.href'); @@ -799,6 +799,89 @@ int main() { assert(hasEditor, 'Monaco editor did not render'); if (!realRun) { + const settingsDefault = await evaluate(cdp, sessionId, `(() => { + const button = document.getElementById('btn-settings'); + const panel = document.getElementById('settings-panel'); + const toggle = document.getElementById('setting-memory-debug'); + const help = document.querySelector('.settings-help-link'); + return { + buttonText: button?.textContent?.replace(/\\s+/g, ' ').trim(), + expanded: button?.getAttribute('aria-expanded'), + panelHidden: panel?.hidden, + toggleChecked: toggle?.checked, + helpHref: help?.href, + helpTarget: help?.target, + helpRel: help?.rel, + }; + })()`); + assert(settingsDefault.buttonText?.includes('Settings'), 'Settings button label did not render'); + assert(settingsDefault.expanded === 'false', 'Settings button should initially be collapsed'); + assert(settingsDefault.panelHidden === true, 'Settings panel should initially be hidden'); + assert(settingsDefault.toggleChecked === false, 'Memory Debug Mode should default to off'); + assert( + settingsDefault.helpHref === 'https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html', + `Unexpected UBSan help URL: ${settingsDefault.helpHref}` + ); + assert(settingsDefault.helpTarget === '_blank', 'UBSan help should open in a new tab'); + assert(settingsDefault.helpRel.split(/\s+/).includes('noopener'), 'UBSan help should protect window.opener'); + + const openPanel = await evaluate(cdp, sessionId, `(() => { + document.getElementById('btn-settings').click(); + const panel = document.getElementById('settings-panel'); + const style = getComputedStyle(panel); + return { + hidden: panel.hidden, + ariaHidden: panel.getAttribute('aria-hidden'), + position: style.position, + right: style.right, + }; + })()`); + assert(openPanel.hidden === false && openPanel.ariaHidden === 'false', 'Settings panel did not open'); + assert(openPanel.position === 'fixed' && openPanel.right === '0px', 'Settings panel is not a right-side drawer'); + + const toggleChecked = await evaluate(cdp, sessionId, `(() => { + const toggle = document.getElementById('setting-memory-debug'); + toggle.click(); + return toggle.checked; + })()`); + assert(toggleChecked === true, 'Memory Debug Mode toggle did not switch on'); + let storedSettings; + try { + storedSettings = await waitFor(async () => evaluate( + cdp, + sessionId, + `chrome.storage.local.get('browser_cpp_settings').then((result) => ( + result.browser_cpp_settings?.memoryDebugMode === true + ? result.browser_cpp_settings + : null + ))`, + { awaitPromise: true } + ), 'Memory Debug Mode persistence', 10_000); + } catch (error) { + const persistenceState = await evaluate( + cdp, + sessionId, + `(async () => ({ + checked: document.getElementById('setting-memory-debug')?.checked, + chromeStored: (await chrome.storage.local.get('browser_cpp_settings')).browser_cpp_settings, + browserNamespace: typeof globalThis.browser, + browserHasStorage: !!globalThis.browser?.storage?.local, + }))()`, + { awaitPromise: true } + ); + throw new Error( + `${error.message}; state=${JSON.stringify(persistenceState)}; console=${consoleErrors.join(' | ') || ''}` + ); + } + assert(storedSettings.memoryDebugMode === true, 'Memory Debug Mode did not save automatically'); + + const closedWithEscape = await evaluate(cdp, sessionId, `(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + const panel = document.getElementById('settings-panel'); + return panel.hidden && document.activeElement?.id === 'btn-settings'; + })()`); + assert(closedWithEscape, 'Escape did not close Settings and restore button focus'); + await evaluate(cdp, sessionId, `(() => { const status = document.getElementById('status-compiler'); if (status) status.textContent = 'Compiler ready'; @@ -838,7 +921,11 @@ int main() { await waitFor(async () => { return evaluate(cdp, sessionId, `globalThis.__browserCppTestFs.exists('output.txt')`); }, 'hosted runtime-created file', 180_000); - terminalText = await evaluate(cdp, sessionId, `document.getElementById('terminal-container')?.textContent || ''`); + terminalText = await evaluate( + cdp, + sessionId, + `document.getElementById('terminal-container')?.__browserCppReadTerminal?.() || ''` + ); } catch (err) { const status = await evaluate(cdp, sessionId, `document.getElementById('status-compiler')?.textContent || ''`); const terminal = await evaluate(cdp, sessionId, `document.getElementById('terminal-container')?.textContent || ''`); @@ -1002,7 +1089,11 @@ int main() { try { await waitFor(async () => { - const text = await evaluate(cdp, sessionId, `document.body.textContent || ''`); + const text = await evaluate( + cdp, + sessionId, + `document.getElementById('terminal-container')?.__browserCppReadTerminal?.() || ''` + ); return text.includes('Compilation successful.') && text.includes('5 stream') ? text : null; }, 'stream insertion compile-and-run output', 120_000); } catch (err) { @@ -1061,6 +1152,7 @@ async function main() { try { await cdp.send('Target.setDiscoverTargets', { discover: true }); const version = await cdp.send('Browser.getVersion'); + await runHostedSmoke(cdp); let extensionId; try { extensionId = await discoverExtensionId(cdp, userDataDir, extensionDir); diff --git a/specs/issue-104-memory-debug-mode-settings-panel-20261008.md b/specs/issue-104-memory-debug-mode-settings-panel-20261008.md new file mode 100644 index 0000000..0d4d232 --- /dev/null +++ b/specs/issue-104-memory-debug-mode-settings-panel-20261008.md @@ -0,0 +1,233 @@ +# Feature: Issue 104 Memory Debug Mode Settings Panel + +## Feature Description +Add an extension-wide Settings entry in the top-right toolbar that opens a right-side panel. The panel contains automatically saved on/off settings, beginning with **Memory Debug Mode**, which defaults to off. When Memory Debug Mode is on, every compilation path in browser.cpp is built with memory-error instrumentation so unsafe runtime memory access terminates the running program with a sanitizer-style diagnostic in the terminal while preserving any stdout/stderr already printed by the program. + +This feature addresses GitHub Issue #104: runtime out-of-bounds memory errors currently continue silently in the WASI/WebAssembly runtime, including unsafe vector access patterns that users expect to fail loudly while debugging. + +## User Story +As a browser.cpp user debugging C++ programs +I want an extension-wide Memory Debug Mode that instruments every compile +So that unsafe memory access terminates the program with a clear terminal diagnostic instead of silently reading invalid memory. + +## Problem Statement +browser.cpp currently compiles C++ into WASI WebAssembly and runs it in a worker with a narrow WASI shim. Ordinary C++ undefined behavior such as `nums[2]` can read from valid WebAssembly linear memory and therefore will not necessarily produce a native WebAssembly bounds trap. Issue #104 asks for Linux-like runtime memory failures and terminal errors, but the current compile pipeline does not add sanitizer flags, does not package sanitizer runtime support, and only formats generic WebAssembly traps as `Runtime error: ...`. + +The confirmed product decisions add a global Settings UI requirement: a top-right gear icon labelled `Settings`, a hamburger-like right-side panel, extension-wide persistence, auto-saving toggle controls, Memory Debug Mode default off, and no interruption of an already-running compile. + +## Solution Statement +Implement Memory Debug Mode as a persisted extension setting loaded during app startup and passed into every compile request. When enabled, the compiler worker applies a single centrally defined instrumentation profile to both toolbar/shortcut compiles and terminal `g++`/`clang++` compiles. Toolchain validation confirmed that current `wasm32-unknown-wasi` Clang rejects `-fsanitize=address`, while `-fsanitize=undefined` successfully emits instrumentation but the sysroot lacks `__ubsan_handle_*` implementations. The selected design supplies fatal, source-aware UBSan handlers as validated WebAssembly imports from the compiler worker and enables libc++ debug hardening for checked container operations such as `std::vector::operator[]`. The finished module is inspected after linking so `--allow-undefined` cannot conceal ordinary unresolved user symbols. + +Runtime diagnostics should flow through the existing worker `stderr` messages before `run-result`, preserving prior program output. If a sanitizer or WebAssembly runtime error occurs, the worker should terminate the run with a nonzero exit code, and the terminal should show the diagnostic followed by the existing `Process exited with code ...` line. + +## Relevant Files +Use these files to implement the feature: + +- `README.md` - Project architecture, compile/run pipeline, browser targets, release checks, and current runtime limitations that must be updated if Memory Debug Mode ships. +- `src/ui/index.html` - Toolbar markup for the top-right `Settings` button and the right-side settings panel shell. +- `src/ui/styles.css` - Existing dark-theme toolbar, panel, and dialog styling; add right drawer and toggle styling here. +- `src/ui/app.js` - Startup orchestration for loading extension settings, initializing the settings panel, and passing the latest setting into toolbar and terminal compile callbacks. +- `src/ui/toolbar.js` - Main compile actions, keyboard shortcuts, worker message handling, and toolbar compile payload assembly. +- `src/ui/terminal.js` - Terminal `g++`/`clang++` command flow and test harness; must ensure terminal compiles use the same Memory Debug Mode flag as toolbar compiles. +- `src/ui/build-request.mjs` - Shared compile request helpers; likely place to keep request shape or normalization logic browser-free for Node E2E tests. +- `src/ui/session-persistence.mjs` - Existing `chrome.storage.local` Promise/callback compatibility pattern to mirror, not extend with project-specific settings. +- `src/extension-api.mjs` - Existing extension API helper used to access `chrome`/`browser`; settings persistence should reuse this boundary. +- `src/workers/compiler.worker.js` - Central compile/link/run implementation; add instrumentation flags, toolchain capability reporting, and sanitizer/runtime diagnostic handling. +- `src/workers/compile-plan.mjs` - Driver plan parser; ensure sanitizer flags and extra link libraries survive `clang++ -###` plan parsing. +- `src/workers/wasi-shim.mjs` - WASI import layer; verify sanitizer-instrumented binaries do not need unsupported WASI imports, and add any narrow required imports only after explicit feasibility proof. +- `scripts/fetch-clang-wasm.js` - Fetches `clang`, `lld`, and `sysroot`; inspected during feasibility work, but no artifact change is required by the selected imported-handler design. +- `scripts/preflight-clang-artifacts.js` - Existing toolchain artifact validation; no sanitizer archive check is needed because the selected runtime support is implemented in JavaScript. +- `scripts/e2e-compiler-link.test.mjs` - Existing real Clang/LLD Node E2E harness; extend or model new sanitizer feasibility and runtime tests after this. +- `scripts/smoke-browser.mjs` - Browser smoke runner; add a focused smoke assertion that the Settings UI renders and can toggle Memory Debug Mode if practical. +- `.github/workflows/ci.yml` - Ensure any new E2E file is included through `npm run test:e2e` or targeted scripts. +- `package.json` - Add any new E2E test file to `test:e2e` and possibly add a dedicated sanitizer/toolchain validation script. + +### New Files +- `src/ui/settings.mjs` - Encapsulate extension-wide settings load/save, DOM binding for the right-side panel, auto-saving toggles, and change notifications. +- `scripts/e2e-settings-memory-debug.test.mjs` - Separate E2E test file for the Settings UI and compile request propagation, as required for UI-affecting work. +- `scripts/e2e-memory-debug-toolchain.test.mjs` - Real toolchain/runtime validation for sanitizer-instrumented builds, including the Issue #104 out-of-bounds sample. + +## Implementation Plan +### Phase 1: Foundation +Validate toolchain feasibility before UI work. Treat ASan as unavailable for the current target unless a future toolchain explicitly proves otherwise. Prove the UBSan WebAssembly-import runtime by compiling, linking, instantiating, and running both failing and valid programs with the current browsercc artifacts. Require source-aware reports and nonzero termination; generic trap-only reporting is not an accepted fallback. + +Create an extension-wide settings module that mirrors the storage compatibility patterns from `session-persistence.mjs`, but stores settings under a separate key such as `browser_cpp_settings`. Keep the default object versioned and explicit, with `memoryDebugMode: false`. + +### Phase 2: Core Implementation +Add the top-right `Settings` button with a gear icon and label. Add a right-side settings panel that opens/closes like a hamburger drawer and contains toggle controls that save immediately. Wire the panel to load settings on startup, update controls from persisted state, and notify compile callers when Memory Debug Mode changes. A toggle change applies to future compile requests only. + +Thread `memoryDebugMode` into every compile request: toolbar Compile, Compile & Run, keyboard shortcuts, and terminal `g++`/`clang++`. Centralize the compile option so all entry paths produce identical worker requests and tests can verify there is no bypass. + +In the worker, derive debug instrumentation flags from `request.memoryDebugMode`. Use fatal UBSan instrumentation, libc++ debug hardening, and WebAssembly-imported UBSan handlers because current ASan is rejected for `wasm32-unknown-wasi`. Keep the diagnostic profile centralized, validate all non-WASI imports in the finished module, and reject any import that is not a known UBSan handler. + +### Phase 3: Integration +Format sanitizer and runtime failures in the terminal without clearing existing output. Existing stdout/stderr streaming already preserves prior output; the worker should emit sanitizer stderr before `run-result`, and terminal `onRunResult` should keep displaying nonzero exit codes. If sanitizer output lacks source lines or stack traces under WASI, surface the best available error type/address and document the limitation rather than inventing detail. + +Update documentation to explain Memory Debug Mode, performance/size overhead, and known diagnostic limitations. Add E2E coverage for storage/UI behavior, compile-request propagation across every entry path, and real sanitizer behavior with the bundled or updated toolchain. + +## Step by Step Tasks +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Create a feature branch and confirm Issue #104 remains the source of truth +- Create a branch such as `feature/issue-104-memory-debug-mode`. +- Re-read GitHub Issue #104 and its confirmation comment before implementation. +- Plan for the implementation PR to include `Closes #104`. +- Do not update unrelated spec files or untracked work already present in the repository. + +### 2. Add a memory-debug feasibility test before UI work +- Create `scripts/e2e-memory-debug-toolchain.test.mjs` from the existing `scripts/e2e-compiler-link.test.mjs` pattern. +- Compile and link small programs with the proposed instrumentation flags. +- Explicitly assert that current `-fsanitize=address` is not selected unless the toolchain supports it; local review observed `unsupported option '-fsanitize=address' for target 'wasm32-unknown-wasi'`. +- Run programs that perform raw-array and vector out-of-bounds access and assert the exact termination and diagnostic behavior the selected profile claims to support. Record arbitrary heap overruns as outside the supported UBSan/libc++ hardening scope because ASan is unavailable. +- Assert that a normal program still compiles and runs when instrumentation is enabled. +- If non-trap UBSan-style linking fails because sanitizer runtime libraries are missing, stop implementation and resolve the toolchain packaging task next, unless the issue owner has accepted a narrower trap/hardening fallback. + +### 3. Supply and validate UBSan handlers as WebAssembly imports +- Inspect the exact missing UBSan symbols emitted by the current Clang/LLD toolchain. +- Implement the required fatal `__ubsan_handle_*` functions in the compiler worker's JavaScript import object. +- Decode UBSan source metadata from WebAssembly memory and print an error category plus file, line, and column before terminating nonzero. +- Link instrumented programs with unresolved imports enabled, then inspect the finished module and reject every non-WASI import that is not an explicitly supported UBSan handler. This prevents the link option from concealing ordinary unresolved user symbols. +- Re-run the feasibility test until both failing and valid instrumented programs build and run as expected. +- Keep the existing fetched toolchain and sysroot unchanged; this approach avoids shipping an unavailable compiler-rt sanitizer archive. + +### 4. Add extension-wide settings persistence +- Create `src/ui/settings.mjs`. +- Implement load, current-value lookup, `setMemoryDebugMode()`, and a small subscription/change callback API. +- Use `chrome.storage.local`/`browser.storage.local` through `getExtensionAPI()`. +- Default to `{ memoryDebugMode: false }` when storage is unavailable or the setting is missing. +- Keep this storage separate from `browser_cpp_session` so it is extension-wide, not project-specific. + +### 5. Add the Settings button and right-side panel UI +- Update `src/ui/index.html` with a top-right `button#btn-settings` containing a gear icon and the label `Settings`. +- Place the button after existing build/status controls so it visually sits at the far right. +- Add a `section#settings-panel` or equivalent right-side drawer with an accessible title and close behavior. +- Add the first setting row for `Memory Debug Mode` using a true toggle/checkbox semantics, not a save/apply form. +- Add a `(?)` help link to Clang's official UBSan documentation, opening with `target="_blank"` and `rel="noopener noreferrer"`. +- Ensure the panel can be opened and closed by the button, outside click or close control, and Escape. + +### 6. Style the Settings drawer and toggles +- Update `src/ui/styles.css` to match the current compact VS Code-inspired dark UI. +- Keep the drawer fixed to the right side above the status bar or full height according to the existing layout constraints. +- Make the toggle state visually clear and keyboard-focusable. +- Avoid layout shifts in the toolbar when the panel opens. + +### 7. Wire settings into app startup +- In `src/ui/app.js`, load settings before initializing compile controls that need them. +- Initialize `settings.mjs` with the DOM elements for the button, panel, and toggle. +- Keep an in-memory latest-settings value for compile callbacks. +- Apply toggle changes to later compiles only; do not terminate or mutate an active compile/run. + +### 8. Thread Memory Debug Mode through every compile entry path +- Update toolbar compile payload assembly so `memoryDebugMode` is included in `worker.postMessage({ type: 'compile', ... })`. +- Update terminal `onCompile` in `app.js` to add the same setting to terminal `g++`/`clang++` requests. +- Verify F5, Ctrl+Shift+B, Compile, Compile and Run, terminal `g++`, and terminal `clang++` all pass the same setting. +- Keep user-supplied terminal flags intact while ensuring Memory Debug Mode cannot be bypassed accidentally when enabled. + +### 9. Add worker instrumentation support +- Update `src/workers/compiler.worker.js` request handling and comments to include `memoryDebugMode`. +- Add a centralized function such as `memoryDebugFlagsFor(request)` that returns instrumentation compile/link flags only when enabled. +- Include debug-friendly flags such as debug info and frame pointers if supported by the WASI target. +- Preserve existing `-fno-exceptions` unless the selected sanitizer/runtime explicitly requires a different strategy. +- Ensure `getCompilePlan()`, per-TU compile steps, and final link all receive the instrumentation consistently. + +### 10. Format runtime memory diagnostics +- Preserve raw sanitizer stderr output from the program. +- Add a small worker/terminal formatting layer only if needed to prepend a concise browser.cpp header such as `Memory Debug Mode detected a runtime memory error`. +- Keep prior stdout visible before the diagnostic. +- Ensure detected sanitizer failures result in a nonzero exit code and do not write misleading success messages. +- If only a generic WebAssembly trap is available, print the trap and nonzero exit, but treat that as insufficient for the Issue #104 acceptance test unless the issue owner accepts the limitation. + +### 11. Create the separate Settings/Memory Debug E2E file +- Create `scripts/e2e-settings-memory-debug.test.mjs`. +- Test that default settings load with Memory Debug Mode off. +- Test toggling Memory Debug Mode saves immediately to fake extension storage without a Save button. +- Test the Settings gear+label button opens and closes the right-side panel. +- Test toolbar and terminal compile callbacks include `memoryDebugMode: true` after the toggle changes. +- Include a DOM-level screenshot/assertion hook if the existing browser smoke harness supports it; otherwise assert markup and state in Node harnesses. + +### 12. Add worker/toolchain runtime tests +- Extend `scripts/e2e-memory-debug-toolchain.test.mjs` to cover: + - the centrally defined debug profile contains fatal UBSan and libc++ hardening flags; + - debug on: unsafe raw-array access terminates nonzero with source-aware output; + - debug on: invalid `std::vector::operator[]` access terminates through libc++ hardening; + - prior stdout is preserved before the memory diagnostic; + - ordinary unresolved user symbols are rejected rather than accepted as imports; + - a valid instrumented program still runs normally. +- Cover compile-payload propagation separately so toolbar, keyboard, and terminal entry points cannot bypass the setting. + +### 13. Update documentation and help text +- Update `README.md` with the Settings panel and Memory Debug Mode behavior. +- Document that Memory Debug Mode increases build size and runtime overhead. +- Document known diagnostic limitations if source lines or stack traces are unavailable in the WASI/browser runtime. +- Link the Settings help icon to Clang's official UBSan documentation in a new tab. +- Update any terminal help only if users need to understand that terminal compiles also honor the setting. +- Align `manifest.json`, `package.json`, and `package-lock.json` at the requested minor version `0.5.0`. + +### 14. Run the Validation Commands +- Execute every command listed below. +- Fix any failure before opening the PR. +- Confirm the PR description references `Closes #104`. + +## Testing Strategy +### Unit Tests +- Storage module tests for default values, Promise-style storage, callback-style storage, failed storage fallback, and immediate save on toggle. +- Compile-request tests confirming toolbar and terminal paths include `memoryDebugMode`. +- Worker request tests confirming instrumentation flags are injected only when enabled and apply to all translation units and link steps. +- Compile-plan tests confirming sanitizer-related driver output is parsed without dropping compile or link steps. + +### Edge Cases +- Storage unavailable outside an extension context: setting defaults off and UI remains usable. +- Setting changed during an active compile or run: active work continues unchanged; next compile uses the new value. +- Terminal user passes conflicting flags: Memory Debug Mode should still instrument unless explicitly documented otherwise. +- Multi-file workspace compile: every translation unit is instrumented. +- No workspace single-buffer compile: Memory Debug Mode still applies. +- Sanitizer runtime emits diagnostics on stderr before exiting: terminal preserves prior stdout/stderr ordering as much as the worker stream allows. +- Sanitizer runtime lacks source lines or stack traces: terminal shows available details without fabricating unsupported fields. +- Instrumented module references an unknown or user-defined import: post-link validation fails the build before execution. + +## Acceptance Criteria +- Top-right toolbar contains a gear icon and visible `Settings` label. +- Clicking `Settings` opens a right-side settings panel; the panel can be closed and is keyboard accessible. +- Panel contains a `Memory Debug Mode` on/off toggle as the first setting. +- Memory Debug Mode defaults to off for fresh storage. +- Toggling Memory Debug Mode saves automatically to extension-wide storage with no Save or Apply button. +- The setting is not tied to a project, workspace folder, or session restore snapshot. +- The setting row includes a `(?)` link to the official Clang UBSan documentation that opens in a new tab without exposing `window.opener`. +- When enabled, toolbar Compile, Compile and Run, F5, Ctrl+Shift+B, terminal `g++`, and terminal `clang++` all produce instrumented builds. +- The setting applies to future compiles and does not interrupt currently running compiles/runs. +- Unsafe out-of-bounds memory access in the Issue #104 sample terminates nonzero when Memory Debug Mode is enabled, subject to the validated sanitizer/runtime support. +- Terminal output preserves program output printed before the memory failure. +- Terminal shows a detailed diagnostic with error type plus address/source/stack details where supported by the toolchain. +- Existing non-debug compiles, runs, file I/O, stdin, and multi-file builds continue to pass. +- Release metadata is aligned at version `0.5.0`. + +## Validation Commands +Execute every command to validate the feature works correctly with zero regressions. + +```bash +npm ci +npm run fetch-clang +npm run lint +npm run build +npm run test:e2e +node --experimental-detect-module --test scripts/e2e-settings-memory-debug.test.mjs +node --experimental-detect-module --test scripts/e2e-memory-debug-toolchain.test.mjs +npm run test:e2e:compiler +npm run test:browser:chrome +``` + +Because this is a minor-version release, also run: + +```bash +npm run package:release +``` + +## Notes +- Inspection of `dist/clang/sysroot.tar` found `lib/clang/20/lib/wasm32-unknown-wasi/libclang_rt.builtins.a` but no obvious `libclang_rt.asan*`, `ubsan`, or sanitizer runtime archive. Feasibility testing established: + - `-fsanitize=address` fails at driver time with `unsupported option '-fsanitize=address' for target 'wasm32-unknown-wasi'`. + - `-fsanitize=undefined` emits calls to UBSan handler symbols such as `__ubsan_handle_out_of_bounds`; browser.cpp now fulfills the supported handler set through validated WebAssembly imports. + - `-fsanitize=bounds -fsanitize-trap=bounds` links and terminates a local array out-of-bounds example, but produces only a generic WebAssembly trap. + - `_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE` catches `std::vector::operator[]` in the issue sample, but also presents as a generic trap. +- WebAssembly traps alone are not enough for Issue #104 because many C++ out-of-bounds accesses stay within linear memory and will not trap. +- `std::vector::at()` behavior may depend on libc++ exception/assertion support. The project currently compiles with `-fno-exceptions` because the bundled WASI libc++abi lacks exception support, so Memory Debug Mode should focus on sanitizer/hardening behavior that is actually validated in this runtime. +- The implementation PR should be opened from `feature/issue-104-memory-debug-mode`, include `Closes #104`, and release version `0.5.0`. diff --git a/src/ui/app.js b/src/ui/app.js index 9421b20..f8ae3e6 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -34,6 +34,7 @@ import { createSessionPersistence, createPersistenceGate } from './session-persi import { registerPageUnload } from './page-lifecycle.mjs'; import { getExtensionVersionLabel } from '../extension-api.mjs'; import { selectRunBinaryBytes } from './build-request.mjs'; +import { createExtensionSettings, initSettingsPanel } from './settings.mjs'; // ── Boot ────────────────────────────────────────────────────────────────────── @@ -41,6 +42,10 @@ window.addEventListener('DOMContentLoaded', async () => { const versionStatus = document.getElementById('status-version'); if (versionStatus) versionStatus.textContent = getExtensionVersionLabel(); + const extensionSettings = createExtensionSettings(); + await extensionSettings.load(); + initSettingsPanel({ document, settings: extensionSettings }); + // 1. Monaco editor const editorContainer = document.getElementById('editor-container'); editorAPI.createEditor(editorContainer); @@ -137,6 +142,7 @@ window.addEventListener('DOMContentLoaded', async () => { persistState: () => persistenceGate.persistState(), persistWorkspace: () => persistenceGate.persistWorkspace(), scheduleState: () => persistenceGate.scheduleState(), + getCompileOptions: () => extensionSettings.get(), }); resetToNewProject(); diff --git a/src/ui/index.html b/src/ui/index.html index 6a322ed..f4b4974 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -36,8 +36,65 @@
+ + + +
diff --git a/src/ui/settings.mjs b/src/ui/settings.mjs new file mode 100644 index 0000000..e155ff5 --- /dev/null +++ b/src/ui/settings.mjs @@ -0,0 +1,143 @@ +'use strict'; + +import { getExtensionAPI, getExtensionRuntimeError } from '../extension-api.mjs'; + +export const SETTINGS_STORAGE_KEY = 'browser_cpp_settings'; +export const DEFAULT_SETTINGS = Object.freeze({ memoryDebugMode: false }); + +function getStorageArea() { + return getExtensionAPI()?.storage?.local ?? null; +} + +function sanitizeSettings(value) { + return { + memoryDebugMode: value?.memoryDebugMode === true, + }; +} + +function storageGet(storage, key) { + if (!storage?.get) return Promise.resolve({}); + if (storage.get.length >= 2) { + return new Promise((resolve, reject) => { + storage.get(key, (value) => { + const error = getExtensionRuntimeError(); + if (error) reject(new Error(error.message || String(error))); + else resolve(value ?? {}); + }); + }); + } + return Promise.resolve(storage.get(key)); +} + +function storageSet(storage, value) { + if (!storage?.set) return Promise.resolve(); + if (storage.set.length >= 2) { + return new Promise((resolve, reject) => { + storage.set(value, () => { + const error = getExtensionRuntimeError(); + if (error) reject(new Error(error.message || String(error))); + else resolve(); + }); + }); + } + return Promise.resolve(storage.set(value)); +} + +/** Create extension-wide, project-independent settings backed by storage.local. */ +export function createExtensionSettings({ + storage = getStorageArea(), + onError = (error) => console.warn('[browser.cpp] Could not persist settings:', error), +} = {}) { + let current = { ...DEFAULT_SETTINGS }; + const subscribers = new Set(); + + function notify() { + const snapshot = { ...current }; + for (const subscriber of subscribers) subscriber(snapshot); + } + + return { + async load() { + try { + const stored = await storageGet(storage, SETTINGS_STORAGE_KEY); + current = sanitizeSettings(stored?.[SETTINGS_STORAGE_KEY]); + } catch (error) { + current = { ...DEFAULT_SETTINGS }; + onError(error); + } + notify(); + return { ...current }; + }, + + get() { + return { ...current }; + }, + + async setMemoryDebugMode(enabled) { + current = { ...current, memoryDebugMode: enabled === true }; + notify(); + try { + await storageSet(storage, { [SETTINGS_STORAGE_KEY]: { ...current } }); + } catch (error) { + onError(error); + } + return { ...current }; + }, + + subscribe(subscriber) { + subscribers.add(subscriber); + return () => subscribers.delete(subscriber); + }, + }; +} + +/** Bind the static Settings drawer markup to extension settings. */ +export function initSettingsPanel({ document, settings }) { + const button = document.getElementById('btn-settings'); + const backdrop = document.getElementById('settings-backdrop'); + const panel = document.getElementById('settings-panel'); + const closeButton = document.getElementById('btn-close-settings'); + const memoryDebugToggle = document.getElementById('setting-memory-debug'); + + if (!button || !backdrop || !panel || !closeButton || !memoryDebugToggle) { + return { destroy() {} }; + } + + const setOpen = (open, restoreFocus = false) => { + panel.hidden = !open; + backdrop.hidden = !open; + panel.setAttribute('aria-hidden', String(!open)); + button.setAttribute('aria-expanded', String(open)); + if (open) closeButton.focus(); + else if (restoreFocus) button.focus(); + }; + const onButtonClick = () => setOpen(panel.hidden, panel.hidden === false); + const onCloseClick = () => setOpen(false, true); + const onBackdropClick = () => setOpen(false, true); + const onKeyDown = (event) => { + if (event.key === 'Escape' && !panel.hidden) setOpen(false, true); + }; + const onToggleChange = () => settings.setMemoryDebugMode(memoryDebugToggle.checked); + const unsubscribe = settings.subscribe(({ memoryDebugMode }) => { + memoryDebugToggle.checked = memoryDebugMode; + }); + + memoryDebugToggle.checked = settings.get().memoryDebugMode; + setOpen(false); + button.addEventListener('click', onButtonClick); + closeButton.addEventListener('click', onCloseClick); + backdrop.addEventListener('click', onBackdropClick); + memoryDebugToggle.addEventListener('change', onToggleChange); + document.addEventListener('keydown', onKeyDown); + + return { + destroy() { + unsubscribe(); + button.removeEventListener('click', onButtonClick); + closeButton.removeEventListener('click', onCloseClick); + backdrop.removeEventListener('click', onBackdropClick); + memoryDebugToggle.removeEventListener('change', onToggleChange); + document.removeEventListener('keydown', onKeyDown); + }, + }; +} diff --git a/src/ui/styles.css b/src/ui/styles.css index 226f35e..abb7437 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -40,6 +40,10 @@ body { overflow: hidden; } +[hidden] { + display: none !important; +} + #app { display: flex; flex-direction: column; @@ -157,6 +161,152 @@ select:focus { text-align: right; } +#btn-settings { + flex: 0 0 auto; +} + +/* ── Extension settings drawer ───────────────────────────────────────────── */ + +.settings-backdrop { + position: fixed; + inset: var(--toolbar-h) 0 var(--statusbar-h) 0; + z-index: 30; + background: rgb(17 17 27 / 55%); +} + +.settings-panel { + position: fixed; + top: var(--toolbar-h); + right: 0; + bottom: var(--statusbar-h); + z-index: 31; + display: flex; + width: min(360px, 100vw); + flex-direction: column; + background: var(--mantle); + border-left: 1px solid var(--surface1); + box-shadow: -8px 0 24px rgb(0 0 0 / 30%); +} + +.settings-panel-header { + display: flex; + min-height: 44px; + align-items: center; + justify-content: space-between; + padding: 0 12px 0 16px; + border-bottom: 1px solid var(--surface0); +} + +.settings-panel-header h2 { + font-size: 14px; + font-weight: 600; +} + +#btn-close-settings { + padding: 4px; + background: transparent; + font-size: 16px; +} + +.settings-panel-content { + overflow-y: auto; + padding: 16px; +} + +.settings-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; +} + +.settings-copy { + min-width: 0; +} + +.settings-label-line { + display: flex; + align-items: center; + gap: 6px; + font-weight: 600; +} + +.settings-copy p { + margin-top: 6px; + color: var(--subtext0); + font-size: 12px; + line-height: 1.45; +} + +.settings-help-link { + display: inline-flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + border: 1px solid var(--overlay0); + border-radius: 50%; + color: var(--blue); + font-size: 11px; + line-height: 1; + text-decoration: none; +} + +.settings-help-link:hover, +.settings-help-link:focus-visible { + border-color: var(--blue); + outline: none; + color: var(--text); +} + +.settings-switch { + position: relative; + flex: 0 0 auto; + cursor: pointer; +} + +.settings-switch input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +} + +.settings-switch-track { + display: block; + width: 34px; + height: 18px; + padding: 2px; + border: 1px solid var(--overlay0); + border-radius: 9px; + background: var(--surface0); +} + +.settings-switch-track::after { + display: block; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--subtext0); + content: ''; + transition: transform 0.12s ease, background 0.12s ease; +} + +.settings-switch input:checked + .settings-switch-track { + border-color: var(--green); + background: var(--green); +} + +.settings-switch input:checked + .settings-switch-track::after { + transform: translateX(16px); + background: var(--crust); +} + +.settings-switch input:focus-visible + .settings-switch-track { + outline: 2px solid var(--blue); + outline-offset: 2px; +} + /* ── Main layout ──────────────────────────────────────────────────────────── */ #main { diff --git a/src/ui/terminal.js b/src/ui/terminal.js index 8941529..f6e8900 100644 --- a/src/ui/terminal.js +++ b/src/ui/terminal.js @@ -343,6 +343,21 @@ export function createTerminal(container, { term.open(container); fitAddon.fit(); + // Browser smoke tests need the logical terminal buffer because xterm may + // render through canvas, where container.textContent contains only styles. + // Keep the hook opt-in so normal extension pages expose no test surface. + if (new URLSearchParams(globalThis.location?.search || '').has('smoke-test')) { + container.__browserCppReadTerminal = () => { + const buffer = term?.buffer?.active; + if (!buffer) return ''; + const lines = []; + for (let index = 0; index < buffer.length; index += 1) { + lines.push(buffer.getLine(index)?.translateToString(true) || ''); + } + return lines.join('\n'); + }; + } + // Welcome banner term.write( `${C.cyan}${C.bold}browser.cpp${C.reset} – C++20 WASM terminal${CRLF}` + diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index 8388232..4189cc1 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -58,6 +58,7 @@ let _loadingFile = false; let _persistSession = null; let _persistWorkspaceSession = null; let _schedulePersistSession = null; +let _getCompileOptions = () => ({ memoryDebugMode: false }); let _persistTimer = null; function describeRuntimeWritebackIssue(reason) { @@ -108,10 +109,12 @@ export function initToolbar(worker, editorAPI, terminalAPI, fsAPI, persistence) _persistSession = persistence; _persistWorkspaceSession = persistence; _schedulePersistSession = null; + _getCompileOptions = () => ({ memoryDebugMode: false }); } else { _persistSession = persistence?.persistState ?? null; _persistWorkspaceSession = persistence?.persistWorkspace ?? _persistSession; _schedulePersistSession = persistence?.scheduleState ?? null; + _getCompileOptions = persistence?.getCompileOptions ?? (() => ({ memoryDebugMode: false })); } bindButtons(); @@ -742,6 +745,7 @@ async function actionCompileRun() { * @returns {Promise} worker `compile` message payload */ export async function assembleCompilePayload({ sourcePaths = null, std, flags = [], outputName = null }) { + const memoryDebugMode = _getCompileOptions()?.memoryDebugMode === true; // Snapshot the active editor buffer so unsaved edits to the focused tab build. if (_activeTabPath !== null && _openTabs.has(_activeTabPath)) { _openTabs.get(_activeTabPath).content = _editorAPI.getValue(); @@ -768,6 +772,7 @@ export async function assembleCompilePayload({ sourcePaths = null, std, flags = std: resolvedStd, flags, outputName, + memoryDebugMode, primarySourcePath: primarySourcePath ? normalizeOverlayPath(primarySourcePath) : null, }; } diff --git a/src/workers/compiler.worker.js b/src/workers/compiler.worker.js index f06b5da..1db4930 100644 --- a/src/workers/compiler.worker.js +++ b/src/workers/compiler.worker.js @@ -49,6 +49,12 @@ import { parseCompilePlan } from './compile-plan.mjs'; import { parseDiagnostics } from '../ui/diagnostics.mjs'; import { createWasiRuntime } from './wasi-shim.mjs'; +import { + createMemoryDebugImports, + isMemoryDebugRuntimeError, + memoryDebugFlags, + unsupportedMemoryDebugImports, +} from './memory-debug.mjs'; import { validateRunRequest, validateStdinMessage } from './run-request.mjs'; import { createStdinSessionRouter, @@ -357,6 +363,7 @@ function populateCompileFs(moduleFs, files) { * files: Array<{path:string, content:string|Uint8Array}>, * std?: string, flags?: string[], * primarySourcePath?: string, outputName?: string|null, + * memoryDebugMode?: boolean, * }} request * @returns {Promise<{success:boolean, diagnostics:string, outputPath:(string|null), * diagnosticsByPath:object}>} @@ -379,7 +386,15 @@ 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. - const userFlags = [`-std=${std}`, '-Wall', '-Wextra', ...flags, '-fno-exceptions']; + const memoryDebugMode = request.memoryDebugMode === true; + const userFlags = [ + `-std=${std}`, + '-Wall', + '-Wextra', + ...flags, + ...memoryDebugFlags(memoryDebugMode), + '-fno-exceptions', + ]; // ── Step 1: Build-plan discovery ───────────────────────────────────────── let plan; @@ -467,6 +482,21 @@ async function compile(request) { return { success: false, diagnostics: allOutput.trim() || 'Linker produced no output binary.', outputPath: null, diagnosticsByPath: diagnosticsByPath() }; } + if (memoryDebugMode) { + try { + const module = await WebAssembly.compile(compiledBinary); + const unsupported = unsupportedMemoryDebugImports(module); + if (unsupported.length > 0) { + compiledBinary = null; + const names = unsupported.map(({ module: namespace, name }) => `${namespace}.${name}`); + return fail(`Linker error: undefined symbol${names.length === 1 ? '' : 's'}: ${names.join(', ')}`); + } + } catch (err) { + compiledBinary = null; + return fail(`Memory Debug Mode validation failed: ${err.message}`); + } + } + const outputPath = normalizeCompilePath(outputName || plan.linkStep.outputPath); return { success: true, @@ -512,10 +542,14 @@ async function run({ stdin, vfsFiles = [], binaryBytes = null }) { return; } + let memoryDebugReportSeen = false; const wasiRuntime = createWasiRuntime({ stdin, onStdout: (text) => send({ type: 'stdout', data: text }), - onStderr: (text) => send({ type: 'stderr', data: text }), + onStderr: (text) => { + if (/assertion .* failed:/.test(text)) memoryDebugReportSeen = true; + send({ type: 'stderr', data: text }); + }, }); wasiRuntime.initRunVfs(vfsFiles); const useJspi = stdin.mode === 'interactive-message'; @@ -528,8 +562,23 @@ async function run({ stdin, vfsFiles = [], binaryBytes = null }) { const wasiImports = useJspi ? createWasiImports(wasiRuntime.wasi) : wasiRuntime.wasi; - const { instance } = await WebAssembly.instantiate(compiledBinary, { + const module = await WebAssembly.compile(compiledBinary); + const unsupported = unsupportedMemoryDebugImports(module); + if (unsupported.length > 0) { + throw new Error(`Unsupported WebAssembly import: ${unsupported[0].module}.${unsupported[0].name}`); + } + let instance = null; + const memoryDebugImports = createMemoryDebugImports({ + module, + getMemory: () => instance?.exports.memory, + onStderr: (text) => { + memoryDebugReportSeen = true; + send({ type: 'stderr', data: text }); + }, + }); + instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasiImports, + ...memoryDebugImports, }); // Give the WASI shim access to the module's memory @@ -541,8 +590,12 @@ async function run({ stdin, vfsFiles = [], binaryBytes = null }) { } catch (e) { if (e && e.__wasi_exit__) { exitCode = e.code; + } else if (isMemoryDebugRuntimeError(e)) { + exitCode = e.exitCode; } else if (e instanceof WebAssembly.RuntimeError) { - send({ type: 'stderr', data: `Runtime error: ${e.message}\n` }); + if (!memoryDebugReportSeen) { + send({ type: 'stderr', data: `Runtime error: ${e.message}\n` }); + } exitCode = 134; // SIGABRT equivalent } else { send({ type: 'stderr', data: `Unexpected error: ${String(e)}\n` }); @@ -585,6 +638,7 @@ self.onmessage = async ({ data }) => { files: data.files || [], std: data.std || 'c++20', flags: data.flags || [], + memoryDebugMode: data.memoryDebugMode === true, primarySourcePath: data.primarySourcePath || null, outputName: data.outputName || null, }); diff --git a/src/workers/memory-debug.mjs b/src/workers/memory-debug.mjs new file mode 100644 index 0000000..1236bae --- /dev/null +++ b/src/workers/memory-debug.mjs @@ -0,0 +1,170 @@ +/** + * Compiler flags and runtime imports for the opt-in Memory Debug Mode. + * + * Clang can instrument wasm32-wasi programs for UndefinedBehaviorSanitizer, + * but the browsercc sysroot does not ship compiler-rt's UBSan handlers. The + * worker supplies the fatal handlers as WebAssembly imports, reads Clang's + * source-location metadata from linear memory, reports the failure, and stops + * the program. libc++ debug hardening separately covers checked container + * operations such as vector::operator[]. + * + * UBSan interface reference: + * https://clang.llvm.org/docs/UndefinedBehaviorSanitizer.html + */ + +'use strict'; + +const UBSAN_PREFIX = '__ubsan_handle_'; +const UBSAN_ABORT_SUFFIX = '_abort'; +const MAX_SOURCE_PATH_BYTES = 4096; +const MEMORY_DEBUG_EXIT_CODE = 134; + +const HANDLER_LABELS = Object.freeze({ + type_mismatch_v1: 'type mismatch', + alignment_assumption: 'alignment assumption failed', + add_overflow: 'addition overflow', + sub_overflow: 'subtraction overflow', + mul_overflow: 'multiplication overflow', + negate_overflow: 'negation overflow', + divrem_overflow: 'division or remainder overflow', + shift_out_of_bounds: 'shift out of bounds', + out_of_bounds: 'out-of-bounds access', + local_out_of_bounds: 'local out-of-bounds access', + builtin_unreachable: 'unreachable code executed', + missing_return: 'missing return value', + vla_bound_not_positive: 'variable-length array bound is not positive', + float_cast_overflow: 'floating-point cast overflow', + load_invalid_value: 'invalid value loaded', + invalid_builtin: 'invalid compiler builtin argument', + function_type_mismatch: 'function type mismatch', + function_type_mismatch_v1: 'function type mismatch', + implicit_conversion: 'invalid implicit conversion', + nonnull_arg: 'null passed to a non-null argument', + nonnull_return: 'null returned from a non-null function', + nonnull_return_v1: 'null returned from a non-null function', + nullability_arg: 'nullability argument violation', + nullability_return: 'nullability return violation', + nullability_return_v1: 'nullability return violation', + pointer_overflow: 'pointer overflow', + cfi_bad_type: 'control-flow integrity type check failed', + cfi_check_fail: 'control-flow integrity check failed', + dynamic_type_cache_miss: 'dynamic type check failed', + invalid_objc_cast: 'invalid Objective-C cast', +}); + +const SUPPORTED_HANDLER_IMPORTS = new Set( + Object.keys(HANDLER_LABELS).flatMap((kind) => [ + `${UBSAN_PREFIX}${kind}`, + `${UBSAN_PREFIX}${kind}${UBSAN_ABORT_SUFFIX}`, + ]) +); + +export class MemoryDebugRuntimeError extends Error { + constructor(message) { + super(message); + this.name = 'MemoryDebugRuntimeError'; + this.exitCode = MEMORY_DEBUG_EXIT_CODE; + this.__memory_debug_exit__ = true; + } +} + +/** Return the centrally managed compile/link profile for Memory Debug Mode. */ +export function memoryDebugFlags(enabled) { + if (!enabled) return []; + return [ + '-g', + '-fsanitize=undefined', + '-fno-sanitize-recover=all', + '-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_DEBUG', + '-Wl,--allow-undefined', + ]; +} + +export function isMemoryDebugRuntimeError(error) { + return Boolean(error?.__memory_debug_exit__); +} + +function handlerKind(name) { + const withoutPrefix = name.startsWith(UBSAN_PREFIX) + ? name.slice(UBSAN_PREFIX.length) + : name; + return withoutPrefix.endsWith(UBSAN_ABORT_SUFFIX) + ? withoutPrefix.slice(0, -UBSAN_ABORT_SUFFIX.length) + : withoutPrefix; +} + +function handlerLabel(name) { + const kind = handlerKind(name); + return HANDLER_LABELS[kind] || kind.replaceAll('_', ' '); +} + +function readCString(memory, pointer) { + if (!memory?.buffer || !Number.isInteger(pointer) || pointer <= 0) return ''; + const bytes = new Uint8Array(memory.buffer); + if (pointer >= bytes.length) return ''; + const limit = Math.min(bytes.length, pointer + MAX_SOURCE_PATH_BYTES); + let end = pointer; + while (end < limit && bytes[end] !== 0) end++; + return new TextDecoder().decode(bytes.subarray(pointer, end)); +} + +/** + * Read the SourceLocation prefix used by UBSan's handler metadata on wasm32: + * `{ const char *filename; uint32_t line; uint32_t column; }`. + */ +function readSourceLocation(memory, dataPointer) { + if (!memory?.buffer || !Number.isInteger(dataPointer) || dataPointer <= 0) return null; + if (dataPointer + 12 > memory.buffer.byteLength) return null; + const view = new DataView(memory.buffer); + const filenamePointer = view.getUint32(dataPointer, true); + const line = view.getUint32(dataPointer + 4, true); + const column = view.getUint32(dataPointer + 8, true); + const filename = readCString(memory, filenamePointer); + if (!filename || line === 0) return null; + return { filename, line, column }; +} + +function formatDiagnostic(name, location) { + const prefix = location + ? `${location.filename}:${location.line}:${location.column}` + : 'Memory Debug Mode'; + return `${prefix}: runtime error: ${handlerLabel(name)}\n`; +} + +function isSupportedHandlerImport(descriptor) { + return descriptor.kind === 'function' && SUPPORTED_HANDLER_IMPORTS.has(descriptor.name); +} + +/** + * Return unresolved imports that are not WASI functions or UBSan handlers. + * Memory Debug Mode uses `--allow-undefined` only because wasm-ld has no UBSan + * runtime to resolve; validating the finished module keeps ordinary missing + * user symbols as link failures instead of silently importing them. + */ +export function unsupportedMemoryDebugImports(module) { + return WebAssembly.Module.imports(module).filter((descriptor) => ( + descriptor.module !== 'wasi_snapshot_preview1' && !isSupportedHandlerImport(descriptor) + )); +} + +/** + * Build only the import namespaces and fatal handlers required by a compiled + * module. `getMemory` is deferred because the WebAssembly instance does not + * exist until after its imports have been constructed. + */ +export function createMemoryDebugImports({ module, getMemory, onStderr = () => {} }) { + const imports = {}; + for (const descriptor of WebAssembly.Module.imports(module)) { + if (!isSupportedHandlerImport(descriptor)) continue; + const namespace = (imports[descriptor.module] ||= {}); + namespace[descriptor.name] = (dataPointer) => { + const diagnostic = formatDiagnostic( + descriptor.name, + readSourceLocation(getMemory?.(), Number(dataPointer)) + ); + onStderr(diagnostic); + throw new MemoryDebugRuntimeError(diagnostic.trim()); + }; + } + return imports; +}