From 526df46ff9111933dcdbde250051c450ad4f6789 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 30 Sep 2026 18:24:55 +0500 Subject: [PATCH 1/2] test(e2e): a native crash on Android is recovered without a JS handler Co-authored-by: Cursor --- .../plans/2026-09-16-implementation-plan.md | 2 +- examples/bare/App.tsx | 17 +- examples/bare/e2e/native-crash.test.ts | 280 ++++++++++++++++++ examples/bare/metro.config.js | 21 +- examples/bare/scenarios/native.ts | 72 ++++- 5 files changed, 374 insertions(+), 18 deletions(-) create mode 100644 examples/bare/e2e/native-crash.test.ts diff --git a/docs/design/plans/2026-09-16-implementation-plan.md b/docs/design/plans/2026-09-16-implementation-plan.md index 3d83eb9c..832d0a59 100644 --- a/docs/design/plans/2026-09-16-implementation-plan.md +++ b/docs/design/plans/2026-09-16-implementation-plan.md @@ -3334,7 +3334,7 @@ export function fileExists(path: string): Promise; - `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. diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index f759c4df..a3fb3c54 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -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 { @@ -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, @@ -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 diff --git a/examples/bare/e2e/native-crash.test.ts b/examples/bare/e2e/native-crash.test.ts new file mode 100644 index 00000000..906e9c24 --- /dev/null +++ b/examples/bare/e2e/native-crash.test.ts @@ -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:`, 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= after Launched, before the signal + * BUGSEE_E2E native after 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 { + 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; +} + +function signalOf(crash: Record): Record { + 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; +} + +/** + * Waits until `pidof com.bareexample` is empty, or `timeoutMs` elapses. + * Returns whether it went empty. + */ +async function awaitProcessGone(timeoutMs: number): Promise { + 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; + readonly signal: Record; +} + +/** + * 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 { + 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); + }); + }); +}); diff --git a/examples/bare/metro.config.js b/examples/bare/metro.config.js index 339cef05..d4f40efe 100644 --- a/examples/bare/metro.config.js +++ b/examples/bare/metro.config.js @@ -1,3 +1,4 @@ +const fs = require('node:fs'); const path = require('node:path'); const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); @@ -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 @@ -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 @@ -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'), }, }, }; diff --git a/examples/bare/scenarios/native.ts b/examples/bare/scenarios/native.ts index 416960bf..5275987b 100644 --- a/examples/bare/scenarios/native.ts +++ b/examples/bare/scenarios/native.ts @@ -1,17 +1,30 @@ /** - * The `bugsee-e2e-native` scenarios (e2e/e2e-native.test.ts, Task 7.6a). + * The `bugsee-e2e-native` scenarios. * - * `e2e-native-smoke` proves the example-only module is linked and reachable, - * after `Launched`: + * `e2e-native-smoke` (Task 7.6a) proves the example-only module is linked and + * reachable, after `Launched`: * - `writeTempFile('smoke-.txt', 'smoke ')`, then `fileExists` on the * path it resolved, logged as `BUGSEE_E2E native tmp path=

exists=`; * - `crashNative` with a kind outside the type, inside `try`, logged as * `BUGSEE_E2E native bad-kind code=` (`none` if * nothing threw, which is the failure: the kind reached native code). + * + * `native-crash-segv` / `native-crash-abort` / `native-crash-observe` + * (Task 7.6b): a report handler registered before `launch()` whose + * `onAfterReportCreated` logs `BUGSEE_E2E native after type=`; the + * crash kinds, after `Launched`, log `BUGSEE_E2E native crashing kind=` + * and then call `crashNative(kind)`. Observe installs the same handler and + * does nothing else. */ +import Bugsee, { type BugseeReportHandler } from '@bugsee/react-native'; import { crashNative, fileExists, writeTempFile } from 'bugsee-e2e-native'; -export const NATIVE_SCENARIOS = ['e2e-native-smoke'] as const; +export const NATIVE_SCENARIOS = [ + 'e2e-native-smoke', + 'native-crash-segv', + 'native-crash-abort', + 'native-crash-observe', +] as const; export type NativeScenario = (typeof NATIVE_SCENARIOS)[number]; @@ -19,6 +32,21 @@ export function isNativeScenario(name: string): name is NativeScenario { return (NATIVE_SCENARIOS as readonly string[]).includes(name); } +const CRASH_KINDS = { + 'native-crash-segv': 'segv', + 'native-crash-abort': 'abort', +} as const; + +function isCrashScenario( + scenario: NativeScenario, +): scenario is keyof typeof CRASH_KINDS { + return scenario === 'native-crash-segv' || scenario === 'native-crash-abort'; +} + +function needsHandler(scenario: NativeScenario): boolean { + return isCrashScenario(scenario) || scenario === 'native-crash-observe'; +} + function mark(message: string): void { console.log(`BUGSEE_E2E native ${message}`); } @@ -27,6 +55,14 @@ function nameOf(error: unknown): string { return error instanceof Error ? error.name : typeof error; } +function crashHandler(): BugseeReportHandler { + return { + onAfterReportCreated(report) { + mark(`after type=${report.type}`); + }, + }; +} + async function runSmoke(nonce: string): Promise { try { const path = await writeTempFile(`smoke-${nonce}.txt`, `smoke ${nonce}`); @@ -43,6 +79,30 @@ async function runSmoke(nonce: string): Promise { } } -export function runNativeScenario(_scenario: NativeScenario, nonce: string): void { - runSmoke(nonce).catch((error: unknown) => mark(`smoke threw ${nameOf(error)}`)); +/** + * Registers the crash scenarios' report handler before `launch()`, so a + * crash recovered at the next launch reaches JS. + */ +export function installNativeHandler(scenario: NativeScenario): void { + if (!needsHandler(scenario)) { + return; + } + Bugsee.setReportHandler(crashHandler()); +} + +/** What the scenario does once the SDK is Launched. */ +export function runNativeScenario(scenario: NativeScenario, nonce: string): void { + if (scenario === 'e2e-native-smoke') { + runSmoke(nonce).catch((error: unknown) => mark(`smoke threw ${nameOf(error)}`)); + return; + } + if (scenario === 'native-crash-observe') { + return; + } + if (isCrashScenario(scenario)) { + const kind = CRASH_KINDS[scenario]; + mark(`crashing kind=${kind}`); + crashNative(kind); + return; + } } From 445b1235bdd3120ac4315d720fbbade5c982cb6a Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Wed, 30 Sep 2026 20:49:50 +0500 Subject: [PATCH 2/2] docs(report): an Android NDK relaunch never reaches the JS handler Co-authored-by: Cursor --- docs/design/2026-09-15-sdk-design.md | 2 +- examples/bare/scenarios/native.ts | 6 ++++-- packages/react-native/src/index.ts | 10 ++++++---- packages/react-native/src/report/types.ts | 12 ++++++++---- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/design/2026-09-15-sdk-design.md b/docs/design/2026-09-15-sdk-design.md index c7d759f3..02ae2441 100644 --- a/docs/design/2026-09-15-sdk-design.md +++ b/docs/design/2026-09-15-sdk-design.md @@ -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). diff --git a/examples/bare/scenarios/native.ts b/examples/bare/scenarios/native.ts index 5275987b..ae2cfd9c 100644 --- a/examples/bare/scenarios/native.ts +++ b/examples/bare/scenarios/native.ts @@ -80,8 +80,10 @@ async function runSmoke(nonce: string): Promise { } /** - * Registers the crash scenarios' report handler before `launch()`, so a - * crash recovered at the next launch reaches JS. + * Registers a report handler before `launch()` for the crash scenarios. + * On an NDK relaunch it is a negative probe: recovery runs on the bounded + * thread, so the handler must not log `after type=crash`. It is not how an + * NDK crash is delivered to JS. */ export function installNativeHandler(scenario: NativeScenario): void { if (!needsHandler(scenario)) { diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index 26bd2567..ed3fe2ba 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -234,10 +234,12 @@ class Bugsee { * raised above that; on Android it follows a lower * `report-handler-callback-timeout`). A report recovered at launch gets * 2.5 s on iOS (off main, best-effort, edits land only until the bundle is - * assembled) but the live 25 s on Android when the SDK was launched from - * JS; Android's early bounded recovery never reaches JS. So async I/O in - * `onAfterReportCreated` can succeed on Android and silently miss on iOS - * recovery. Details on `BugseeReportHandler`. + * assembled). On Android, a Java uncaught-exception relaunch launched from + * JS gets the live 25 s on `BugseeReportHandlerThread`. An NDK relaunch, + * and Android's early bounded recovery, never reach JS. So async I/O in + * `onAfterReportCreated` can succeed on a Java relaunch and silently miss + * on iOS recovery or an Android NDK relaunch. Details on + * `BugseeReportHandler`. */ setReportHandler(handler: BugseeReportHandler | null): void { installReportHandler(handler); diff --git a/packages/react-native/src/report/types.ts b/packages/react-native/src/report/types.ts index e7943272..1f208e8c 100644 --- a/packages/react-native/src/report/types.ts +++ b/packages/react-native/src/report/types.ts @@ -111,10 +111,14 @@ export interface BugseeReport { * option when the app sets it lower. A report recovered at launch differs: * - iOS gives JS 2.5 s, off the main thread and best-effort -- edits land * only until the SDK assembles the bundle; - * - Android gives the live 25 s when the SDK was launched from JS; the SDK's - * own early, bounded recovery path never reaches JS at all. - * So async I/O in `onAfterReportCreated` can succeed on Android and silently - * miss on an iOS recovered report. Keep recovery-time work short. + * - Android's Java uncaught-exception relaunch, when the SDK was launched + * from JS, runs on `BugseeReportHandlerThread` and gives the live 25 s; + * - Android's NDK relaunch (SIGSEGV / SIGABRT), and the SDK's own early + * bounded recovery, run off that thread (`bugsee-report-handler-bounded` + * for an NDK crash) and never reach JS. + * So async I/O in `onAfterReportCreated` can succeed on a Java relaunch and + * silently miss on an iOS recovered report or an Android NDK relaunch. Keep + * recovery-time work short. */ export interface BugseeReportHandler { onBeforeReportCreated?(report: BugseeReport): void | Promise;