Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
35 changes: 33 additions & 2 deletions .github/workflows/cef-learning-harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@
# or type error fails in ~2-3min instead of after the full ~30min SDK+CMake+Rust+Xvfb
# cycle. `Swatinem/rust-cache` caches cargo's registry/git dirs for the rust-core
# crate Corrosion builds via CMake, cutting redundant crates.io re-fetches.
#
# QNBS-v3: build type is RelWithDebInfo, not Release — the crash-symbolization proof
# (scripts/cef/run-symbolization-proof.mjs) needs real DWARF debug info in
# worldscript_host to resolve function names via dump_syms. dump_syms/minidump-stackwalk
# are standalone Rust tools (mozilla/dump_syms, rust-minidump/rust-minidump) with tiny
# prebuilt Linux binaries — neither needs a Chromium checkout, confirmed by reading their
# own READMEs directly.
# ============================================================

name: 🧪 CEF Learning Harness
Expand Down Expand Up @@ -148,7 +155,7 @@ jobs:
run: |
set -o pipefail
BUILD_SRC_DIR=$(node scripts/cef/prepare-cef-build.mjs "$CEF_DIR" build | tail -1)
cmake -S "$BUILD_SRC_DIR" -B build -DCMAKE_BUILD_TYPE=Release
cmake -S "$BUILD_SRC_DIR" -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --target worldscript_host --parallel "$(nproc)"

- name: List worldscript_host output directory (diagnostic)
Expand All @@ -157,6 +164,22 @@ jobs:
- name: Linux runtime linkage check (ldd against shipped .so files)
run: node scripts/cef/check-linux-runtime-linkage.mjs build/worldscript_host

# QNBS-v3: pinned prebuilt release binaries (~3.6MB each), sha256-verified — not built from
# source (would need a slow cargo build for two more Rust tools) and not a Chromium
# checkout (neither tool needs one; see the header comment).
- name: Fetch dump_syms + minidump-stackwalk (crash-symbolization tools)
run: |
set -euo pipefail
mkdir -p symtools
curl -sL "https://github.com/mozilla/dump_syms/releases/download/v2.3.9/dump_syms-x86_64-unknown-linux-gnu.tar.xz" -o symtools/dump_syms.tar.xz
curl -sL "https://github.com/rust-minidump/rust-minidump/releases/download/v0.27.0/minidump-stackwalk-x86_64-unknown-linux-gnu.tar.xz" -o symtools/mds.tar.xz
echo "0fc852a86b00337407d9d423cc388a24c3b489ccaaedcf92623cad57af5ca8ad symtools/dump_syms.tar.xz" | sha256sum -c -
echo "0020324c54cc359596e927ee907204f4c8d4da6718765536edcabaa1622122ad symtools/mds.tar.xz" | sha256sum -c -
tar xf symtools/dump_syms.tar.xz -C symtools
tar xf symtools/mds.tar.xz -C symtools
echo "DUMP_SYMS_BIN=$(pwd)/$(find symtools -name dump_syms -type f)" >> "$GITHUB_ENV"
echo "MINIDUMP_STACKWALK_BIN=$(pwd)/$(find symtools -name minidump-stackwalk -type f)" >> "$GITHUB_ENV"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

- name: Serve production bundle + repeated launch/close proof
run: |
python3 -m http.server 8080 --directory dist &
Expand All @@ -167,6 +190,13 @@ jobs:
"http://localhost:8080/" --cycles 3
kill "$SERVER_PID"

- name: Crash-symbolization proof (dump_syms + minidump-stackwalk)
run: |
xvfb-run -a node scripts/cef/run-symbolization-proof.mjs \
"$(pwd)/build/worldscript_host/worldscript_host" \
"$DUMP_SYMS_BIN" \
"$MINIDUMP_STACKWALK_BIN"

- name: Best-effort Wayland launch smoke (roadmap §44.2)
id: wayland-smoke
continue-on-error: true
Expand All @@ -189,5 +219,6 @@ jobs:
echo "- Cache hit: \`${{ steps.cef-cache.outputs.cache-hit }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- worldscript_host built and repeated launch/close cycles proven against the real production bundle (dist/), under Xvfb." >> "$GITHUB_STEP_SUMMARY"
echo "- Linux runtime linkage (ldd against the shipped worldscript_host + libcef.so, not just dpkg package presence): see the \"Linux runtime linkage check\" step above." >> "$GITHUB_STEP_SUMMARY"
echo "- Crash-symbolization proof: a self-induced browser-process crash resolved via dump_syms + minidump-stackwalk against our own DWARF debug info — Chromium/CEF-internal frames remain unsymbolized (no debug-symbols archive is published for this distribution)." >> "$GITHUB_STEP_SUMMARY"
echo "- Wayland smoke (best-effort, roadmap §44.2): \`${{ steps.wayland-smoke.outcome }}\`" >> "$GITHUB_STEP_SUMMARY"
echo "- Not yet in scope: X11/Wayland matrix beyond this one runner, sandbox posture, accessibility smoke." >> "$GITHUB_STEP_SUMMARY"
echo "- Not yet in scope: X11/Wayland matrix beyond this one runner, sandbox posture, accessibility-tree observability (AT-SPI — state enablement is proven, see the launch-cycle-proof step)." >> "$GITHUB_STEP_SUMMARY"
4 changes: 4 additions & 0 deletions apps/desktop-cef/rust-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ crate-type = ["staticlib"]

