diff --git a/electron/crashRecoveryFallback.test.js b/electron/crashRecoveryFallback.test.js new file mode 100644 index 00000000..ce26054f --- /dev/null +++ b/electron/crashRecoveryFallback.test.js @@ -0,0 +1,50 @@ +// Run: node --test electron/crashRecoveryFallback.test.js +// +// ENG-265: "the app sometimes dies when submitting the default onboarding input." The issue names +// three ways the app can actually die, and one of them is this: the crash-recovery DIALOG throws, and +// the handler answers by quitting. That throws the user's whole session away for a reason that says +// nothing about whether the app could have recovered, and it leaves no window and no explanation, +// which is exactly what "it just died" looks like from the outside. +// +// main.js is a single 5k-line module with no export surface, so this asserts the shape of the path +// rather than executing it. The properties that matter: the failure is recorded, recovery is +// attempted before quitting, and the attempt is one-shot so a broken recreate cannot loop. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const src = fs.readFileSync(path.join(__dirname, 'main.js'), 'utf8'); +const start = src.indexOf('async function showCrashRecoveryOverlay'); +const body = src.slice(start, src.indexOf('\n}', src.indexOf('crash-recovery-dialog-failed')) + 2); + +test('the dialog failure is written down before anything else happens', () => { + // A dead stdout eats console lines (ENG-264), so the console.error alone left this unexplainable. + assert.match(body, /writeCrashReport\('crash-recovery-dialog-failed'/); +}); + +test('a failed dialog attempts recovery before it quits', () => { + const failurePath = body.slice(body.indexOf('crash-recovery-dialog-failed')); + assert.match(failurePath, /recreateMainWindow\(\)/, 'quitting on a dialog failure is a session thrown away for nothing'); + assert.ok( + failurePath.indexOf('recreateMainWindow()') < failurePath.lastIndexOf('app.quit()'), + 'the recovery attempt must come before the quit, not after it', + ); +}); + +test('the fallback is one-shot, so a broken recreate cannot become a crash loop', () => { + assert.match(src, /let p_crashDialogFallbackUsed = false;/); + const failurePath = body.slice(body.indexOf('crash-recovery-dialog-failed')); + assert.match(failurePath, /if \(p_crashDialogFallbackUsed\)/, 'second time through must just quit'); + assert.match(failurePath, /p_crashDialogFallbackUsed = true;/); +}); + +test('a recreate that throws is recorded too, and then quits', () => { + const failurePath = body.slice(body.indexOf('crash-recovery-dialog-failed')); + assert.match(failurePath, /writeCrashReport\('crash-recovery-recreate-failed'/); +}); + +test('the user-chose-quit branch is untouched', () => { + // Both directions: recovering harder must not take away the user's explicit Quit. + assert.match(body, /result\.response === 0[\s\S]*?app\.quit\(\)/, 'Quit must still quit when the user picks it'); +}); diff --git a/electron/main.js b/electron/main.js index ae576ad6..ff100c5c 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1817,6 +1817,8 @@ function recreateMainWindow() { }); } +// One shot only, so a recreate that itself throws cannot turn into a crash loop. +let p_crashDialogFallbackUsed = false; // Crash recovery path B: the cap-exceeded fallback. Native dialog (not a BrowserWindow) so we cannot trigger the same observer-double-add DCHECK that motivated this whole change. User-driven Reload runs in a clean call stack outside the render-process-gone handler. async function showCrashRecoveryOverlay() { try { @@ -1852,7 +1854,24 @@ async function showCrashRecoveryOverlay() { crashCount: rendererCrashTimes.length, }); console.error('[main] showCrashRecoveryOverlay failed:', err && err.message); - app.quit(); + // Quitting because a DIALOG failed threw the user's whole session away for a reason that says + // nothing about whether the app could have recovered (ENG-265). Try the recovery we would have + // offered, once; quit only if that dies too, so a broken recreate cannot become a crash loop. + if (p_crashDialogFallbackUsed) { + app.quit(); + return; + } + p_crashDialogFallbackUsed = true; + try { + rendererCrashTimes = []; + recreateMainWindow(); + } catch (again) { + crashReports.writeCrashReport('crash-recovery-recreate-failed', { + message: String(again && again.message || again), + stack: String(again && again.stack || ''), + }); + app.quit(); + } } }