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
9 changes: 9 additions & 0 deletions .changeset/crash-invite-includes-minidump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@inkeep/open-knowledge": patch
---

When OpenKnowledge invites you to report a crash it just detected, and a crash dump for that crash is on disk, the dump is now included in the report by default. The crash dump is a memory snapshot from the moment of the crash, and the single most useful artifact for finding the cause, so it now rides along unless you uncheck it. Previously it was off by default, so crash reports usually arrived without the very dump that triggered them.

The crash-dump option now appears only when a dump actually exists for the crash, so a crash invite with nothing to include (for example a session that ended without a clean quit but left no native dump) no longer shows a dead checkbox.

Nothing about the consent flow changes: the crash dump is still a labeled checkbox you can turn off, the note still says it can contain document content and can't be redacted, and nothing is sent until you review the exact bundle. This only affects reports triggered by a detected crash; regular bug reports are unchanged and never include a crash dump.
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const INVITE: OkBugReportCrashDetectedEvent = {
eventId: 'boot:1751871600000',
kind: 'boot',
context: { dirtyShutdown: true, newMinidumps: 0 },
minidumpAvailable: false,
};

interface CrashBridgeStub {
Expand Down
39 changes: 31 additions & 8 deletions packages/app/src/components/ReportBugDialog.dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -523,9 +523,10 @@ describe('ReportBugDialog', () => {
eventId: 'boot:1751871600000',
kind: 'boot',
context: { dirtyShutdown: true, newMinidumps: 1 },
minidumpAvailable: true,
};

test('a crash invite reskins compose: banner, crash note label, pre-checked diagnostics, off-by-default dump, Not now', async () => {
test('a crash invite reskins compose: banner, crash note label, pre-checked diagnostics, on-by-default dump, Not now', async () => {
installBridge();
await renderDialog({ crashInvite: BOOT_INVITE });

Expand All @@ -549,8 +550,8 @@ describe('ReportBugDialog', () => {
).toBe('true');

const dumpBox = screen.getByRole('checkbox', { name: 'Crash dump' });
expect(dumpBox.getAttribute('aria-checked')).toBe('false');
expect(screen.getByText(/a memory snapshot from the crash\./i)).not.toBeNull();
expect(dumpBox.getAttribute('aria-checked')).toBe('true');
expect(screen.getByText(/a memory snapshot from the crash/i)).not.toBeNull();
expect(screen.getByText(/can't be redacted/i)).not.toBeNull();

expect(screen.getByRole('button', { name: 'Not now' })).not.toBeNull();
Expand All @@ -560,7 +561,7 @@ describe('ReportBugDialog', () => {
expect(screen.queryByText(/secrets like api keys and tokens are redacted/i)).toBeNull();
});

test('crash-invite create folds the crash details in and leaves the dump out unless opted in', async () => {
test('crash-invite create folds the crash details in and includes the dump by default', async () => {
const log = installBridge();
await renderDialog({ crashInvite: BOOT_INVITE });

Expand All @@ -571,20 +572,42 @@ describe('ReportBugDialog', () => {
{
level: 'full',
note: 'Crash source: previous session ended without a clean quit\nCrash event: boot:1751871600000',
includeCrashDump: false,
includeCrashDump: true,
},
]);
});

test('checking Crash dump opts the minidump into create', async () => {
test('unchecking Crash dump excludes the minidump from create', async () => {
const log = installBridge();
await renderDialog({ crashInvite: BOOT_INVITE });

await userEvent.click(screen.getByRole('checkbox', { name: 'Crash dump' }));
await userEvent.click(screen.getByRole('button', { name: 'Create report' }));
await screen.findByRole('heading', { name: 'Review your report' });

expect(log.createCalls[0]?.includeCrashDump).toBe(true);
expect(log.createCalls[0]?.includeCrashDump).toBe(false);
});

test('a crash invite with no available minidump shows no dump row and sends no flag', async () => {
const log = installBridge();
await renderDialog({
crashInvite: {
eventId: 'boot:1751871600001',
kind: 'boot',
context: { dirtyShutdown: true, newMinidumps: 0 },
minidumpAvailable: false,
},
});

// A dirty shutdown that left no native crash dump: the invite still opens,
// but there is nothing to include, so no dead checkbox is offered.
expect(screen.queryByRole('checkbox', { name: 'Crash dump' })).toBeNull();
expect(screen.getByText('OpenKnowledge quit unexpectedly last time.')).not.toBeNull();

await userEvent.click(screen.getByRole('button', { name: 'Create report' }));
await screen.findByRole('heading', { name: 'Review your report' });

expect(log.createCalls[0]).not.toHaveProperty('includeCrashDump');
});

test('the plain compose never renders the crash-dump opt-in and never sends the flag', async () => {
Expand All @@ -611,7 +634,7 @@ describe('ReportBugDialog', () => {
});
await renderDialog({ crashInvite: BOOT_INVITE });

await userEvent.click(screen.getByRole('checkbox', { name: 'Crash dump' }));
// The dump rides in by default for a crash invite, so no click is needed.
await userEvent.click(screen.getByRole('button', { name: 'Create report' }));
await screen.findByRole('heading', { name: 'Review your report' });

Expand Down
31 changes: 21 additions & 10 deletions packages/app/src/components/ReportBugDialogBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ export interface ReportBugDialogProps {
* Present when a crash-detected invitation opened the dialog
* (`ReportBugCrashInviteTrigger`). Switches compose to the crash-invite
* variant: banner, "What were you doing?" note label, detailed diagnostics
* pre-checked, the crash-dump opt-in row (default off), and a "Not now"
* dismiss. The event's kind and id fold into the report's note.
* pre-checked, the crash-dump row (only when the event's `minidumpAvailable`
* is true; default on, opt-out), and a "Not now" dismiss. The event's kind
* and id fold into the report's note.
*/
crashInvite?: OkBugReportCrashDetectedEvent;
/**
Expand All @@ -206,7 +207,16 @@ function ReportBugDialog({
const [phase, setPhase] = useState<Phase>(COMPOSE_IDLE);
const [note, setNote] = useState('');
const [detailed, setDetailed] = useState(crashContext !== undefined || crashInvite !== undefined);
const [includeDump, setIncludeDump] = useState(false);
// The crash-dump opt-in only exists when main confirmed a minidump is on
// disk for this event; a dump-less invite (e.g. a dirty shutdown that left
// no native crash) offers no dead checkbox.
const crashDumpAvailable = crashInvite?.minidumpAvailable === true;
// Default ON when a dump is available: the crash is the whole reason for the
// report, and its minidump is the artifact triage most needs. Consent is
// preserved without a silent send — the row stays visible and uncheckable,
// its hint states the memory is unredactable, and the review step flags
// "crash dump not redacted" before the user sends.
const [includeDump, setIncludeDump] = useState(crashDumpAvailable);
// Default-on per the spec: when a screenshot was captured it rides along
// unless the user unchecks it. Only ever sent to `create` when one exists.
const [includeScreenshot, setIncludeScreenshot] = useState(true);
Expand Down Expand Up @@ -261,7 +271,7 @@ function ReportBugDialog({
if (phase.step === 'success' || phase.step === 'email' || phase.step === 'failure') {
setNote('');
setDetailed(crashContext !== undefined || crashInvite !== undefined);
setIncludeDump(false);
setIncludeDump(crashDumpAvailable);
// Re-default the screenshot to on so the next open (which captures a
// fresh screenshot) starts checked, matching the compose default.
setIncludeScreenshot(true);
Expand All @@ -286,9 +296,9 @@ function ReportBugDialog({
const result = await bugReport.create({
level: detailed ? 'full' : 'standard',
note: composeNote(note, noteContextLines),
// Only the crash invite ever asks for the dump the plain compose has
// no opt-in surface, so it must not even send the flag.
...(crashInvite !== undefined ? { includeCrashDump: includeDump } : {}),
// Only a crash invite with an available dump exposes the opt-in, so only
// then is the flag sent — plain compose and dump-less invites omit it.
...(crashDumpAvailable ? { includeCrashDump: includeDump } : {}),
// Only send the flag when a screenshot was actually captured — absent
// means main has nothing staged, so it must not claim an inclusion.
...(screenshot !== null ? { includeScreenshot } : {}),
Expand Down Expand Up @@ -561,7 +571,7 @@ function ReportBugDialog({
</div>
</div>
)}
{crashInvite !== undefined && (
{crashDumpAvailable && (
<div className="flex items-start gap-2.5">
<Checkbox
id={dumpId}
Expand All @@ -577,8 +587,9 @@ function ReportBugDialog({
</label>
<p id={dumpHintId} className="text-1sm text-muted-foreground">
<Trans>
A memory snapshot from the crash. It can contain document content and
can't be redacted, so leave it off unless you're comfortable sharing it.
A memory snapshot from the crash, and the artifact that helps us most. It
can contain document content and can't be redacted, so uncheck it if you'd
rather not share it.
</Trans>
</p>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/lib/crash-invite-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const crashEvent: OkBugReportCrashDetectedEvent = {
eventId: 'crash:render:1751871600000:0',
kind: 'render-process-gone',
context: { reason: 'crashed', exitCode: 5 },
minidumpAvailable: false,
};

describe('createCrashInviteStore — install', () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -1539,9 +1539,6 @@
"YEIS9T": ["Edits apply live. Light or dark is chosen from the background."],
"YEP3X2": ["Delete a table when the active table cell selection covers the full table."],
"YGIi_c": ["Reinstall"],
"YIp4R0": [
"A memory snapshot from the crash. It can contain document content and can't be redacted, so leave it off unless you're comfortable sharing it."
],
"YJ1BTp": ["A skill with that name already exists."],
"YKLP_-": ["Template renamed"],
"YLMBSd": ["e.g. The editor froze after I pasted a large table"],
Expand Down Expand Up @@ -2070,6 +2067,9 @@
"jerqF0": ["Loading branch state"],
"jhtG3I": [" Your selection has been restored."],
"jjtmV_": ["First file name"],
"jnCROW": [
"A memory snapshot from the crash, and the artifact that helps us most. It can contain document content and can't be redacted, so uncheck it if you'd rather not share it."
],
"jol95m": ["Failed to open Project Navigator."],
"jpctdh": ["View"],
"jtg_8-": [
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/locales/en/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -633,8 +633,8 @@ msgid "A line with a footnote<0><1>[1]</1></0> and a definition shown below."
msgstr "A line with a footnote<0><1>[1]</1></0> and a definition shown below."

#: src/components/ReportBugDialogBody.tsx
msgid "A memory snapshot from the crash. It can contain document content and can't be redacted, so leave it off unless you're comfortable sharing it."
msgstr "A memory snapshot from the crash. It can contain document content and can't be redacted, so leave it off unless you're comfortable sharing it."
msgid "A memory snapshot from the crash, and the artifact that helps us most. It can contain document content and can't be redacted, so uncheck it if you'd rather not share it."
msgstr "A memory snapshot from the crash, and the artifact that helps us most. It can contain document content and can't be redacted, so uncheck it if you'd rather not share it."

#: src/components/ShareReceiveDialog.tsx
msgid "A network problem or GitHub outage interrupted the clone."
Expand Down
6 changes: 3 additions & 3 deletions packages/app/src/locales/pseudo/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -1539,9 +1539,6 @@
"YEIS9T": ["Ēďĩţś àƥƥĺŷ ĺĩvē. Ĺĩĝĥţ ōŕ ďàŕķ ĩś ćĥōśēń ƒŕōḿ ţĥē ƀàćķĝŕōũńď."],
"YEP3X2": ["Ďēĺēţē à ţàƀĺē ŵĥēń ţĥē àćţĩvē ţàƀĺē ćēĺĺ śēĺēćţĩōń ćōvēŕś ţĥē ƒũĺĺ ţàƀĺē."],
"YGIi_c": ["Ŕēĩńśţàĺĺ"],
"YIp4R0": [
"À ḿēḿōŕŷ śńàƥśĥōţ ƒŕōḿ ţĥē ćŕàśĥ. Ĩţ ćàń ćōńţàĩń ďōćũḿēńţ ćōńţēńţ àńď ćàń'ţ ƀē ŕēďàćţēď, śō ĺēàvē ĩţ ōƒƒ ũńĺēśś ŷōũ'ŕē ćōḿƒōŕţàƀĺē śĥàŕĩńĝ ĩţ."
],
"YJ1BTp": ["À śķĩĺĺ ŵĩţĥ ţĥàţ ńàḿē àĺŕēàďŷ ēxĩśţś."],
"YKLP_-": ["Ţēḿƥĺàţē ŕēńàḿēď"],
"YLMBSd": ["ē.ĝ. Ţĥē ēďĩţōŕ ƒŕōźē àƒţēŕ Ĩ ƥàśţēď à ĺàŕĝē ţàƀĺē"],
Expand Down Expand Up @@ -2070,6 +2067,9 @@
"jerqF0": ["Ĺōàďĩńĝ ƀŕàńćĥ śţàţē"],
"jhtG3I": [" Ŷōũŕ śēĺēćţĩōń ĥàś ƀēēń ŕēśţōŕēď."],
"jjtmV_": ["Ƒĩŕśţ ƒĩĺē ńàḿē"],
"jnCROW": [
"À ḿēḿōŕŷ śńàƥśĥōţ ƒŕōḿ ţĥē ćŕàśĥ, àńď ţĥē àŕţĩƒàćţ ţĥàţ ĥēĺƥś ũś ḿōśţ. Ĩţ ćàń ćōńţàĩń ďōćũḿēńţ ćōńţēńţ àńď ćàń'ţ ƀē ŕēďàćţēď, śō ũńćĥēćķ ĩţ ĩƒ ŷōũ'ď ŕàţĥēŕ ńōţ śĥàŕē ĩţ."
],
"jol95m": ["Ƒàĩĺēď ţō ōƥēń ƤŕōĴēćţ Ńàvĩĝàţōŕ."],
"jpctdh": ["Vĩēŵ"],
"jtg_8-": [
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/locales/pseudo/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ msgid "A line with a footnote<0><1>[1]</1></0> and a definition shown below."
msgstr ""

#: src/components/ReportBugDialogBody.tsx
msgid "A memory snapshot from the crash. It can contain document content and can't be redacted, so leave it off unless you're comfortable sharing it."
msgid "A memory snapshot from the crash, and the artifact that helps us most. It can contain document content and can't be redacted, so uncheck it if you'd rather not share it."
msgstr ""

#: src/components/ShareReceiveDialog.tsx
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/logger-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,23 +168,34 @@ export type OkBugReportSendResult =
*
* Lives in core for the same reason as the sibling bug-report types: the
* desktop bridge contract's per-package copies must all name one type.
*
* `minidumpAvailable` is main's authoritative answer (only main can stat the
* crash-dumps dir) to "is there a crash minidump to include for this event?".
* The report dialog renders the crash-dump opt-in only when it is true — an
* invite with no dump (e.g. a dirty shutdown that left none) offers no dead
* checkbox. Advisory for UI gating only: the actual attach still re-checks the
* dump on disk at bundle time, so a dump that vanished by then is simply
* omitted.
*/
export type OkBugReportCrashDetectedEvent =
| {
eventId: string;
kind: 'render-process-gone';
context: { reason: string; exitCode?: number };
minidumpAvailable: boolean;
}
| {
eventId: string;
kind: 'child-process-gone';
context: { reason: string; processType: string; name?: string; exitCode?: number };
minidumpAvailable: boolean;
}
| {
eventId: string;
/** Boot-time detection: the previous session left a dirty-shutdown sentinel or fresh minidumps. */
kind: 'boot';
context: { dirtyShutdown: boolean; newMinidumps: number };
minidumpAvailable: boolean;
};

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/desktop/src/main/crash-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,18 @@ describe('runtime process-gone invitations', () => {
expect(event.context.reason).toBe('crashed');
expect(event.context.exitCode).toBe(5);
}
// No dump on disk for this crash, so the invite offers no dump option.
expect(event?.minidumpAvailable).toBe(false);
});

test('a renderer crash with a fresh minidump on disk reports it as available', () => {
const rig = makeRig();
const detection = createCrashDetection(rig.deps);
seedMinidump(rig, 'completed/renderer.dmp', rig.tick());

detection.handleRenderProcessGone({ reason: 'crashed' });

expect(rig.emitted[0]?.minidumpAvailable).toBe(true);
});

test('routine process teardown never invites', () => {
Expand Down Expand Up @@ -182,6 +194,8 @@ describe('boot-time detection', () => {
if (armed?.kind === 'boot') {
expect(armed.context.dirtyShutdown).toBe(true);
expect(armed.context.newMinidumps).toBe(0);
// A dirty shutdown that left no native dump offers no dump option.
expect(armed.minidumpAvailable).toBe(false);
}

// Boot events wait for the first ready renderer instead of racing window load.
Expand Down Expand Up @@ -246,6 +260,7 @@ describe('boot-time detection', () => {
if (armed?.kind === 'boot') {
expect(armed.context.dirtyShutdown).toBe(false);
expect(armed.context.newMinidumps).toBe(1);
expect(armed.minidumpAvailable).toBe(true);
}
if (!armed) throw new Error('expected a minidump-driven boot invitation');
sessionB.ack(armed.eventId);
Expand Down
Loading