[profile.release]
panic = "abort"
# QNBS-v3: cargo's release profile strips debug info by default — without this, dump_syms would
# have nothing to extract from the Rust side of worldscript_host, and the Wave 2 crash-symbolization
# proof couldn't resolve worldscript_rust_debug_crash_self_test's function name. No runtime cost.
debug = true
10 changes: 10 additions & 0 deletions apps/desktop-cef/rust-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ pub extern "C" fn worldscript_rust_ping() -> i32 {
// QNBS-v3: non-trivial sentinel so a stub/miscompiled stand-in can't accidentally match by coincidence (e.g. a default-zeroed return reading as success).
424242
}

/// Deliberate, distinctively-named crash for the CI crash-symbolization proof
/// (docs/cef/ROADMAP-CEF-DESKTOP-MIGRATION.md Wave 2 exit criterion). Only ever called
/// behind the `--debug-crash-self` CLI flag (see apps/desktop-cef/src/main.cpp) — never
/// reachable in normal operation. `panic = "abort"` (this crate's release profile) turns
/// this into a real SIGABRT Crashpad can catch, not an unwind.
#[no_mangle]
pub extern "C" fn worldscript_rust_debug_crash_self_test() {
panic!("worldscript_rust_debug_crash_self_test: deliberate self-test crash");
}
23 changes: 23 additions & 0 deletions apps/desktop-cef/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
#include "shutdown_signal.h"
#include "worldscript_app.h"

// QNBS-v3: crash-symbolization proof (Wave 2 exit criterion) — only ever invoked behind
// --debug-crash-self below, never reachable in normal operation.
extern "C" void worldscript_rust_debug_crash_self_test();

namespace {

// QNBS-v3: defaults to about:blank so a bare invocation (e.g. a subprocess re-exec) never depends on --url being present.
Expand All @@ -21,11 +25,21 @@ std::string ParseStartUrl(int argc, char* argv[]) {
return "about:blank";
}

bool HasDebugCrashSelfFlag(int argc, char* argv[]) {
for (int i = 1; i < argc; ++i) {
if (std::string(argv[i]) == "--debug-crash-self") {
return true;
}
}
return false;
}

} // namespace

