test(serve): cover renderer death session retention

This commit is contained in:
Jinwoo-H
2026-08-23 18:50:28 -04:00
parent 0926c25854
commit d00a53d62c
@@ -0,0 +1,237 @@
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { PROTOCOL_VERSION } from '../../src/main/daemon/types'
import type { RuntimeStatus } from '../../src/shared/runtime-types'
import { expect, test } from './helpers/orca-app'
import {
createHostCliTerminal,
proveSameLivePty,
writeRetentionFixture
} from './helpers/host-created-terminal-retention-oracle'
import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host'
import {
processIdentityIsAlive,
recordProcessIdentity
} from './helpers/daemon-generation-processes'
type RendererIdentity = {
crashed: boolean
destroyed: boolean
marker: string | null
pid: number
url: string
webContentsId: number
}
function readDaemonPid(userDataDir: string): number {
const raw = readFileSync(
path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`),
'utf8'
)
const parsed = JSON.parse(raw) as { pid?: unknown }
if (typeof parsed.pid !== 'number') {
throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`)
}
return parsed.pid
}
async function readOffscreenRenderer(
app: Awaited<ReturnType<typeof launchHeadlessPairedRuntimeHost>>['app'],
pageId: string,
readMarker = true
): Promise<RendererIdentity | null> {
return app.evaluate(
async ({ BrowserWindow }, { readMarker, targetPageId }) => {
const win = BrowserWindow.getAllWindows().find((candidate) =>
candidate.webContents.getURL().includes(targetPageId)
)
if (!win) {
return null
}
let marker: string | null = null
if (readMarker && !win.webContents.isCrashed() && !win.webContents.isDestroyed()) {
try {
marker = (await win.webContents.executeJavaScript(
"document.querySelector('#sta-5228-marker')?.textContent ?? null"
)) as string | null
} catch {}
}
return {
crashed: win.webContents.isCrashed(),
destroyed: win.webContents.isDestroyed(),
marker,
pid: win.webContents.getOSProcessId(),
url: win.webContents.getURL(),
webContentsId: win.webContents.id
}
},
{ readMarker, targetPageId: pageId }
)
}
test('STA-5228 renderer death keeps headless runtime and PTYs alive', async ({ testRepoPath }) => {
test.setTimeout(180_000)
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-sta-5228-'))
const fixturePath = writeRetentionFixture(scratch)
const sinkPath = path.join(scratch, 'pty.log')
let host: Awaited<ReturnType<typeof launchHeadlessPairedRuntimeHost>> | null = null
try {
const launchedHost = await launchHeadlessPairedRuntimeHost()
host = launchedHost
const userDataDir = await launchedHost.app.evaluate(({ app }) => app.getPath('userData'))
const mainPid = await launchedHost.app.evaluate(() => process.pid)
const daemonPid = readDaemonPid(userDataDir)
const mainIdentity = await recordProcessIdentity(mainPid)
const daemonIdentity = await recordProcessIdentity(daemonPid)
expect(
await launchedHost.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().length)
).toBe(0)
const beforeStatus = await launchedHost.client.call<RuntimeStatus>('status.get')
const added = await launchedHost.client.call<{ repo: { id: string } }>('repo.add', {
path: testRepoPath,
kind: 'git'
})
const listed = await launchedHost.client.call<{ worktrees: { id: string }[] }>(
'worktree.list',
{
repo: `id:${added.result.repo.id}`
}
)
const worktreeId = listed.result.worktrees[0]?.id
if (!worktreeId) {
throw new Error('Headless host did not list the seeded worktree')
}
const call = async <TResult>(method: string, params: unknown): Promise<TResult> =>
(await launchedHost.client.call<TResult>(method, params)).result
const terminalA = await createHostCliTerminal(call, worktreeId, fixturePath, sinkPath)
const terminalB = await createHostCliTerminal(
call,
worktreeId,
fixturePath,
path.join(scratch, 'pty-b.log')
)
await proveSameLivePty(call, terminalA, 'BEFORE_RENDERER_KILL_A')
await proveSameLivePty(call, terminalB, 'BEFORE_RENDERER_KILL_B')
const pageId = `sta-5228-${Date.now()}`
const markerUrl = `data:text/html,${encodeURIComponent(
`<p id="sta-5228-marker">${pageId}</p>`
)}`
await launchedHost.client.call<{ browserPageId: string }>('browser.tabCreate', {
url: markerUrl,
worktree: `id:${worktreeId}`
})
const rendererObservation = { value: null as RendererIdentity | null }
await expect
.poll(
async () => {
rendererObservation.value = await readOffscreenRenderer(launchedHost.app, pageId)
return rendererObservation.value
},
{
timeout: 30_000,
message: 'Headless offscreen renderer never loaded the marker page'
}
)
.toMatchObject({ marker: pageId, crashed: false, destroyed: false })
const renderer = rendererObservation.value
if (!renderer || renderer.pid <= 0) {
throw new Error('Headless offscreen renderer did not expose an OS process identity')
}
expect(renderer.pid).not.toBe(mainPid)
expect(renderer.pid).not.toBe(daemonPid)
const rendererIdentity = await recordProcessIdentity(renderer.pid)
const fencedRenderer = await readOffscreenRenderer(launchedHost.app, pageId)
expect(fencedRenderer).toMatchObject({
crashed: false,
marker: pageId,
pid: renderer.pid,
webContentsId: renderer.webContentsId
})
expect(await processIdentityIsAlive(rendererIdentity)).toBe(true)
if (process.platform === 'linux') {
expect(readFileSync(path.join('/proc', String(renderer.pid), 'cmdline'), 'utf8')).toContain(
'--type=renderer'
)
}
await launchedHost.app.evaluate(
({ BrowserWindow }, expected) => {
const win = BrowserWindow.getAllWindows().find(
(candidate) => candidate.webContents.id === expected.webContentsId
)
if (
!win ||
!win.webContents.getURL().includes(expected.pageId) ||
win.webContents.getOSProcessId() !== expected.pid
) {
throw new Error('Offscreen renderer identity changed before the fault')
}
const probe = { details: null as Electron.RenderProcessGoneDetails | null }
;(globalThis as typeof globalThis & { __sta5228Probe?: typeof probe }).__sta5228Probe =
probe
win.webContents.once('render-process-gone', (_event, details) => {
probe.details = details
})
setTimeout(() => win.webContents.forcefullyCrashRenderer(), 0)
},
{ pageId, pid: renderer.pid, webContentsId: renderer.webContentsId }
)
await expect
.poll(
() =>
launchedHost.app.evaluate(
() =>
(
globalThis as typeof globalThis & {
__sta5228Probe?: { details: Electron.RenderProcessGoneDetails | null }
}
).__sta5228Probe?.details ?? null
),
{
timeout: 15_000,
message: 'Exact offscreen renderer fault produced no process-gone event'
}
)
.toMatchObject({ reason: 'killed' })
await expect
.poll(() => processIdentityIsAlive(rendererIdentity), {
message: 'The exact renderer process remained alive after its WebContents crashed'
})
.toBe(false)
const afterStatus = await launchedHost.client.call<RuntimeStatus>('status.get')
expect(await launchedHost.app.evaluate(() => process.pid)).toBe(mainPid)
expect(await processIdentityIsAlive(mainIdentity)).toBe(true)
expect(afterStatus.result.runtimeId).toBe(beforeStatus.result.runtimeId)
expect(readDaemonPid(userDataDir)).toBe(daemonPid)
expect(await processIdentityIsAlive(daemonIdentity)).toBe(true)
await proveSameLivePty(call, terminalA, 'AFTER_RENDERER_KILL_A')
await proveSameLivePty(call, terminalB, 'AFTER_RENDERER_KILL_B')
const afterRenderer = await readOffscreenRenderer(launchedHost.app, pageId, false)
expect(afterRenderer).toMatchObject({
crashed: true,
pid: 0,
webContentsId: renderer.webContentsId
})
console.log('[STA-5228] renderer lifecycle snapshot', {
afterRenderer,
beforeRenderer: renderer,
daemonPid,
mainPid,
ptyIds: [terminalA.ptyId, terminalB.ptyId],
runtimeId: beforeStatus.result.runtimeId
})
} finally {
try {
await host?.dispose()
} finally {
rmSync(scratch, { recursive: true, force: true })
}
}
})