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
2 changes: 1 addition & 1 deletion docs/design/2026-09-15-sdk-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ One object per platform replaces four scattered mechanisms in the 6.x wrapper, a

Verified during Phase 3, on-device and by reading both SDKs' sources. `bugsee/specs` `sdk/report-contract` has the cross-SDK contract; what follows is what this wrapper actually observed against it.

**Android.** A crash the SDK recovers through its own bounded early-recovery dispatch — a call off the live `BugseeReportHandlerThread`, completion `Callback.NOOP` — completes natively at once (`completed by=recovery`) and never reaches JS; the bridge detects this by thread name, not by `isTerminating` (the plan's Phase 3 rulings). A crash recovered at the *next launch*, when that launch is JS-initiated, is a different path: the SDK dispatches it on the live `BugseeReportHandlerThread` with the ordinary 25 s live deadline, so it *does* reach JS and `onAfterReportCreated`'s edits land in the retained crash bundle. Verified on the WOD_LX1 (Task 3.4d): the relaunch logged `deadline=25000` and `completed by=js`, never `by=recovery`.
**Android.** A crash the SDK recovers through its own bounded early-recovery dispatch — a call off the live `BugseeReportHandlerThread`, completion `Callback.NOOP` — completes natively at once (`completed by=recovery`) and never reaches JS; the bridge detects this by thread name, not by `isTerminating` (the plan's Phase 3 rulings). A crash recovered at the *next launch*, when that launch is JS-initiated, is not one path. A Java uncaught exception is dispatched on the live `BugseeReportHandlerThread` with the ordinary 25 s live deadline, so it does reach JS and `onAfterReportCreated`'s edits land in the retained crash bundle. Verified on the WOD_LX1 (Task 3.4d): the relaunch logged `deadline=25000` and `completed by=js`, never `by=recovery`. An NDK crash (SIGSEGV or SIGABRT) recovered at that same JS-initiated launch is the bounded path instead: the SDK dispatches it on `bugsee-report-handler-bounded`, the bridge completes `by=recovery`, and the JS handler is never asked. Verified on the WOD_LX1 (Task 7.6b): the relaunch logged `completed by=recovery phase=after` and never `by=js`.

**iOS.** Live dispatch is on the main thread (25 s deadline); a report recovered at relaunch is dispatched off main (2.5 s deadline) — and, unlike Android's bounded early-recovery path, it **does** reach JS, because iOS re-persists the report on a late completion until the bundle is assembled. The recovery case cannot run in the Simulator: the simulator slice of 7.0.0-beta3 has no crash reporter (Task 3.4f), so it is gated `E2E_IOS_RECOVERY=1` and proven only on physical hardware (§12).

Expand Down
2 changes: 1 addition & 1 deletion docs/design/plans/2026-09-16-implementation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3334,7 +3334,7 @@ export function fileExists(path: string): Promise<boolean>;
- `crash.json` `ndkCrash === true`;
- `exception_type === 'native'`;
- `signal.name === 'SIGSEGV'` and `signal.number === 11`.
3. `the report handler sees it` — `BUGSEE_E2E native after type=crash` appears in the relaunch.
3. `the NDK relaunch recovers on the bounded path, not JS` — the relaunch log has `report handler - completed by=recovery` (NDK early recovery on `bugsee-report-handler-bounded`; Task 3.4d's Java recovery was the live `BugseeReportHandlerThread`, this one is not), and `BUGSEE_E2E native after type=crash` is absent for that run.
4. `crashNative('abort') is SIGABRT` — cases 1–2 again with `kind=abort`: `Fatal signal 6 (SIGABRT)`, and `signal.name === 'SIGABRT'`.

- [ ] **Red/Green** — all four cases pass on the WOD_LX1.
Expand Down
17 changes: 13 additions & 4 deletions examples/bare/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
* `rh-*` scenarios are the report-handler cases in scenarios/report-handler.ts;
* `channel` is scenarios/channel.ts, `data` is scenarios/data.ts, `secure`
* is scenarios/secure.tsx, `attributes`/`attributes-persist` are
* scenarios/attributes.ts, `e2e-native-smoke` is scenarios/native.ts, and
* `blackout`, `blackout-prelaunch`, `secure-component` and `view-tree` are
* scenarios/privacy.tsx.
* scenarios/attributes.ts, `e2e-native-smoke` / `native-crash-*` are
* scenarios/native.ts, and `blackout`, `blackout-prelaunch`,
* `secure-component` and `view-tree` are scenarios/privacy.tsx.
*/
import { useEffect, useState } from 'react';
import {
Expand Down Expand Up @@ -58,7 +58,11 @@ import {
isAttributeScenario,
runAttributeScenario,
} from './scenarios/attributes';
import { isNativeScenario, runNativeScenario } from './scenarios/native';
import {
installNativeHandler,
isNativeScenario,
runNativeScenario,
} from './scenarios/native';
import {
PrivacyStage,
type PrivacyScenario,
Expand Down Expand Up @@ -299,6 +303,11 @@ export default function App() {
if (privacyScenario !== undefined) {
preLaunchPrivacyProbe(privacyScenario, choice.nonce);
}
// Before launch(): a crash recovered at the next launch only reaches a
// handler that is already registered (Task 7.6b).
if (isNativeScenario(choice.scenario)) {
installNativeHandler(choice.scenario);
}
console.log(`BUGSEE_E2E launching on ${Platform.OS}`);
// Polling starts before launch() is awaited, not after. The SDK brings
// capture up off the main thread, so by the time the promise resolves the
Expand Down
280 changes: 280 additions & 0 deletions examples/bare/e2e/native-crash.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
/**
* Task 7.6b: a real native crash from JS reaches an Android report.
*
* `crashNative('segv'|'abort')` (examples/e2e-native) raises a signal in JNI
* code. The Bugsee NDK path recovers it on the next launch as a retained
* crash bundle. Task 3.4d's external `adb shell run-as … kill -11` stays in
* place; this suite is the NDK path's own coverage.
*
* Preconditions (as report-handler.test.ts / 3.4d): the debug app is
* installed on the handset in device.ts, Metro is reachable over
* `adb reverse tcp:8081 tcp:<E2E_METRO_PORT>`, airplane mode is on before
* the app starts, and bundles are cleared and the clear asserted.
*
* Markers, from scenarios/native.ts:
* BUGSEE_E2E native crashing kind=<kind> after Launched, before the signal
* BUGSEE_E2E native after type=<type> onAfterReportCreated (observe)
*
* Case 3 pins the NDK relaunch path: the SDK recovers on
* `bugsee-report-handler-bounded`, so ReportHandlerBridge completes
* `by=recovery` and never emits to JS. Task 3.4d's Java recovery was the
* live `BugseeReportHandlerThread`; this NDK relaunch is not.
*
* crash.json fields asserted here are the ones the Android SDK writes
* (ExceptionSerializer / BugseeDetectionCrashNdk.buildSignalInfo) and the
* reporting-bundle spec names: `ndkCrash`, `exception_type`, `signal.name`,
* `signal.number`.
*/
import { writeFileSync } from 'node:fs';

import {
type PulledBundle,
airplane,
removePulledBundles,
} from './bundles';
import { ANDROID_PACKAGE } from './device';
import {
ON_ANDROID,
type Run,
awaitBundles,
clearBundles,
TARGET_NAME,
describeDevice,
must,
report,
startRun,
useLog,
} from './harness';
import {
type DeviceLog,
type LogLine,
Logcat,
adb,
pidOf,
resetScenario,
} from './scenario';

const itAndroid = ON_ANDROID ? it : it.skip;

jest.setTimeout(10 * 60_000);

let log: DeviceLog;

/** crash.json from a pulled bundle (manifest type `crash`). */
function crashOf(bundle: PulledBundle): Record<string, unknown> {
const text = bundle.captures.get('crash');
if (text === undefined) {
throw new Error(
`bundle ${bundle.file} has no crash capture; files=${JSON.stringify(bundle.manifest.files)}`,
);
}
return JSON.parse(text) as Record<string, unknown>;
}

function signalOf(crash: Record<string, unknown>): Record<string, unknown> {
const signal = crash.signal;
if (signal === null || typeof signal !== 'object') {
throw new Error(`crash.json has no signal object: ${JSON.stringify(crash)}`);
}
return signal as Record<string, unknown>;
}

/**
* Waits until `pidof com.bareexample` is empty, or `timeoutMs` elapses.
* Returns whether it went empty.
*/
async function awaitProcessGone(timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if ((await pidOf()) === undefined) {
return true;
}
if (Date.now() >= deadline) {
return false;
}
await new Promise(resolve => setTimeout(resolve, 200));
}
}

interface KindRun {
readonly crashRun: Run;
readonly crashing: LogLine;
readonly fatal: LogLine;
readonly gone: boolean;
readonly observe: Run;
/** Bounded early-recovery completions in the observe run. */
readonly recoveryLines: readonly LogLine[];
readonly crash: Record<string, unknown>;
readonly signal: Record<string, unknown>;
}

/**
* Crash with `kind`, relaunch observe, pull the retained crash bundle.
* Waits for the NDK bounded recovery completion (`by=recovery`); case 3
* asserts that path and that JS never saw the report.
*/
async function runKind(
kind: 'segv' | 'abort',
fatalPattern: RegExp,
fatalLabel: string,
): Promise<KindRun> {
await clearBundles();
const crashRun = await startRun(`native-crash-${kind}`);
report('banner', crashRun.banner.text.trim());
expect(crashRun.dev).toBe(true);

const crashing = must(
await log.waitFor(
new RegExp(`BUGSEE_E2E native crashing kind=${kind}`),
15_000,
crashRun.launched.index,
),
`the ${kind} crashing marker`,
crashRun.start,
);
report(`${kind} crashing`, crashing.text.trim());

const fatal = must(
await log.waitFor(fatalPattern, 20_000, crashing.index),
fatalLabel,
crashRun.start,
);
report(`${kind} fatal`, fatal.text.trim());
const gone = await awaitProcessGone(10_000);
report(`${kind} pid gone`, gone);

const observe = await startRun('native-crash-observe');
// NDK recovery: bounded thread, completed natively — wait for that, not JS.
must(
await log.waitFor(
/report handler - completed by=recovery phase=after/,
30_000,
observe.start,
),
'report handler - completed by=recovery phase=after',
observe.start,
);
// A short beat so a late live dispatch (which must not happen) would land.
await new Promise(resolve => setTimeout(resolve, 2_000));
const recoveryLines = log.all(/report handler - completed by=recovery/, observe.start);
report(
`${kind} recovery`,
recoveryLines.map(line => line.text.trim()),
);
report(
`${kind} after markers`,
log.all(/BUGSEE_E2E native after type=crash/, observe.start).map(line => line.text.trim()),
);

const bundles = await awaitBundles(1);
const crashes = bundles.filter(b => b.request.type === 'crash');
expect(crashes).toHaveLength(1);
const crash = crashOf(crashes[0]!);
const signal = signalOf(crash);
report(`${kind} crash.json signal`, signal);
report(`${kind} crash.json ndkCrash`, crash.ndkCrash);
report(`${kind} crash.json exception_type`, crash.exception_type);

return { crashRun, crashing, fatal, gone, observe, recoveryLines, crash, signal };
}

describeDevice(`native crash on ${TARGET_NAME}`, () => {
beforeAll(async () => {
if (!ON_ANDROID) {
return;
}
log = await Logcat.start();
useLog(log, '7.6b');
await airplane(true);
});

afterAll(async () => {
if (!ON_ANDROID) {
return;
}
try {
await adb('shell', 'am', 'force-stop', ANDROID_PACKAGE).catch(() => {});
await clearBundles().catch((error: unknown) => report('cleanup clear failed', String(error)));
} finally {
try {
await airplane(false);
} finally {
try {
const { removed, kept } = removePulledBundles();
report('pulled bundle roots', { removed: removed.length, kept });
} finally {
try {
if (log !== undefined) {
log.stop();
const dump = process.env.E2E_LOGCAT_DUMP;
if (dump) {
writeFileSync(dump, log.lines.map(line => line.text).join('\n'));
}
}
} finally {
resetScenario();
}
}
}
}
});

describe('segv', () => {
let run: KindRun;

beforeAll(async () => {
if (!ON_ANDROID) {
return;
}
run = await runKind('segv', /Fatal signal 11 \(SIGSEGV\)/, 'Fatal signal 11 (SIGSEGV)');
});

itAndroid("crashNative('segv') kills the process with SIGSEGV", () => {
expect(run.fatal.text).toMatch(/Fatal signal 11 \(SIGSEGV\)/);
expect(run.gone).toBe(true);
});

itAndroid('the relaunch recovers it as a native crash', () => {
expect(run.crash.ndkCrash).toBe(true);
expect(run.crash.exception_type).toBe('native');
expect(run.signal.name).toBe('SIGSEGV');
expect(run.signal.number).toBe(11);
});

itAndroid('the NDK relaunch recovers on the bounded path, not JS', () => {
// Pins this path only: recovery completion present, JS after absent.
// A live-thread delivery (by=js + after marker) would fail both halves.
const recoveryTexts = run.recoveryLines.map(line => line.text);
expect(recoveryTexts.some(text => /completed by=recovery phase=after/.test(text))).toBe(
true,
);
report('case 3 recovery lines', recoveryTexts.map(text => text.trim()));
expect(
log.all(/BUGSEE_E2E native after type=crash/, run.observe.start).map(line => line.text),
).toEqual([]);
expect(
log.all(/report handler .* completed by=js/, run.observe.start).map(line => line.text),
).toEqual([]);
});
});

describe('abort', () => {
let run: KindRun;

beforeAll(async () => {
if (!ON_ANDROID) {
return;
}
run = await runKind('abort', /Fatal signal 6 \(SIGABRT\)/, 'Fatal signal 6 (SIGABRT)');
});

itAndroid("crashNative('abort') is SIGABRT", () => {
expect(run.fatal.text).toMatch(/Fatal signal 6 \(SIGABRT\)/);
expect(run.gone).toBe(true);
expect(run.crash.ndkCrash).toBe(true);
expect(run.crash.exception_type).toBe('native');
expect(run.signal.name).toBe('SIGABRT');
expect(run.signal.number).toBe(6);
});
});
});
21 changes: 14 additions & 7 deletions examples/bare/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const fs = require('node:fs');
const path = require('node:path');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');

Expand All @@ -6,6 +7,15 @@ const library = path.resolve(workspaceRoot, 'packages', 'react-native');
// The example-only native test helpers (Task 7.6a), a workspace sibling.
const e2eNative = path.resolve(workspaceRoot, 'examples', 'e2e-native');

/**
* Real paths of the node_modules trees. A git worktree may symlink them at
* the main clone (so yarn does not reinstall); Metro refuses to serve a file
* whose realpath is outside projectRoot/watchFolders, so the resolved
* destinations must be listed explicitly.
*/
const appNodeModules = fs.realpathSync(path.resolve(__dirname, 'node_modules'));
const rootNodeModules = fs.realpathSync(path.resolve(workspaceRoot, 'node_modules'));

/**
* The app's own copies. The library's `react` and `react-native` imports must
* land here, never on another copy in the workspace: two Reacts break every
Expand All @@ -28,7 +38,7 @@ const appOrigin = path.resolve(__dirname, 'index.js');
* @type {import('@react-native/metro-config').MetroConfig}
*/
const config = {
watchFolders: [library, e2eNative, path.resolve(workspaceRoot, 'node_modules')],
watchFolders: [library, e2eNative, rootNodeModules, appNodeModules],
resolver: {
// extraNodeModules below is only a FALLBACK: Metro tries the hierarchical
// node_modules lookup first. From the library's sources that lookup finds
Expand All @@ -48,17 +58,14 @@ const config = {
}
return context.resolveRequest(context, moduleName, platform);
},
nodeModulesPaths: [
path.resolve(__dirname, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
],
nodeModulesPaths: [appNodeModules, rootNodeModules],
extraNodeModules: {
'@bugsee/react-native': library,
'bugsee-e2e-native': e2eNative,
// The library declares react and react-native as peers; point them at
// the app's single copy so there are never two Reacts in the bundle.
react: path.resolve(__dirname, 'node_modules', 'react'),
'react-native': path.resolve(__dirname, 'node_modules', 'react-native'),
react: path.join(appNodeModules, 'react'),
'react-native': path.join(appNodeModules, 'react-native'),
},
},
};
Expand Down
Loading
Loading