Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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.

---


Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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",
Expand Down
227 changes: 227 additions & 0 deletions scripts/e2e-memory-debug-toolchain.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <iostream>

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 <iostream>
#include <vector>

int main() {
std::cout << "before\\n";
std::vector<int> 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 <iostream>

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);
});
41 changes: 41 additions & 0 deletions scripts/e2e-session-persistence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading