blob: 5e05eb8ebbf403ff0600a53d093dfca261d61823 [file]
// Bounded teardown for a spawned Electron tree.
//
// `ElectronApplication.close()` waits for the app to quit and has no deadline:
// a wedged main process turns teardown into an infinite hang, and everything
// sequenced after it — temp-dir cleanup, the next fixture, a CI job — never
// runs. Every consumer that launches an Electron app (the Playwright E2E
// suite and the migration contract harness) closes through here instead, so
// a launch that goes wrong costs seconds, not a hung run.
//
// Lives in scripts/ beside fixture-env.mjs for the same reason: a bare-node
// .mjs harness cannot import a .ts module, while the Playwright suite can
// import this one. One launch concern, one home.
// Resolved from the built dist (the package's entry point). On a fresh clone
// nothing under packages/ is built yet, and the raw ERR_MODULE_NOT_FOUND from
// deep inside an import chain (test file → harness → launcher → here) does
// not say what to do about it.
const { terminateChildProcessTree } = await import('@maka/runtime').catch((cause) => {
throw new Error(
"electron-lifecycle needs @maka/runtime's built dist. Run `npm --workspace @maka/runtime run build` (or any desktop build) first.",
{ cause },
);
});
/**
* @typedef {{
* exitCode: number | null,
* signalCode: NodeJS.Signals | null,
* kill(signal: NodeJS.Signals): boolean,
* once(event: 'exit', listener: () => void): unknown,
* off(event: 'exit', listener: () => void): unknown,
* }} ElectronProcessHandle
*/
/**
* @typedef {{
* close(): Promise<void>,
* process(): ElectronProcessHandle,
* }} ClosableElectronApplication
*/
/**
* @typedef {(child: ElectronProcessHandle, signal: 'SIGKILL') => Promise<boolean>} ProcessTreeTerminator
*/
/** @type {ProcessTreeTerminator} */
const terminateElectronProcessTree = (child, signal) =>
terminateChildProcessTree(
/** @type {import('node:child_process').ChildProcess} */ (child),
signal,
);
/**
* Close gracefully within `graceMs`, then SIGKILL the process tree and wait
* up to 2s for the exit to land. Throws if the process survives the kill.
*
* @param {ClosableElectronApplication} app
* @param {number} graceMs
* @param {ProcessTreeTerminator} [terminateTree]
* @returns {Promise<void>}
*/
export async function closeElectronApplication(
app,
graceMs,
terminateTree = terminateElectronProcessTree,
) {
const child = app.process();
const gracefulClose = app.close().then(
() => true,
() => false,
);
if (await settlesWithin(gracefulClose, graceMs)) return;
if (child.exitCode === null && child.signalCode === null) {
await terminateTree(child, 'SIGKILL');
}
// The tree terminator signals the child's process GROUP. A child that was
// not spawned detached is not a group leader, so the group signal reports
// "no such process" and the terminator concludes the tree is gone while
// the root lives on — measured with the smoke gate's visible window, which
// also ignores SIGTERM. A direct kill always lands on the root itself.
if (child.exitCode === null && child.signalCode === null) {
try {
child.kill('SIGKILL');
} catch {
// Exited between the check and the signal.
}
}
if (!(await waitForExit(child, 2_000))) {
throw new Error('Electron process did not exit after SIGKILL');
}
}
/**
* @param {Promise<boolean>} promise
* @param {number} timeoutMs
* @returns {Promise<boolean>}
*/
async function settlesWithin(promise, timeoutMs) {
/** @type {ReturnType<typeof setTimeout> | undefined} */
let timer;
try {
return await Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
/**
* @param {ElectronProcessHandle} child
* @param {number} timeoutMs
* @returns {Promise<boolean>}
*/
async function waitForExit(child, timeoutMs) {
if (child.exitCode !== null || child.signalCode !== null) return true;
/** @type {ReturnType<typeof setTimeout> | undefined} */
let timer;
/** @type {(() => void) | undefined} */
let onExit;
try {
return await Promise.race([
new Promise((resolve) => {
onExit = () => resolve(true);
child.once('exit', onExit);
}),
new Promise((resolve) => {
timer = setTimeout(() => resolve(false), timeoutMs);
}),
]);
} finally {
if (timer) clearTimeout(timer);
if (onExit) child.off('exit', onExit);
}
}