From 0cba971c79214173784a649c83f97033c21e23c5 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 27 Jul 2026 10:32:47 -0600 Subject: [PATCH 1/9] Match the startup tripwire against the full page-error description Playwright wraps a non-Error value thrown in the renderer into a bare `Error` whose `.message` collapses to something useless like "Object", keeping the real text only in the stack. `withFatalStartupTripwire` matched FATAL_STARTUP_PAGE_ERROR against `.message` alone, so a theme-settle failure arriving that way slipped past the tripwire and cost the launch its whole readiness budget instead of fast-failing. Add `describePageError()`, which combines the message, the stack, and a JSON dump of own-enumerable properties, and match the tripwire against that. The smoke fixture's `pageerror` logging uses it too, so the CI log shows the real error rather than "Page error: Object". Also record why the boot-time PlatformError rejections must stay log-only: promoting them to fatal signals was tried and reverted after CI showed launches that emit them usually recover. Co-Authored-By: Claude Opus 5 (1M context) --- e2e-tests/fixtures/app.fixture.ts | 3 ++- e2e-tests/fixtures/helpers.ts | 44 ++++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/e2e-tests/fixtures/app.fixture.ts b/e2e-tests/fixtures/app.fixture.ts index 6c1b9042..e707dc9b 100644 --- a/e2e-tests/fixtures/app.fixture.ts +++ b/e2e-tests/fixtures/app.fixture.ts @@ -12,6 +12,7 @@ import { preConfigureSettings, PROCESS_READY_TIMEOUT, teardownElectronApp, + describePageError, } from './helpers'; export { expect } from '@playwright/test'; @@ -65,7 +66,7 @@ export const test = base.extend({ * * @param err The error thrown in the page context. */ - const onPageError = (err: Error) => console.error(`Page error: ${err.message}`); + const onPageError = (err: Error) => console.error(`Page error: ${describePageError(err)}`); /** * Log console error messages from the page to the process console. diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index 4e049f85..ca5ca154 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -78,6 +78,33 @@ const SERVICE_HOST_OBJECT_METHODS = [ const FATAL_STARTUP_PAGE_ERROR = /Timeout reached when waiting for .*allThemeFamiliesById to settle/i; +/** + * Build a detailed description of a renderer page error for logging and pattern matching. + * Playwright surfaces uncaught page errors as `Error` objects, but when the page throws a non-Error + * value (e.g. a plain object) `.message` collapses to something useless like "Object" — hiding the + * real cause from both the CI log and {@link FATAL_STARTUP_PAGE_ERROR}, which is why a doomed cold + * start can burn its whole readiness budget instead of fast-failing. This combines the message, the + * stack (which often carries the true text for wrapped throwables), and a JSON dump of any + * own-enumerable properties so the real error survives to the log and to the tripwire regex. + * + * @param err The error surfaced by Playwright's `pageerror` event. + * @returns A newline-joined description containing every distinct detail recoverable from `err`. + */ +export function describePageError(err: Error): string { + const parts: string[] = []; + if (err.message) parts.push(err.message); + if (err.stack) parts.push(err.stack); + try { + // A real Error serializes to "{}" — its message and stack are non-enumerable — so only a wrapped + // non-Error throwable yields properties worth keeping; skip the empty/null cases. + const json = JSON.stringify(err); + if (json && json !== '{}' && json !== 'null') parts.push(json); + } catch { + // Circular/non-serializable value — the message and stack above are our best description. + } + return parts.join('\n'); +} + /** * Keep in sync with GET_METHODS from @shared/data/rpc.model. Required to be 'rpc.discover' by the * OpenRPC specification. @@ -621,6 +648,13 @@ interface DockTabTitlesOptions { * It wraps the WHOLE readiness sequence (service-host wait AND dock-tab wait) because the fatal * theme-settle error can surface during either and never self-recovers. * + * Deliberately NOT a tripwire signal: the unhandled `PlatformError` rejections paranext-core emits + * during boot (e.g. the `platform.getOSPlatform` -32601 not-found race). Tripping on those was + * tried and reverted — launches that emit them frequently still come up healthy, and because that + * boot race recurs on fresh launches, treating them as fatal fast-failed every retry of a + * recoverable start and turned flaky-but-green runs into hard failures. Do not promote a startup + * signal to fatal unless launches that emit it have been observed to NEVER recover. + * * The listener is registered only when `enabled` (a warm shared instance leaves it off, so a stale * error from a long-past cold start can't abort an otherwise-healthy wait), and always removed in * `finally` so it can't leak across tests on the shared CDP page. A sentinel distinguishes @@ -645,9 +679,13 @@ export async function withFatalStartupTripwire( const fatalErrorTripped = new Promise((_resolve, reject) => { if (!enabled) return; onFatalPageError = (err: Error) => { - if (!FATAL_STARTUP_PAGE_ERROR.test(err.message)) return; - fatalError.message = err.message; - reject(new Error(err.message)); + // Match against the full description, not just `err.message`: a non-Error throwable can carry + // the theme-settle text in its stack while its `.message` is a useless "Object", which would + // otherwise slip past the tripwire and cost this launch its whole readiness budget. + const described = describePageError(err); + if (!FATAL_STARTUP_PAGE_ERROR.test(described)) return; + fatalError.message = described; + reject(new Error(described)); }; page.on('pageerror', onFatalPageError); }); From d0d2ae6c3151a43df9be3b10ae0e705bb92d9f9d Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 27 Jul 2026 11:23:16 -0600 Subject: [PATCH 2/9] Make e2e setup and teardown cleanup failure-safe Seeding and launch now run inside the CDP setup's guarded path so a failure can't leak settings or the user-data dir, PID-marker removal no longer masks a successful kill, and the CDP teardown's tail always runs. Also drop stale simple-mode wording now that the suite forces Power mode. --- e2e-tests/fixtures/helpers.ts | 33 +++-- e2e-tests/global-setup-cdp.ts | 223 ++++++++++++++++++++-------------- e2e-tests/global-teardown.ts | 30 ++++- 3 files changed, 186 insertions(+), 100 deletions(-) diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index ca5ca154..4a33da15 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -204,6 +204,13 @@ export const E2E_SETTINGS_OVERRIDES: Record = { * the `.cdp-*` run-marker files). Shared by BOTH the smoke and CDP tiers, not one file per tier: * there is only one `settings.json` to protect, so a stale backup left by either tier's crashed run * can be recovered by the other tier's own self-heal (see {@link backupAndSeedSettings}). + * + * Sharing one file is safe only because seed/restore cycles never overlap: both Playwright configs + * set `workers: 1` and `fullyParallel: false`, and the tiers run sequentially. Two concurrent + * cycles would interleave — one restoring `settings.json` to the original while the other's app + * reads it at startup (dropping the overrides), and the backup could capture an already-seeded + * file. If either tier ever gains workers or the two are run at once, give each tier its own backup + * file. */ const SETTINGS_BACKUP_FILE = path.join(__dirname, '..', '.e2e-settings-backup'); @@ -279,9 +286,21 @@ export function restoreBackedUpSettings(): void { * @returns A restore function that puts the file back to its exact pre-call contents (or deletes it * if it did not exist). Call it AFTER the app has closed so the developer's saved settings are * not permanently replaced by test values. + * @throws Whatever {@link backupAndSeedSettings} throws (a filesystem error while backing up, + * creating the settings directory, or writing the seeded file). The caller never receives a + * restore function in that case, so this rolls the partial seed back itself before rethrowing — + * otherwise a mid-seed failure would strand the developer's settings in a seeded state. */ export function preConfigureSettings(overrides: Record): () => void { - backupAndSeedSettings(overrides); + try { + backupAndSeedSettings(overrides); + } catch (error) { + // Best-effort and non-throwing (see restoreBackedUpSettings), so this cannot mask `error`. If + // the backup file was written before the failure, this restores from it; if the failure came + // before that, there is nothing to undo and it is a no-op. + restoreBackedUpSettings(); + throw error; + } return restoreBackedUpSettings; } @@ -1393,9 +1412,9 @@ export async function ensureInterlinearizerOpenOnWeb(page: Page): Promise * partial references are ambiguous and are not auto-submitted by the control. * * The toolbar's book-chapter control is only ENABLED when BCV navigation has a resolved target web - * view. In the default simple mode that target is the first open Scripture editor with a project, - * NOT the focused Interlinearizer tab — so the control needs an open, project-bearing Scripture - * editor to be drivable at all. That precondition holds because callers run this after + * view. That target is the first open Scripture editor with a project, NOT the focused + * Interlinearizer tab — so the control needs an open, project-bearing Scripture editor to be + * drivable at all. That precondition holds because callers run this after * `ensureInterlinearizerOpenOnWeb`, which loads the WEB project into a Scripture Editor. * * The control can be momentarily disabled right after startup, before the target resolves, so this @@ -1425,9 +1444,9 @@ export async function navigateToScriptureRef(page: Page, reference: string): Pro throw new Error( `navigateToScriptureRef: the platform book-chapter control never became enabled, so ` + `"${reference}" could not be navigated to. This means BCV navigation has no resolved ` + - 'target: in the default simple interface mode the target is the first open Scripture editor ' + - 'with a project, so the WEB Scripture Editor is likely closed or its project failed to load. ' + - 'Ensure it is open (openInterlinearizerFromScriptureEditor) before navigating.', + 'target: the target is the first open Scripture editor with a project, so the WEB Scripture ' + + 'Editor is likely closed or its project failed to load. Ensure it is open ' + + '(openInterlinearizerFromScriptureEditor) before navigating.', ); } diff --git a/e2e-tests/global-setup-cdp.ts b/e2e-tests/global-setup-cdp.ts index 938f8253..0fb52da2 100644 --- a/e2e-tests/global-setup-cdp.ts +++ b/e2e-tests/global-setup-cdp.ts @@ -113,9 +113,15 @@ export default async function globalSetupCdp(_config: FullConfig): Promise const electronExecutable = coreRequire('electron') as string; // Isolated user-data dir so the singleton lock can't collide with a developer's own instance and - // the run leaves the real profile untouched. + // the run leaves the real profile untouched. If recording the ownership marker fails, remove the + // dir we just created rather than leaking an unreferenced temp dir teardown can never find. const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paranext-e2e-cdp-')); - fs.writeFileSync(CDP_USER_DATA_FILE, userDataDir); + try { + fs.writeFileSync(CDP_USER_DATA_FILE, userDataDir); + } catch (error) { + await removeDirWithRetry(userDataDir, 'CDP user-data dir'); + throw error; + } // VSCode/Claude Code set ELECTRON_RUN_AS_NODE=1, which forces the Electron binary to run as plain // Node.js. Omit it so the Electron child launches a real GUI process. @@ -126,83 +132,97 @@ export default async function globalSetupCdp(_config: FullConfig): Promise console.log(`Loading extension from: ${extensionDist}`); console.log(`Remote debugging on port ${CDP_PORT}`); - // Seed E2E_SETTINGS_OVERRIDES before launch and back up settings (restored by globalTeardownCdp - // after the launched app is killed). - seedE2ESettingsOverrides(); + // Everything from the settings seed onward acquires state this setup owns (seeded settings, the + // log fd, the launched process, the PID marker). Playwright does not run globalTeardown when + // globalSetup throws, so a failure anywhere in here must clean up on the spot rather than leak to + // the next run — hence the single try/catch spanning both the launch and the readiness waits, + // rather than one starting only at the waits. + let appProcess: ReturnType | undefined; + let exited: Promise | undefined; + try { + // Seed E2E_SETTINGS_OVERRIDES before launch and back up settings (restored by globalTeardownCdp + // after the launched app is killed). + seedE2ESettingsOverrides(); - // Stream the app's output to a log file rather than discarding it (`stdio: 'ignore'`): when the - // app crashes on startup the only other symptom is an opaque WebSocket-port timeout below. - const appLogFd = fs.openSync(CDP_APP_LOG_FILE, 'w'); + // Stream the app's output to a log file rather than discarding it (`stdio: 'ignore'`): when the + // app crashes on startup the only other symptom is an opaque WebSocket-port timeout below. + const appLogFd = fs.openSync(CDP_APP_LOG_FILE, 'w'); - // Detached: the CDP fixture connects to this process over CDP, so Playwright must not own its - // lifecycle. Teardown kills the whole tree by the recorded PID. - const appProcess = spawn( - electronExecutable, - [ - `--user-data-dir=${userDataDir}`, - coreDir, - '--extensions', - extensionDist, - `--remote-debugging-port=${CDP_PORT}`, - // Deterministic window size matching the CI xvfb screen (1280x960) so dock panels have room - // and modals aren't clipped. paranext-core supports this arg for automation. - '--window-size', - '1280x960', - // --no-sandbox: GitHub-hosted Linux runners lack a setuid chrome-sandbox binary, so Electron's - // SUID sandbox helper aborts on launch. --ozone-platform=x11: in a Wayland session with - // DISPLAY redirected to xvfb (local headless runs), Electron otherwise picks Wayland and - // segfaults when the compositor socket is unreachable; no-op on CI runners (X11-only). - ...(process.platform === 'linux' ? ['--no-sandbox', '--ozone-platform=x11'] : []), - ], - { - cwd: coreDir, - env: { - ...restEnv, - NODE_ENV: 'development', - DEV_NOISY: process.env.DEV_NOISY ?? 'false', - // With NODE_ENV=development, paranext-core auto-opens DevTools on every window; on CI Linux - // that docks inside the window and squeezes the viewport so dock panels collapse and clicks - // land on neighboring iframes. ELECTRON_IS_DEV=0 disables the auto-open without changing - // other NODE_ENV-driven behavior (dev-server URL, etc.). - ELECTRON_IS_DEV: '0', - }, - stdio: ['ignore', appLogFd, appLogFd], - detached: true, - }, - ); - appProcess.unref(); - // The child has inherited the fd; close our copy so the file is flushed and released on exit. - fs.closeSync(appLogFd); + // Detached: the CDP fixture connects to this process over CDP, so Playwright must not own its + // lifecycle. Teardown kills the whole tree by the recorded PID. + try { + appProcess = spawn( + electronExecutable, + [ + `--user-data-dir=${userDataDir}`, + coreDir, + '--extensions', + extensionDist, + `--remote-debugging-port=${CDP_PORT}`, + // Deterministic window size matching the CI xvfb screen (1280x960) so dock panels have + // room and modals aren't clipped. paranext-core supports this arg for automation. + '--window-size', + '1280x960', + // --no-sandbox: GitHub-hosted Linux runners lack a setuid chrome-sandbox binary, so + // Electron's SUID sandbox helper aborts on launch. --ozone-platform=x11: in a Wayland + // session with DISPLAY redirected to xvfb (local headless runs), Electron otherwise picks + // Wayland and segfaults when the compositor socket is unreachable; no-op on CI runners + // (X11-only). + ...(process.platform === 'linux' ? ['--no-sandbox', '--ozone-platform=x11'] : []), + ], + { + cwd: coreDir, + env: { + ...restEnv, + NODE_ENV: 'development', + DEV_NOISY: process.env.DEV_NOISY ?? 'false', + // With NODE_ENV=development, paranext-core auto-opens DevTools on every window; on CI + // Linux that docks inside the window and squeezes the viewport so dock panels collapse + // and clicks land on neighboring iframes. ELECTRON_IS_DEV=0 disables the auto-open + // without changing other NODE_ENV-driven behavior (dev-server URL, etc.). + ELECTRON_IS_DEV: '0', + }, + stdio: ['ignore', appLogFd, appLogFd], + detached: true, + }, + ); + } finally { + // Close our copy of the fd whether or not the spawn succeeded: on success the child has + // inherited it (so the file is flushed and released when the child exits), and on failure + // there is no child to inherit it and leaving it open would leak the descriptor. + fs.closeSync(appLogFd); + } + appProcess.unref(); - if (appProcess.pid) fs.writeFileSync(CDP_PID_FILE, String(appProcess.pid)); + if (appProcess.pid) fs.writeFileSync(CDP_PID_FILE, String(appProcess.pid)); - /** - * Rejects the moment {@link appProcess} exits, so a startup crash fails setup immediately instead - * of surfacing only after the full {@link APP_READY_TIMEOUT} port-wait elapses. Raced against both - * port waits and the renderer-settle wait, since a crash can come after any of them completes. - */ - const earlyExit = new Promise((_resolve, reject) => { - appProcess.once('exit', (code, signal) => { - reject( - new Error( - `Launched Platform.Bible (CDP) process exited early (code=${code}, signal=${signal}) ` + - 'during startup, before it became ready.', - ), - ); + /** + * Rejects the moment {@link appProcess} exits, so a startup crash fails setup immediately + * instead of surfacing only after the full {@link APP_READY_TIMEOUT} port-wait elapses. Raced + * against both port waits and the renderer-settle wait, since a crash can come after any of + * them completes. + */ + const earlyExit = new Promise((_resolve, reject) => { + appProcess?.once('exit', (code, signal) => { + reject( + new Error( + `Launched Platform.Bible (CDP) process exited early (code=${code}, signal=${signal}) ` + + 'during startup, before it became ready.', + ), + ); + }); }); - }); - // Setup returns with the app running, so nothing awaits earlyExit after the waits below — mark it - // handled so the eventual teardown kill (same 'exit' event) can't surface as an unhandled - // rejection. - earlyExit.catch(() => {}); + // Setup returns with the app running, so nothing awaits earlyExit after the waits below — mark + // it handled so the eventual teardown kill (same 'exit' event) can't surface as an unhandled + // rejection. + earlyExit.catch(() => {}); - // Resolves once the process exits (unlike earlyExit, which rejects). Allows the failure-path - // cleanup to bound its wait for a just-issued SIGKILL to take effect. - const exited = new Promise((resolve) => { - appProcess.once('exit', () => resolve()); - }); + // Resolves once the process exits (unlike earlyExit, which rejects). Allows the failure-path + // cleanup to bound its wait for a just-issued SIGKILL to take effect. + exited = new Promise((resolve) => { + appProcess?.once('exit', () => resolve()); + }); - try { console.log(`Waiting for PAPI WebSocket on port ${WEBSOCKET_PORT}...`); await Promise.race([waitForPort(WEBSOCKET_PORT, APP_READY_TIMEOUT), earlyExit]); console.log(`Waiting for CDP debug port ${CDP_PORT}...`); @@ -213,29 +233,52 @@ export default async function globalSetupCdp(_config: FullConfig): Promise ); await Promise.race([waitForRendererSettled(RENDERER_SETTLE_TIMEOUT), earlyExit]); } catch (error) { - // The app never came up (or came up broken). Echo its captured output so the cause is in the CI - // log itself, not only the uploaded artifact. - dumpAppLog(); - // Playwright does not run globalTeardown when globalSetup throws, so clean up everything this - // setup owns right here instead of leaking it to the next run. - const killed = appProcess.pid ? killProcessTree(appProcess.pid, 'SIGKILL') : false; - if (killed) { - await waitForProcessExit(exited, 1_000); - } - await removeDirWithRetry(userDataDir, 'CDP user-data dir'); - // Restore settings after waiting for the kill above to take effect. The still-running app owns - // dev-appdata/settings.json, so restoring before it's dead risks flushing the seeded value back - // over the just-restored original, with no backup left to recover from. - restoreSeededSettings(); - fs.rmSync(CDP_PID_FILE, { force: true }); - fs.rmSync(CDP_USER_DATA_FILE, { force: true }); - // Also stop the renderer dev server that bootstrapRendererDevServer() may have started. - killProcessFromPidFile(DEV_SERVER_PID_FILE, 'SIGTERM', 'renderer dev server'); + await cleanUpFailedLaunch(userDataDir, appProcess?.pid, exited); throw error; } console.log('Platform.Bible (CDP) is ready.'); } +/** + * Undo everything {@link globalSetupCdp} acquired, after its launch failed. Playwright skips + * globalTeardown when globalSetup throws, so this is the only chance to release the seeded + * settings, the launched process, its user-data dir, the ownership markers, and the renderer dev + * server before the failure propagates — otherwise each leaks into the next run. + * + * Callable at any point after the settings seed, including before a process was spawned: an absent + * `pid` simply skips the kill. Every step is individually best-effort (the kill, the dir removal, + * and the settings restore all swallow their own errors), so one failure cannot strand the rest. + * + * @param userDataDir Isolated user-data dir created for the launch, removed here. + * @param pid PID of the launched app if it was spawned; `undefined` if the failure preceded the + * spawn or the process never reported a PID. + * @param exited Promise resolving when the launched process exits, used to bound the post-SIGKILL + * wait; `undefined` when no process was spawned. + * @returns Resolves once every cleanup step has been attempted. + */ +async function cleanUpFailedLaunch( + userDataDir: string, + pid: number | undefined, + exited: Promise | undefined, +): Promise { + // The app never came up (or came up broken). Echo its captured output so the cause is in the CI + // log itself, not only the uploaded artifact. + dumpAppLog(); + const killed = pid ? killProcessTree(pid, 'SIGKILL') : false; + if (killed && exited) { + await waitForProcessExit(exited, 1_000); + } + await removeDirWithRetry(userDataDir, 'CDP user-data dir'); + // Restore settings after waiting for the kill above to take effect. The still-running app owns + // dev-appdata/settings.json, so restoring before it's dead risks flushing the seeded value back + // over the just-restored original, with no backup left to recover from. + restoreSeededSettings(); + fs.rmSync(CDP_PID_FILE, { force: true }); + fs.rmSync(CDP_USER_DATA_FILE, { force: true }); + // Also stop the renderer dev server that bootstrapRendererDevServer() may have started. + killProcessFromPidFile(DEV_SERVER_PID_FILE, 'SIGTERM', 'renderer dev server'); +} + /** * Connect to the launched app over CDP and wait for its renderer to settle: the renderer page * exists, the dock tabs have real titles (none stuck at "Unknown"), and the interlinearizer diff --git a/e2e-tests/global-teardown.ts b/e2e-tests/global-teardown.ts index ca99905a..4411b4f9 100644 --- a/e2e-tests/global-teardown.ts +++ b/e2e-tests/global-teardown.ts @@ -14,6 +14,24 @@ export interface KillFromPidFileResult { pid?: number; } +/** + * Remove a PID marker file, swallowing any filesystem error. Separate from the kill it accompanies + * so a marker-removal failure cannot be mistaken for a kill failure (see the call sites in + * {@link killProcessFromPidFile}). A leftover marker is harmless — the next run re-reads it and + * finds a dead PID — so warning and continuing beats aborting teardown. + * + * @param pidFile Absolute path to the PID marker file to remove. + * @param label Human-readable name of the process the marker belongs to, used in the warning log. + * @returns Nothing. + */ +function removePidFile(pidFile: string, label: string): void { + try { + fs.unlinkSync(pidFile); + } catch (e) { + console.warn(`Could not remove ${label} PID marker ${pidFile}: ${e}`); + } +} + /** * Kill a process recorded in a PID file (whole tree), then remove the PID file. Shared by this * teardown's dev-server kill and {@link globalTeardownCdp}'s launched-app kill, which follow the @@ -23,7 +41,9 @@ export interface KillFromPidFileResult { * A missing PID file is a no-op; a file whose contents don't parse as an integer is warned about * and skipped rather than used to kill an arbitrary PID. The PID file is always removed when * present, so no run leaves a stale marker behind. A filesystem error (concurrent deletion, a - * locked file) is warned about and swallowed so teardown continues rather than aborting. + * locked file) is warned about and swallowed so teardown continues rather than aborting — with the + * marker removal guarded separately (see {@link removePidFile}) so it cannot downgrade a successful + * kill to `killed: false`. * * @param pidFile Absolute path to the file holding the target process's PID. * @param signal Kill signal to send (`'SIGKILL'` when the target may ignore SIGTERM). @@ -44,12 +64,16 @@ export function killProcessFromPidFile( const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10); if (Number.isNaN(pid)) { console.warn(`Invalid PID in ${pidFile}, skipping ${label} kill`); - fs.unlinkSync(pidFile); + removePidFile(pidFile, label); return { killed: false }; } console.log(`Stopping ${label} (PID: ${pid})...`); const killed = killProcessTree(pid, signal); - fs.unlinkSync(pidFile); + // Remove the marker independently of the kill result: an fs error here says nothing about + // whether the kill succeeded, and callers act on `killed`/`pid` (globalTeardownCdp waits for the + // PID to exit before removing the app's user-data dir). Folding this into the outer catch would + // report a successful kill as `killed: false` and skip that wait, hitting a live SingletonLock. + removePidFile(pidFile, label); return { killed, pid }; } catch (e) { console.warn(`Failed to kill ${label} from ${pidFile}: ${e}`); From ee33444dfe5e6a2d79b97566f7086c8c534780cd Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 27 Jul 2026 11:40:16 -0600 Subject: [PATCH 3/9] Document backupAndSeedSettings' failure conditions --- e2e-tests/fixtures/helpers.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index 4a33da15..401577f9 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -228,6 +228,11 @@ const SETTINGS_ABSENT_SENTINEL = '__SETTINGS_ABSENT__'; * @param overrides Setting keys to merge into the file (e.g. `{ 'platform.firstRunComplete': true * }`). * @returns Nothing. + * @throws A filesystem error while backing up the original settings, creating the settings + * directory, or writing the seeded file. The seed may be partially applied when it throws, so a + * caller that has to leave the developer's settings untouched must roll it back with + * {@link restoreBackedUpSettings} (see {@link preConfigureSettings}). A corrupt settings file is + * not an error — it is overwritten with just the overrides. */ export function backupAndSeedSettings(overrides: Record): void { restoreBackedUpSettings(); From 23c5e866a5ab327745f1a586d04bff881acf9df4 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 27 Jul 2026 11:49:33 -0600 Subject: [PATCH 4/9] Kill a never-ready renderer dev server before setup fails Playwright skips globalTeardown when globalSetup throws, so a dev server spawned here but stuck before readiness survived the failed run, holding port 1212 and serving a stale bundle to later runs. --- e2e-tests/global-setup-cdp.ts | 5 ++- e2e-tests/global-setup.ts | 80 ++++++++++++++++++++++++++++------- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/e2e-tests/global-setup-cdp.ts b/e2e-tests/global-setup-cdp.ts index 0fb52da2..e5032570 100644 --- a/e2e-tests/global-setup-cdp.ts +++ b/e2e-tests/global-setup-cdp.ts @@ -114,12 +114,15 @@ export default async function globalSetupCdp(_config: FullConfig): Promise // Isolated user-data dir so the singleton lock can't collide with a developer's own instance and // the run leaves the real profile untouched. If recording the ownership marker fails, remove the - // dir we just created rather than leaking an unreferenced temp dir teardown can never find. + // dir we just created rather than leaking an unreferenced temp dir teardown can never find, and + // stop the dev server the bootstrap above just started — this is past bootstrapRendererDevServer's + // own cleanup but before the try/catch below, so nothing else would. const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paranext-e2e-cdp-')); try { fs.writeFileSync(CDP_USER_DATA_FILE, userDataDir); } catch (error) { await removeDirWithRetry(userDataDir, 'CDP user-data dir'); + killProcessFromPidFile(DEV_SERVER_PID_FILE, 'SIGTERM', 'renderer dev server'); throw error; } diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index eba2b266..c927e6f6 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -5,6 +5,7 @@ import http from 'http'; import net from 'net'; import path from 'path'; import fs from 'fs'; +import { killProcessTree } from './process-utils'; export const WEBSOCKET_PORT = 8876; export const RENDERER_PORT = 1212; @@ -149,9 +150,15 @@ export function waitForPort(port: number, timeout: number): Promise { * (recording its PID for teardown). Shared by both the smoke {@link globalSetup} (whose fixture then * launches Electron) and the CDP setup (which launches Electron itself with remote debugging). * + * Self-cleaning on failure: if a dev server started here never becomes ready, it is killed before + * the error propagates (see {@link killSpawnedDevServer}), so neither caller leaks it. Callers + * therefore need no dev-server cleanup of their own for a bootstrap failure. + * * @returns Resolves when the renderer dev server is ready. * @throws {Error} If port 8876 is already in use (a running Platform.Bible would conflict). * @throws {Error} If the extension dist is missing. + * @throws {Error} If a dev server started here does not open port 1212 within 60s, or (outside CI) + * fails the HTTP compilation probe within 120s. */ export async function bootstrapRendererDevServer(): Promise { const extensionRoot = path.resolve(__dirname, '..'); @@ -225,29 +232,70 @@ export async function bootstrapRendererDevServer(): Promise { fs.writeFileSync(DEV_SERVER_PID_FILE, String(devServer.pid)); } - console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`); - await waitForPort(RENDERER_PORT, 60_000); - console.log( - `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, - ); - // webpack-dev-middleware holds requests open until initial compilation finishes. Probe a - // renderer URL to opportunistically wait for compilation, but do not hard-fail CI on this - // probe because CI runners can be noisy and late-compiling; the fixture has a longer CI-ready - // timeout and will keep waiting for the renderer window to recover. + // From here on this run owns a spawned, detached dev server. Playwright skips globalTeardown + // when globalSetup throws, so a readiness failure below must kill it on the spot or it survives + // the failed run, holds port 1212, and silently serves a stale bundle to every later run. + // Scoped to this branch only: the already-running branch above didn't start the server, so this + // run must leave it alone. try { - await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/`, 120_000); - } catch (error) { - if (!process.env.CI) throw error; - const message = - error instanceof Error ? error.message : 'Unknown renderer readiness probe failure'; - console.warn( - `Renderer HTTP readiness probe timed out in CI: ${message}. Continuing with port-only readiness.`, + console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`); + await waitForPort(RENDERER_PORT, 60_000); + console.log( + `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, ); + // webpack-dev-middleware holds requests open until initial compilation finishes. Probe a + // renderer URL to opportunistically wait for compilation, but do not hard-fail CI on this + // probe because CI runners can be noisy and late-compiling; the fixture has a longer CI-ready + // timeout and will keep waiting for the renderer window to recover. + try { + await waitForHttpOk(`http://127.0.0.1:${RENDERER_PORT}/`, 120_000); + } catch (error) { + if (!process.env.CI) throw error; + const message = + error instanceof Error ? error.message : 'Unknown renderer readiness probe failure'; + console.warn( + `Renderer HTTP readiness probe timed out in CI: ${message}. Continuing with port-only readiness.`, + ); + } + } catch (error) { + killSpawnedDevServer(devServer.pid); + throw error; } console.log('Renderer dev server is ready.'); } } +/** + * Kill a dev server this run spawned but never got ready, and clear its PID marker. Used only on + * {@link bootstrapRendererDevServer}'s failure path, where the caller's own cleanup cannot reach: + * the CDP setup's `cleanUpFailedLaunch` runs only for failures after the bootstrap returns, and + * Playwright skips `globalTeardown` entirely when `globalSetup` throws. + * + * Deliberately does not reuse `killProcessFromPidFile` from global-teardown.ts: importing it here + * would make setup and teardown mutually dependent (teardown already imports + * {@link DEV_SERVER_PID_FILE} from this module). The PID is in hand anyway, so the read-back that + * helper exists to do is unnecessary. + * + * Best-effort: every step swallows its own error, since this runs while an exception is already + * propagating and must not replace the real failure with a cleanup one. + * + * @param pid PID of the spawned dev server; `undefined` if the spawn never reported one, in which + * case there is nothing to kill and no marker was written. + * @returns Nothing. + */ +function killSpawnedDevServer(pid: number | undefined): void { + if (!pid) return; + console.log(`Renderer dev server failed to become ready — stopping it (PID: ${pid})...`); + killProcessTree(pid, 'SIGTERM'); + try { + fs.rmSync(DEV_SERVER_PID_FILE, { force: true }); + } catch (error) { + console.warn( + `Could not remove renderer dev server PID marker ${DEV_SERVER_PID_FILE}: ${error}`, + ); + } +} + /** * Playwright global setup for the smoke config. Runs once before any test worker starts. Bootstraps * the renderer dev server via {@link bootstrapRendererDevServer}; the smoke fixture From fb397efd273d50eb3f7545a2b3fdc7d38ecdebf1 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 27 Jul 2026 14:34:43 -0600 Subject: [PATCH 5/9] Register the app-exit signal before the PID-marker write A throw from the CDP_PID_FILE write left `exited` undefined, so cleanUpFailedLaunch skipped its post-SIGKILL wait and removed the user-data dir while the app still held it. --- e2e-tests/global-setup-cdp.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/e2e-tests/global-setup-cdp.ts b/e2e-tests/global-setup-cdp.ts index e5032570..d71475a3 100644 --- a/e2e-tests/global-setup-cdp.ts +++ b/e2e-tests/global-setup-cdp.ts @@ -197,6 +197,14 @@ export default async function globalSetupCdp(_config: FullConfig): Promise } appProcess.unref(); + // Resolves once the process exits (unlike earlyExit below, which rejects). Allows the + // failure-path cleanup to bound its wait for a just-issued SIGKILL to take effect. Registered + // before the PID-marker write so a write failure still reaches cleanUpFailedLaunch with a usable + // exit signal rather than making it skip the post-kill wait. + exited = new Promise((resolve) => { + appProcess?.once('exit', () => resolve()); + }); + if (appProcess.pid) fs.writeFileSync(CDP_PID_FILE, String(appProcess.pid)); /** @@ -220,12 +228,6 @@ export default async function globalSetupCdp(_config: FullConfig): Promise // rejection. earlyExit.catch(() => {}); - // Resolves once the process exits (unlike earlyExit, which rejects). Allows the failure-path - // cleanup to bound its wait for a just-issued SIGKILL to take effect. - exited = new Promise((resolve) => { - appProcess?.once('exit', () => resolve()); - }); - console.log(`Waiting for PAPI WebSocket on port ${WEBSOCKET_PORT}...`); await Promise.race([waitForPort(WEBSOCKET_PORT, APP_READY_TIMEOUT), earlyExit]); console.log(`Waiting for CDP debug port ${CDP_PORT}...`); From 5ec8990816ca2e9e36ff70a9e8f189ef6c905f71 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 27 Jul 2026 16:14:40 -0600 Subject: [PATCH 6/9] Scope the BCV nav helper to the top toolbar's control Power mode renders a per-tab BookChapterControl with the same aria-label and no disabled prop, so a page-wide .first() relied on render order and would have made the enabled-wait vacuous. --- e2e-tests/fixtures/helpers.ts | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/e2e-tests/fixtures/helpers.ts b/e2e-tests/fixtures/helpers.ts index 401577f9..44365f7c 100644 --- a/e2e-tests/fixtures/helpers.ts +++ b/e2e-tests/fixtures/helpers.ts @@ -1416,11 +1416,24 @@ export async function ensureInterlinearizerOpenOnWeb(page: Page): Promise * submits it. Requires a fully-qualified reference (book, chapter, and verse — e.g. `"GEN 1:1"`); * partial references are ambiguous and are not auto-submitted by the control. * - * The toolbar's book-chapter control is only ENABLED when BCV navigation has a resolved target web - * view. That target is the first open Scripture editor with a project, NOT the focused - * Interlinearizer tab — so the control needs an open, project-bearing Scripture editor to be - * drivable at all. That precondition holds because callers run this after - * `ensureInterlinearizerOpenOnWeb`, which loads the WEB project into a Scripture Editor. + * Drives the TOP TOOLBAR's book-chapter control specifically. In Power mode — which + * {@link E2E_SETTINGS_OVERRIDES} forces for every launch — it is not the only one on the page: + * paranext-core also renders a PER-TAB `BookChapterControl` in each web view's own tab toolbar, and + * every instance carries the same `aria-label="book-chapter-trigger"`. So the trigger is scoped to + * the toolbar's `toolbar-reserved-space-wrapper` root rather than selected page-wide; a bare + * `.first()` would depend on the toolbar happening to render before the dock layout in document + * order, and would silently drive some tab's control if paranext-core ever reordered them. + * + * The scoping also matters for the enabled-wait below, because the two kinds of control differ: + * only the toolbar's is passed a `disabled` prop (bound to whether BCV navigation has a resolved + * target web view). The per-tab controls receive no such prop and are therefore ALWAYS enabled, so + * waiting on one of those would make the wait vacuous — it would pass even with no navigation + * target resolved. + * + * That target is the first open Scripture editor with a project, NOT the focused Interlinearizer + * tab — so the toolbar control needs an open, project-bearing Scripture editor to be drivable at + * all. That precondition holds because callers run this after `ensureInterlinearizerOpenOnWeb`, + * which loads the WEB project into a Scripture Editor. * * The control can be momentarily disabled right after startup, before the target resolves, so this * waits a BOUNDED time for it to enable before clicking; a plain click on a still-disabled button @@ -1435,7 +1448,13 @@ export async function ensureInterlinearizerOpenOnWeb(page: Page): Promise * if it is enabled but its popover does not open or close within the timeouts. */ export async function navigateToScriptureRef(page: Page, reference: string): Promise { - const trigger = page.locator('button[aria-label="book-chapter-trigger"]').first(); + // Scope to the top toolbar's own root: in Power mode each open web view tab renders its own + // identically-labeled (and always-enabled) BookChapterControl, so a page-wide lookup would be + // ambiguous. `.first()` stays as a strict-mode guard within that single scope. + const trigger = page + .getByTestId('toolbar-reserved-space-wrapper') + .locator('button[aria-label="book-chapter-trigger"]') + .first(); // Bounded wait for the control to become enabled, covering the brief post-launch window before the // navigation target resolves. `toBeEnabled` polls (unlike `isEnabled`, which reads once); swallow @@ -1447,7 +1466,7 @@ export async function navigateToScriptureRef(page: Page, reference: string): Pro .catch(() => false); if (!enabled) { throw new Error( - `navigateToScriptureRef: the platform book-chapter control never became enabled, so ` + + `navigateToScriptureRef: the top toolbar's book-chapter control never became enabled, so ` + `"${reference}" could not be navigated to. This means BCV navigation has no resolved ` + 'target: the target is the first open Scripture editor with a project, so the WEB Scripture ' + 'Editor is likely closed or its project failed to load. Ensure it is open ' + From 1e1da28299ab2fe2c1601d92bcbd1ba1dc0ce263 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Mon, 27 Jul 2026 16:53:01 -0600 Subject: [PATCH 7/9] Move the dev-server PID-marker write inside its cleanup try --- e2e-tests/global-setup.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index c927e6f6..0ac0e8a0 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -228,16 +228,18 @@ export async function bootstrapRendererDevServer(): Promise { devServer.unref(); - if (devServer.pid) { - fs.writeFileSync(DEV_SERVER_PID_FILE, String(devServer.pid)); - } - // From here on this run owns a spawned, detached dev server. Playwright skips globalTeardown - // when globalSetup throws, so a readiness failure below must kill it on the spot or it survives - // the failed run, holds port 1212, and silently serves a stale bundle to every later run. + // when globalSetup throws, so a failure below must kill it on the spot or it survives the + // failed run, holds port 1212, and silently serves a stale bundle to every later run. The + // PID-marker write is inside this try for the same reason: a synchronous writeFileSync failure + // would otherwise leak the running server with no marker for any later teardown to find it by. // Scoped to this branch only: the already-running branch above didn't start the server, so this // run must leave it alone. try { + if (devServer.pid) { + fs.writeFileSync(DEV_SERVER_PID_FILE, String(devServer.pid)); + } + console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`); await waitForPort(RENDERER_PORT, 60_000); console.log( From 46f776ec774499a270b27882583ecb7627fe5576 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 15:36:03 -0600 Subject: [PATCH 8/9] Close two cleanup gaps on the CDP setup failure path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed spawn reports itself by emitting 'error' asynchronously, and an unhandled 'error' on an EventEmitter throws as an uncaughtException — outside the try/catch, so cleanUpFailedLaunch never ran and the seeded settings were left in place. Race a listener-backed rejection against the readiness waits in both setups instead. Creating the isolated user-data dir also sat outside its cleanup try, so a failure there left the just-started renderer dev server running. Co-Authored-By: Claude Opus 5 (1M context) --- e2e-tests/global-setup-cdp.ts | 38 ++++++++++++++++++++++++++--------- e2e-tests/global-setup.ts | 17 +++++++++++++++- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/e2e-tests/global-setup-cdp.ts b/e2e-tests/global-setup-cdp.ts index d71475a3..aa861d4a 100644 --- a/e2e-tests/global-setup-cdp.ts +++ b/e2e-tests/global-setup-cdp.ts @@ -113,15 +113,17 @@ export default async function globalSetupCdp(_config: FullConfig): Promise const electronExecutable = coreRequire('electron') as string; // Isolated user-data dir so the singleton lock can't collide with a developer's own instance and - // the run leaves the real profile untouched. If recording the ownership marker fails, remove the - // dir we just created rather than leaking an unreferenced temp dir teardown can never find, and - // stop the dev server the bootstrap above just started — this is past bootstrapRendererDevServer's - // own cleanup but before the try/catch below, so nothing else would. - const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paranext-e2e-cdp-')); + // the run leaves the real profile untouched. Creating the dir and recording its ownership marker + // are both covered here: this is past bootstrapRendererDevServer's own cleanup but before the + // try/catch below, so a failure at either step would otherwise leave the dev server it just + // started running, plus an unreferenced temp dir that teardown can never find. + let userDataDir = ''; try { + userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'paranext-e2e-cdp-')); fs.writeFileSync(CDP_USER_DATA_FILE, userDataDir); } catch (error) { - await removeDirWithRetry(userDataDir, 'CDP user-data dir'); + // Empty when mkdtempSync itself failed, in which case there is no dir to remove. + if (userDataDir) await removeDirWithRetry(userDataDir, 'CDP user-data dir'); killProcessFromPidFile(DEV_SERVER_PID_FILE, 'SIGTERM', 'renderer dev server'); throw error; } @@ -197,6 +199,24 @@ export default async function globalSetupCdp(_config: FullConfig): Promise } appProcess.unref(); + /** + * Rejects if the process could not be spawned at all (a missing or non-executable Electron + * binary). Registering a listener is what makes this recoverable: `spawn` reports such failures + * by emitting `'error'` asynchronously, and an unhandled `'error'` on an EventEmitter throws as + * an uncaughtException outside this try/catch — killing the setup process before any cleanup + * runs and stranding the seeded settings. No `'exit'` follows a failed spawn, so neither + * {@link earlyExit} nor `exited` covers this. + */ + const spawnFailed = new Promise((_resolve, reject) => { + appProcess?.once('error', (error) => { + reject( + new Error(`Failed to spawn Platform.Bible (CDP) at ${electronExecutable}: ${error}`), + ); + }); + }); + // Handled for the same reason as earlyExit below: setup returns with nothing awaiting this. + spawnFailed.catch(() => {}); + // Resolves once the process exits (unlike earlyExit below, which rejects). Allows the // failure-path cleanup to bound its wait for a just-issued SIGKILL to take effect. Registered // before the PID-marker write so a write failure still reaches cleanUpFailedLaunch with a usable @@ -229,14 +249,14 @@ export default async function globalSetupCdp(_config: FullConfig): Promise earlyExit.catch(() => {}); console.log(`Waiting for PAPI WebSocket on port ${WEBSOCKET_PORT}...`); - await Promise.race([waitForPort(WEBSOCKET_PORT, APP_READY_TIMEOUT), earlyExit]); + await Promise.race([waitForPort(WEBSOCKET_PORT, APP_READY_TIMEOUT), earlyExit, spawnFailed]); console.log(`Waiting for CDP debug port ${CDP_PORT}...`); - await Promise.race([waitForPort(CDP_PORT, APP_READY_TIMEOUT), earlyExit]); + await Promise.race([waitForPort(CDP_PORT, APP_READY_TIMEOUT), earlyExit, spawnFailed]); console.log( 'Ports are up. Waiting for the renderer to settle (dock tabs with real titles) and the ' + 'interlinearizer extension to activate...', ); - await Promise.race([waitForRendererSettled(RENDERER_SETTLE_TIMEOUT), earlyExit]); + await Promise.race([waitForRendererSettled(RENDERER_SETTLE_TIMEOUT), earlyExit, spawnFailed]); } catch (error) { await cleanUpFailedLaunch(userDataDir, appProcess?.pid, exited); throw error; diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 0ac0e8a0..51c8e225 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -236,12 +236,27 @@ export async function bootstrapRendererDevServer(): Promise { // Scoped to this branch only: the already-running branch above didn't start the server, so this // run must leave it alone. try { + /** + * Rejects if the dev server could not be spawned. `spawn` reports that by emitting `'error'` + * asynchronously, and an unhandled `'error'` on an EventEmitter throws as an + * uncaughtException that no catch here could reach; racing it against the readiness wait + * routes it through this block's cleanup instead, and fails immediately rather than after the + * full 60s port wait. + */ + const spawnFailed = new Promise((_resolve, reject) => { + devServer.once('error', (error) => { + reject(new Error(`Failed to spawn the renderer dev server: ${error}`)); + }); + }); + // Nothing awaits this once the waits below succeed, so mark it handled. + spawnFailed.catch(() => {}); + if (devServer.pid) { fs.writeFileSync(DEV_SERVER_PID_FILE, String(devServer.pid)); } console.log(`Waiting for renderer dev server on port ${RENDERER_PORT}...`); - await waitForPort(RENDERER_PORT, 60_000); + await Promise.race([waitForPort(RENDERER_PORT, 60_000), spawnFailed]); console.log( `Port ${RENDERER_PORT} is accepting connections. Waiting for webpack compilation...`, ); From 8a16355170302e4f1337a8d17b4d64a639d91462 Mon Sep 17 00:00:00 2001 From: Alex Rawlings Date: Tue, 28 Jul 2026 15:46:40 -0600 Subject: [PATCH 9/9] Drop a stale dev-server PID marker on the reuse path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker is only written when a run spawns the server, so one left on disk here belongs to a prior run — killing by it could hit a process the OS has since recycled that PID onto. --- e2e-tests/global-setup.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/e2e-tests/global-setup.ts b/e2e-tests/global-setup.ts index 51c8e225..fd19dfe8 100644 --- a/e2e-tests/global-setup.ts +++ b/e2e-tests/global-setup.ts @@ -14,6 +14,10 @@ export const RENDERER_PORT = 1212; * File the renderer dev server's PID is written to, for {@link globalTeardown} to kill it — and for * a CDP setup failure path to kill it too, since Playwright skips `globalTeardown` when * `globalSetup` throws. + * + * Names the dev server the CURRENT run spawned, never one it merely reused: a kill from here + * signals the recorded PID's whole process tree, so a foreign or already-exited PID could take down + * something unrelated. */ export const DEV_SERVER_PID_FILE = path.join(__dirname, '.dev-server.pid'); @@ -216,6 +220,9 @@ export async function bootstrapRendererDevServer(): Promise { // Start the webpack dev server for the renderer if not already running if (await isPortInUse(RENDERER_PORT)) { console.log(`Renderer dev server already running on port ${RENDERER_PORT}.`); + // A marker on disk can only be a prior run's leftover here, and killing by a stale PID risks a + // process the OS has since recycled it onto. The reused server is left running instead. + removeDevServerPidMarker(); } else { console.log('Starting paranext-core renderer dev server...'); const devServer = spawn('npm', ['run', 'start:renderer'], { @@ -304,6 +311,15 @@ function killSpawnedDevServer(pid: number | undefined): void { if (!pid) return; console.log(`Renderer dev server failed to become ready — stopping it (PID: ${pid})...`); killProcessTree(pid, 'SIGTERM'); + removeDevServerPidMarker(); +} + +/** + * Delete the dev server's PID marker so no later cleanup kills by the PID it holds. Best-effort: a + * filesystem error is warned about and swallowed, since one caller runs while an exception is + * already propagating and the other must not fail a healthy bootstrap over a marker. + */ +function removeDevServerPidMarker(): void { try { fs.rmSync(DEV_SERVER_PID_FILE, { force: true }); } catch (error) {