mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
test(serve): cover renderer death session retention
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user