int main(int argc, char* argv[]) {
CefMainArgs main_args(argc, argv);

const bool debug_crash_self = HasDebugCrashSelfFlag(argc, argv);
CefRefPtr<WorldScriptApp> app(new WorldScriptApp(ParseStartUrl(argc, argv)));

// QNBS-v3: every CEF host re-executes itself for renderer/GPU/utility subprocesses — must run before CefInitialize; non-negative return means this invocation *was* one of those, already run to completion.
Expand All @@ -51,6 +65,15 @@ int main(int argc, char* argv[]) {
CefCrashReportingEnabled() ? "true" : "false");
fflush(stdout);

// QNBS-v3: Wave 2 crash-symbolization proof only — deliberately crashes the browser
// process itself (not a renderer subprocess) so the resulting dump's stack is our own
// code, not Chromium/CEF internals we have no debug symbols for.
if (debug_crash_self) {
printf("[worldscript_host] debug_crash_self_test = triggering\n");
fflush(stdout);
worldscript_rust_debug_crash_self_test();
}

CefRunMessageLoop();
CefShutdown();

Expand Down
251 changes: 251 additions & 0 deletions scripts/cef/run-symbolization-proof.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/**
* Crash-symbolization proof for the Wave 2 CEF host (roadmap §3142 exit criterion
* "initial crash-reporting/symbolization proof").
*
* The existing crash-reporting proof (run-launch-cycle-proof.mjs's runCrashReportingProofCycle)
* deliberately crashes chrome://crash — a Chromium/CEF-internal renderer crash. That proves
* *reporting* (a real Crashpad .dmp file is written), but its own comment is explicit that
* *symbolizing* that specific dump is out of reach: the crash is entirely inside Chromium's own
* stripped code, and CEF's official Spotify-hosted builds (verified against
* https://cef-builds.spotifycdn.com/index.json for this project's pinned version) ship no
* separate debug-symbols archive for any distribution type (standard/tools/minimal/client) — so
* there is no way to symbolize Chromium-internal frames without building Chromium itself.
*
* This script proves the *other* half honestly instead: symbolizing a crash inside *our own*
* code, which we compile ourselves and fully control the debug info for. It deliberately crashes
* the browser process itself (not a renderer subprocess) via the --debug-crash-self CLI flag
* (apps/desktop-cef/src/main.cpp), which calls a distinctively-named Rust function
* (worldscript_rust_debug_crash_self_test, apps/desktop-cef/rust-core/src/lib.rs) that panics
* under panic=abort — a real SIGABRT Crashpad catches the same way it caught the renderer crash.
*
* dump_syms (github.com/mozilla/dump_syms) and minidump-stackwalk (github.com/rust-minidump/
* rust-minidump) are both standalone Rust projects with prebuilt Linux release binaries — neither
* needs a Chromium checkout, confirmed by reading their own READMEs directly, not assumed. Their
* combination is the same Breakpad-format toolchain Mozilla uses for real Firefox crash
* symbolication, applied here to our own binary's DWARF debug info (RelWithDebInfo build +
* rust-core's `debug = true` release-profile override — see cef-learning-harness.yml).
*
* Deliberately a separate script, not a mode flag on run-launch-cycle-proof.mjs's existing
* crash-reporting proof — same "no shared-code coupling between proofs" discipline established
* after the PR #391 accessibility-attempt regression.
*
* Run: node scripts/cef/run-symbolization-proof.mjs <binary-path> <dump-syms-path> <minidump-stackwalk-path>
*/
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const [binaryPath, dumpSymsPath, minidumpStackwalkPath] = process.argv.slice(2);

const STARTUP_GRACE_MS = 10000;
const SHUTDOWN_GRACE_MS = 6000;
const ORPHAN_CHECK_GRACE_MS = 3000;
const DUMP_WRITE_GRACE_MS = 8000;
const CRASH_TRIGGERED_LINE = 'debug_crash_self_test = triggering';
// QNBS-v3: the exact Rust function name — proves minidump-stackwalk actually resolved a symbol,
// not just that it ran without error (a tool that silently produced zero symbols would still
// exit 0 and emit valid-but-useless JSON).
const CRASH_FUNCTION_NAME = 'worldscript_rust_debug_crash_self_test';

if (!binaryPath || !dumpSymsPath || !minidumpStackwalkPath) {
console.error(
'[symbolization-proof] Usage: node scripts/cef/run-symbolization-proof.mjs <binary-path> <dump-syms-path> <minidump-stackwalk-path>',
);
process.exit(1);
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function listMatchingPids() {
try {
const out = execFileSync('pgrep', ['-f', `^${binaryPath}`], {
stdio: ['ignore', 'pipe', 'ignore'],
})
.toString()
.trim();
return out
.split('\n')
.filter(Boolean)
.map(Number)
.filter((pid) => pid !== process.pid);
} catch {
return [];
}
}

function processTreeAlive() {
return listMatchingPids().length > 0;
}

function killAllMatchingProcesses() {
for (const pid of listMatchingPids()) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// Already gone between the pgrep snapshot and this call — fine.
}
}
}

function logStderr(label, stderr) {
if (stderr) console.error(`[symbolization-proof] ${label} stderr:\n${stderr}`);
}

function findFilesRecursive(dir) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
return entry.isDirectory() ? findFilesRecursive(full) : [full];
});
}

async function main() {
console.log(
'[symbolization-proof] Launching with --debug-crash-self to deliberately crash the browser process itself…',
);
const dumpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'worldscript-symbolization-dumps-'));
const symbolsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'worldscript-symbolization-syms-'));

const child = spawn(
binaryPath,
['--url=about:blank', '--debug-crash-self', '--enable-logging=stderr', '--v=1'],
{
cwd: path.dirname(binaryPath),
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BREAKPAD_DUMP_LOCATION: dumpDir },
},
);

let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += chunk.toString();
});
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});

const exited = new Promise((resolve) =>
child.once('exit', (code, signal) => resolve({ code, signal })),
);

