test(electron): reap isolated macOS crash reporters on teardown

This commit is contained in:
Neil
2026-09-05 07:59:20 -07:00
parent 5da57004e9
commit 41454cfec3
3 changed files with 93 additions and 0 deletions
@@ -0,0 +1,46 @@
import { execFileSync } from 'node:child_process'
import path from 'node:path'
function ownsCrashpad(command: string, userDataDir: string): boolean {
return (
command.includes('/chrome_crashpad_handler ') &&
command.includes(` --database=${path.join(userDataDir, 'Crashpad')} `)
)
}
export function cleanupE2ECrashpad(userDataDir: string): void {
if (process.platform !== 'darwin') {
return
}
// macOS reparents Crashpad before app exit; its inherited stderr can keep Playwright open.
try {
const table = execFileSync('ps', ['-axo', 'pid=,command='], {
encoding: 'utf8',
timeout: 5_000
})
for (const row of table.split('\n')) {
const match = row.match(/^\s*(\d+)\s+(.+)$/)
if (!match || !ownsCrashpad(match[2], userDataDir)) {
continue
}
const pid = Number(match[1])
if (!Number.isSafeInteger(pid) || pid <= 1) {
continue
}
try {
const command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], {
encoding: 'utf8',
timeout: 5_000
})
if (ownsCrashpad(command, userDataDir)) {
process.kill(pid, 'SIGTERM')
}
} catch {
// The test-owned reporter may already have exited.
}
}
} catch {
// Cleanup remains best-effort when process enumeration is unavailable.
}
}
@@ -0,0 +1,45 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import { cleanupE2ECrashpad } from './electron-crashpad-cleanup'
vi.mock('node:child_process', () => ({ execFileSync: vi.fn() }))
const profile = '/tmp/test profile'
const database = path.join(profile, 'Crashpad')
const reporter = `/Electron Framework/Helpers/chrome_crashpad_handler --database=${database} --annotation=prod=Electron`
afterEach(() => vi.restoreAllMocks())
describe('test-owned macOS Crashpad cleanup', () => {
it('terminates only the reporter for the exact temporary profile after rechecking ownership', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
vi.mocked(execFileSync)
.mockReturnValueOnce(
`111 ${reporter}\n222 ${reporter.replace('Crashpad ', 'Crashpad-old ')}\n333 ${reporter.replace('test profile', 'another profile')}\n444 /bin/echo --database=${database} \n`
)
.mockReturnValueOnce(reporter)
cleanupE2ECrashpad(profile)
expect(kill).toHaveBeenCalledExactlyOnceWith(111, 'SIGTERM')
expect(execFileSync).toHaveBeenLastCalledWith('ps', ['-p', '111', '-o', 'command='], {
encoding: 'utf8',
timeout: 5_000
})
})
it('does not signal a PID whose ownership changed after enumeration', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const kill = vi.spyOn(process, 'kill').mockReturnValue(true)
vi.mocked(execFileSync).mockReturnValueOnce(`111 ${reporter}`).mockReturnValueOnce('/bin/sh')
cleanupE2ECrashpad(profile)
expect(kill).not.toHaveBeenCalled()
})
it.each(['win32', 'linux'] as const)('does not enumerate processes on %s', (platform) => {
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
vi.mocked(execFileSync).mockClear()
cleanupE2ECrashpad(profile)
expect(execFileSync).not.toHaveBeenCalled()
})
})
@@ -2,6 +2,7 @@ import type { ChildProcess } from 'node:child_process'
import { execFileSync } from 'node:child_process'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import path from 'node:path'
import { cleanupE2ECrashpad } from './electron-crashpad-cleanup'
import type { ElectronApplication } from '@stablyai/playwright-test'
const GRACEFUL_CLOSE_TIMEOUT_MS = 10_000
@@ -221,4 +222,5 @@ export async function cleanupE2EDaemons(userDataDir: string): Promise<void> {
for (const pid of readDaemonPidFiles(userDataDir)) {
await forceKillPidTree(pid)
}
cleanupE2ECrashpad(userDataDir)
}