mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
* test(electron): reap Windows process trees and retain smoke diagnostics * test(electron): require confirmed process exit before relaunch * test(electron): retain process handle after application exit * test(electron): bind captured processes to application instances
198 lines
6.3 KiB
TypeScript
198 lines
6.3 KiB
TypeScript
import type { ChildProcess } from 'node:child_process';
|
|
import { terminateElectronProcess } from './electron-process-termination';
|
|
|
|
type ElectronChildProcess = Pick<
|
|
ChildProcess,
|
|
'exitCode' | 'kill' | 'once' | 'pid' | 'removeListener' | 'signalCode'
|
|
>;
|
|
|
|
export interface ClosableElectronApplication {
|
|
close(): Promise<void>;
|
|
}
|
|
|
|
export interface ElectronExitConfirmationOptions {
|
|
readonly closeTimeoutMs: number;
|
|
readonly exitTimeoutMs: number;
|
|
}
|
|
|
|
const electronProcesses = new WeakMap<
|
|
ClosableElectronApplication,
|
|
ElectronChildProcess
|
|
>();
|
|
|
|
export function captureElectronProcess<Process extends ElectronChildProcess>(
|
|
application: ClosableElectronApplication & { process(): Process }
|
|
): Process {
|
|
const child = application.process();
|
|
electronProcesses.set(application, child);
|
|
return child;
|
|
}
|
|
|
|
export interface PrepareElectronApplicationOptions<Application, Prepared> {
|
|
readonly application: Application;
|
|
readonly dispose: (application: Application) => Promise<void>;
|
|
readonly prepare: (application: Application) => Promise<Prepared>;
|
|
}
|
|
|
|
export class ElectronApplicationDisposalError extends AggregateError {
|
|
constructor(failure: unknown, disposeFailure: unknown) {
|
|
super(
|
|
[failure, disposeFailure],
|
|
'Electron launch preparation and disposal both failed',
|
|
{ cause: failure }
|
|
);
|
|
this.name = 'ElectronApplicationDisposalError';
|
|
}
|
|
}
|
|
|
|
export async function prepareElectronApplication<Application, Prepared>(
|
|
options: PrepareElectronApplicationOptions<Application, Prepared>
|
|
): Promise<Prepared> {
|
|
try {
|
|
return await options.prepare(options.application);
|
|
} catch (failure) {
|
|
try {
|
|
await options.dispose(options.application);
|
|
} catch (disposeFailure) {
|
|
throw new ElectronApplicationDisposalError(failure, disposeFailure);
|
|
}
|
|
throw failure;
|
|
}
|
|
}
|
|
|
|
export async function closeElectronApplicationAndConfirmExit(
|
|
application: ClosableElectronApplication,
|
|
options: ElectronExitConfirmationOptions,
|
|
terminate: (
|
|
child: ElectronChildProcess,
|
|
signal: NodeJS.Signals
|
|
) => void = terminateElectronProcess
|
|
): Promise<void> {
|
|
assertTimeout(options.closeTimeoutMs);
|
|
assertTimeout(options.exitTimeoutMs);
|
|
// Playwright discards its process dispatcher when Electron exits. Keep
|
|
// using the Node handle captured immediately after launch, including when
|
|
// the application's last window already caused a normal process exit.
|
|
const child = electronProcesses.get(application);
|
|
if (!child) throw new Error('electron-process-handle-not-captured');
|
|
if (hasExited(child)) return;
|
|
|
|
const exit = observeProcessExit(child);
|
|
let failure: unknown;
|
|
try {
|
|
const close = Promise.resolve()
|
|
.then(() => application.close())
|
|
.then(
|
|
() => ({ kind: 'close-resolved' as const }),
|
|
(closeFailure: unknown) => ({
|
|
failure: closeFailure,
|
|
kind: 'close-rejected' as const,
|
|
})
|
|
);
|
|
const first = await raceCloseAndExit(
|
|
close,
|
|
exit.promise,
|
|
options.closeTimeoutMs
|
|
);
|
|
if (first.kind === 'exit') return;
|
|
if (
|
|
first.kind === 'close-resolved' &&
|
|
(await resolvesWithin(exit.promise, options.exitTimeoutMs))
|
|
) {
|
|
return;
|
|
}
|
|
if (first.kind === 'close-rejected') failure = first.failure;
|
|
|
|
// A failed termination must not skip exit observation or let a
|
|
// caller restart against a profile that is still owned by Electron.
|
|
for (const signal of ['SIGTERM', 'SIGKILL'] as const) {
|
|
try {
|
|
terminate(child, signal);
|
|
} catch (killFailure) {
|
|
failure = failure
|
|
? new AggregateError([failure, killFailure])
|
|
: killFailure;
|
|
}
|
|
if (await resolvesWithin(exit.promise, options.exitTimeoutMs))
|
|
return;
|
|
}
|
|
} finally {
|
|
exit.cancel();
|
|
}
|
|
throw new Error('electron-process-exit-unconfirmed', { cause: failure });
|
|
}
|
|
|
|
function hasExited(child: ElectronChildProcess): boolean {
|
|
return child.exitCode !== null || child.signalCode !== null;
|
|
}
|
|
|
|
function observeProcessExit(child: ElectronChildProcess): {
|
|
readonly cancel: () => void;
|
|
readonly promise: Promise<void>;
|
|
} {
|
|
let resolveExit: (() => void) | undefined;
|
|
const onExit = () => resolveExit?.();
|
|
const promise = new Promise<void>((resolvePromise) => {
|
|
resolveExit = resolvePromise;
|
|
child.once('exit', onExit);
|
|
if (hasExited(child)) resolvePromise();
|
|
});
|
|
return {
|
|
cancel: () => child.removeListener('exit', onExit),
|
|
promise,
|
|
};
|
|
}
|
|
|
|
async function resolvesWithin(
|
|
promise: Promise<unknown>,
|
|
timeoutMs: number
|
|
): Promise<boolean> {
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
promise.then(() => true),
|
|
new Promise<boolean>((resolvePromise) => {
|
|
timeout = setTimeout(() => resolvePromise(false), timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
async function raceCloseAndExit(
|
|
close: Promise<
|
|
| { readonly kind: 'close-resolved' }
|
|
| { readonly failure: unknown; readonly kind: 'close-rejected' }
|
|
>,
|
|
exit: Promise<void>,
|
|
timeoutMs: number
|
|
): Promise<
|
|
| { readonly kind: 'close-resolved' }
|
|
| { readonly failure: unknown; readonly kind: 'close-rejected' }
|
|
| { readonly kind: 'exit' }
|
|
| { readonly kind: 'timeout' }
|
|
> {
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
close,
|
|
exit.then(() => ({ kind: 'exit' as const })),
|
|
new Promise<{ readonly kind: 'timeout' }>((resolvePromise) => {
|
|
timeout = setTimeout(
|
|
() => resolvePromise({ kind: 'timeout' }),
|
|
timeoutMs
|
|
);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function assertTimeout(value: number): void {
|
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
throw new Error('electron-process-exit-timeout-invalid');
|
|
}
|
|
}
|