// QNBS-v3: every throw below is caught here so the child is always reaped — same discipline as
// run-launch-cycle-proof.mjs's runCrashReportingProofCycle (PR #392 finding).
try {
let stopPolling = false;

const crashTriggered = await Promise.race([
(async () => {
while (!stopPolling && !stdout.includes(CRASH_TRIGGERED_LINE)) {
await sleep(200);
}
return stdout.includes(CRASH_TRIGGERED_LINE);
})(),
sleep(STARTUP_GRACE_MS).then(() => false),
]);
stopPolling = true;
if (!crashTriggered) {
throw new Error(
`"${CRASH_TRIGGERED_LINE}" not observed within ${STARTUP_GRACE_MS}ms — stdout so far:\n${stdout}`,
);
}

const shutdownResult = await Promise.race([exited, sleep(SHUTDOWN_GRACE_MS).then(() => null)]);
if (!shutdownResult) {
throw new Error(
'process did not exit within the shutdown grace period after the self-crash.',
);
}
// QNBS-v3: panic=abort raises SIGABRT — anything else (a clean exit, SIGTERM) means the
// crash trigger didn't actually crash the process, which would make the rest of this proof
// meaningless (symbolizing a dump that was never really a crash).
if (shutdownResult.signal !== 'SIGABRT') {
throw new Error(
`expected SIGABRT from the self-crash, got code=${shutdownResult.code} signal=${shutdownResult.signal}.`,
);
}
console.log('[symbolization-proof] Browser process crashed via SIGABRT as expected.');

stopPolling = false;
const dumpAppeared = await Promise.race([
(async () => {
while (
!stopPolling &&
findFilesRecursive(dumpDir).filter((f) => f.endsWith('.dmp')).length === 0
) {
await sleep(200);
}
return findFilesRecursive(dumpDir).some((f) => f.endsWith('.dmp'));
})(),
sleep(DUMP_WRITE_GRACE_MS).then(() => false),
]);
stopPolling = true;
const dumpFiles = findFilesRecursive(dumpDir).filter((f) => f.endsWith('.dmp'));
if (!dumpAppeared || dumpFiles.length === 0) {
throw new Error(
`no .dmp file appeared under BREAKPAD_DUMP_LOCATION (${dumpDir}) within ${DUMP_WRITE_GRACE_MS}ms.`,
);
}
console.log(`[symbolization-proof] Dump file confirmed: ${dumpFiles[0]}`);

await sleep(ORPHAN_CHECK_GRACE_MS);
if (processTreeAlive()) {
throw new Error('orphaned worldscript_host process(es) still running after the self-crash.');
}

console.log('[symbolization-proof] Generating Breakpad symbols with dump_syms…');
execFileSync(dumpSymsPath, ['-s', symbolsDir, binaryPath], { stdio: 'inherit' });
const symFiles = findFilesRecursive(symbolsDir).filter((f) => f.endsWith('.sym'));
if (symFiles.length === 0) {
throw new Error(`dump_syms produced no .sym file under ${symbolsDir}.`);
}
console.log(`[symbolization-proof] Symbol file generated: ${symFiles[0]}`);

console.log('[symbolization-proof] Running minidump-stackwalk…');
const stackwalkOutput = execFileSync(
minidumpStackwalkPath,
['--json', dumpFiles[0], symbolsDir],
{ encoding: 'utf8' },
);
if (!stackwalkOutput.includes(CRASH_FUNCTION_NAME)) {
throw new Error(
`minidump-stackwalk's output does not contain "${CRASH_FUNCTION_NAME}" — the crash frame was not symbolized. Output:\n${stackwalkOutput.slice(0, 4000)}`,
);
}

console.log(
`[symbolization-proof] OK — real Crashpad dump from a self-induced crash was symbolized end-to-end, resolving "${CRASH_FUNCTION_NAME}" via dump_syms + minidump-stackwalk. Chromium/CEF-internal frames remain unsymbolized (no debug-symbols archive is published for this distribution) — this proof is scoped to our own code, honestly, not the whole stack.`,
);
} catch (err) {
logStderr('Symbolization proof', stderr);
child.kill('SIGKILL');
await Promise.race([exited, sleep(SHUTDOWN_GRACE_MS)]);
if (processTreeAlive()) {
killAllMatchingProcesses();
await sleep(ORPHAN_CHECK_GRACE_MS);
if (processTreeAlive()) {
console.error(
'[symbolization-proof] WARNING — worldscript_host process(es) still running after failure cleanup.',
);
}
}
throw new Error(`Symbolization proof: ${err instanceof Error ? err.message : String(err)}`);
} finally {
fs.rmSync(dumpDir, { recursive: true, force: true });
fs.rmSync(symbolsDir, { recursive: true, force: true });
}
}

main().catch((err) => {
console.error(`[symbolization-proof] FAIL — ${err.message}`);
process.exit(1);
});
Loading