mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
test(electron): fix Windows cleanup and retain packaged smoke diagnostics (#1665)
* 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
This commit is contained in:
1 parent
dc4b33991d
commit
0ecfc06494
10 files changed
+402
-95
No files matched your search
@@ -1150,6 +1150,16 @@ jobs:
|
||||
electron-backend-e2e:packaged-frame-copy-smoke \
|
||||
--skip-nx-cache
|
||||
|
||||
- name: Upload packaged frame-copy smoke diagnostics
|
||||
if: always() && matrix.os == 'linux' && matrix.linux_profile == 'portable'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
||||
with:
|
||||
name: packaged-frame-copy-smoke
|
||||
path: |
|
||||
dist/playwright-report/electron-backend-e2e/packaged-frame-copy-smoke/
|
||||
dist/test-results/electron-backend-e2e/packaged-frame-copy-smoke/
|
||||
retention-days: 7
|
||||
|
||||
- name: Diagnose packaged x64 frame-copy hardware path
|
||||
if: matrix.os == 'linux' && matrix.linux_profile == 'portable'
|
||||
continue-on-error: true
|
||||
|
||||
@@ -68,6 +68,9 @@ jobs:
|
||||
- name: Build Backend
|
||||
run: pnpm nx build electron-backend
|
||||
|
||||
- name: Verify Electron process cleanup
|
||||
run: pnpm exec tsx --test apps/electron-backend-e2e/src/performance/electron-process-lifecycle.spec.ts apps/electron-backend-e2e/src/performance/electron-process-termination.spec.ts
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { ChildProcess } from 'node:child_process';
|
||||
import { terminateElectronProcess } from './electron-process-termination';
|
||||
|
||||
type ElectronChildProcess = Pick<
|
||||
ChildProcess,
|
||||
'exitCode' | 'kill' | 'once' | 'removeListener' | 'signalCode'
|
||||
'exitCode' | 'kill' | 'once' | 'pid' | 'removeListener' | 'signalCode'
|
||||
>;
|
||||
|
||||
export interface ClosableElectronApplication {
|
||||
close(): Promise<void>;
|
||||
process(): ElectronChildProcess;
|
||||
}
|
||||
|
||||
export interface ElectronExitConfirmationOptions {
|
||||
@@ -15,6 +15,19 @@ export interface ElectronExitConfirmationOptions {
|
||||
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>;
|
||||
@@ -49,11 +62,19 @@ export async function prepareElectronApplication<Application, Prepared>(
|
||||
|
||||
export async function closeElectronApplicationAndConfirmExit(
|
||||
application: ClosableElectronApplication,
|
||||
options: ElectronExitConfirmationOptions
|
||||
options: ElectronExitConfirmationOptions,
|
||||
terminate: (
|
||||
child: ElectronChildProcess,
|
||||
signal: NodeJS.Signals
|
||||
) => void = terminateElectronProcess
|
||||
): Promise<void> {
|
||||
assertTimeout(options.closeTimeoutMs);
|
||||
assertTimeout(options.exitTimeoutMs);
|
||||
const child = application.process();
|
||||
// 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);
|
||||
@@ -82,14 +103,19 @@ export async function closeElectronApplicationAndConfirmExit(
|
||||
}
|
||||
if (first.kind === 'close-rejected') failure = first.failure;
|
||||
|
||||
try {
|
||||
child.kill();
|
||||
} catch (killFailure) {
|
||||
failure = failure
|
||||
? new AggregateError([failure, killFailure])
|
||||
: killFailure;
|
||||
// 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;
|
||||
}
|
||||
if (await resolvesWithin(exit.promise, options.exitTimeoutMs)) return;
|
||||
} finally {
|
||||
exit.cancel();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { execFileSync, type ChildProcess } from 'node:child_process';
|
||||
|
||||
type ElectronProcess = Pick<ChildProcess, 'pid' | 'kill'>;
|
||||
|
||||
export function terminateElectronProcess(
|
||||
child: ElectronProcess,
|
||||
signal: NodeJS.Signals = 'SIGTERM',
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
run: typeof execFileSync = execFileSync
|
||||
): void {
|
||||
// Playwright launches Electron through cmd.exe on Windows. Killing only
|
||||
// that ChildProcess leaves Electron (and its profile lock) alive.
|
||||
if (platform === 'win32') {
|
||||
if (!Number.isSafeInteger(child.pid) || (child.pid ?? 0) <= 0) {
|
||||
throw new Error('electron-process-pid-unavailable');
|
||||
}
|
||||
run('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
child.kill(signal);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Page,
|
||||
test as base,
|
||||
} from '@playwright/test';
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { createServer, Server } from 'http';
|
||||
import {
|
||||
accessSync,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
writeDataDirOwnerMarker,
|
||||
} from './data-dir-reaper';
|
||||
import {
|
||||
captureElectronProcess,
|
||||
closeElectronApplicationAndConfirmExit,
|
||||
prepareElectronApplication,
|
||||
} from './electron-process-lifecycle';
|
||||
@@ -234,6 +235,7 @@ export async function launchElectronApp(
|
||||
args,
|
||||
env: buildElectronLaunchEnvironment(dataDir, options),
|
||||
});
|
||||
const electronProcess = captureElectronProcess(electronApp);
|
||||
return prepareElectronApplication({
|
||||
application: electronApp,
|
||||
dispose: (application) =>
|
||||
@@ -242,7 +244,7 @@ export async function launchElectronApp(
|
||||
exitTimeoutMs: electronAppKillWaitMs,
|
||||
}),
|
||||
prepare: async (application) => {
|
||||
attachElectronProcessDiagnostics(application);
|
||||
attachElectronProcessDiagnostics(electronProcess);
|
||||
const mainWindow = await findMainWindow(application);
|
||||
await waitForAppReady(mainWindow);
|
||||
await startPortalDebugCapture(mainWindow);
|
||||
@@ -458,26 +460,31 @@ export async function launchPackagedElectronApp(
|
||||
NODE_ENV: 'test',
|
||||
},
|
||||
});
|
||||
attachElectronProcessDiagnostics(electronApp);
|
||||
|
||||
const mainWindow = await findMainWindow(electronApp);
|
||||
await waitForAppReady(mainWindow);
|
||||
|
||||
return {
|
||||
electronApp,
|
||||
mainWindow,
|
||||
};
|
||||
const electronProcess = captureElectronProcess(electronApp);
|
||||
return prepareElectronApplication({
|
||||
application: electronApp,
|
||||
dispose: (application) =>
|
||||
closeElectronApplicationAndConfirmExit(application, {
|
||||
closeTimeoutMs: electronAppCloseTimeoutMs,
|
||||
exitTimeoutMs: electronAppKillWaitMs,
|
||||
}),
|
||||
prepare: async (application) => {
|
||||
attachElectronProcessDiagnostics(electronProcess);
|
||||
const mainWindow = await findMainWindow(application);
|
||||
await waitForAppReady(mainWindow);
|
||||
return {
|
||||
electronApp: application,
|
||||
mainWindow,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function attachElectronProcessDiagnostics(
|
||||
electronApp: ElectronApplication
|
||||
): void {
|
||||
function attachElectronProcessDiagnostics(childProcess: ChildProcess): void {
|
||||
if (!process.env['CI']) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childProcess = electronApp.process();
|
||||
|
||||
childProcess.stdout?.on('data', (chunk: Buffer) => {
|
||||
console.log(`[electron stdout] ${chunk.toString().trimEnd()}`);
|
||||
});
|
||||
@@ -564,52 +571,7 @@ export async function launchCompetingElectronInstance(
|
||||
export async function closeElectronApp(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<void> {
|
||||
try {
|
||||
const closePromise = app.electronApp.close();
|
||||
const closed = await waitForPromiseWithTimeout(
|
||||
closePromise,
|
||||
electronAppCloseTimeoutMs
|
||||
);
|
||||
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`Electron app did not close within ${electronAppCloseTimeoutMs}ms; killing process`
|
||||
);
|
||||
const childProcess = app.electronApp.process();
|
||||
|
||||
if (!childProcess.killed) {
|
||||
childProcess.kill();
|
||||
}
|
||||
|
||||
await waitForPromiseWithTimeout(
|
||||
closePromise.catch(() => undefined),
|
||||
electronAppKillWaitMs
|
||||
);
|
||||
|
||||
// SIGTERM asks Electron for a graceful quit, which the app can
|
||||
// legitimately refuse — the unsaved-settings close guard cancels the
|
||||
// quit while it waits for an answer. A process that survives here
|
||||
// would outlive the test, hold its data dir, and time out the worker
|
||||
// teardown, so escalate to SIGKILL.
|
||||
if (
|
||||
childProcess.exitCode === null &&
|
||||
childProcess.signalCode === null
|
||||
) {
|
||||
console.warn(
|
||||
'Electron app survived SIGTERM; escalating to SIGKILL'
|
||||
);
|
||||
childProcess.kill('SIGKILL');
|
||||
await waitForPromiseWithTimeout(
|
||||
closePromise.catch(() => undefined),
|
||||
electronAppKillWaitMs
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to close Electron app cleanly:', error);
|
||||
}
|
||||
await closeElectronAppAndConfirmExit(app);
|
||||
}
|
||||
|
||||
export async function closeElectronAppAndConfirmExit(
|
||||
@@ -621,26 +583,6 @@ export async function closeElectronAppAndConfirmExit(
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForPromiseWithTimeout(
|
||||
promise: Promise<unknown>,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise.then(() => true),
|
||||
new Promise<boolean>((resolvePromise) => {
|
||||
timeoutId = setTimeout(() => resolvePromise(false), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertPackagedRendererBuildIsElectronSafe(): void {
|
||||
if (!existsSync(packagedRendererIndexPath)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
openSettingsSection,
|
||||
} from './electron-test-fixtures';
|
||||
import { seedLegacyProfile, legacyPlaylists } from './legacy-profile-fixture';
|
||||
import { captureElectronProcess } from './electron-process-lifecycle';
|
||||
import { applyTheme } from './theme-contrast';
|
||||
|
||||
interface StartupTestGlobals {
|
||||
@@ -94,6 +95,7 @@ require(${JSON.stringify(electronMainPath)});`
|
||||
],
|
||||
env: buildElectronLaunchEnvironment(dataDir),
|
||||
});
|
||||
captureElectronProcess(app);
|
||||
const page = await app.firstWindow();
|
||||
try {
|
||||
// The route is intentionally still empty while recovery is pending.
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
closeElectronApp,
|
||||
type LaunchedElectronApp,
|
||||
} from '../electron-test-fixtures';
|
||||
|
||||
import {
|
||||
closeElectronApplicationAndConfirmExit,
|
||||
captureElectronProcess,
|
||||
closeElectronApplicationAndConfirmExit as closeApplication,
|
||||
ElectronApplicationDisposalError,
|
||||
prepareElectronApplication,
|
||||
} from '../electron-process-lifecycle';
|
||||
|
||||
// Fake children have no OS PID; exercise lifecycle decisions independently
|
||||
// of the platform process-tree integration test.
|
||||
const closeElectronApplicationAndConfirmExit: typeof closeApplication = (
|
||||
app,
|
||||
options
|
||||
) =>
|
||||
closeApplication(app, options, (child, signal) => {
|
||||
child.kill(signal);
|
||||
});
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
exitCode: number | null = null;
|
||||
signalCode: NodeJS.Signals | null = null;
|
||||
@@ -25,6 +40,94 @@ class FakeChildProcess extends EventEmitter {
|
||||
}
|
||||
|
||||
describe('Electron process lifecycle', () => {
|
||||
it('rejects cleanup when no process was captured for that application', async () => {
|
||||
await assert.rejects(
|
||||
closeApplication(
|
||||
{ close: async () => assert.fail('exit cannot be confirmed') },
|
||||
{ closeTimeoutMs: 1, exitTimeoutMs: 1 }
|
||||
),
|
||||
/electron-process-handle-not-captured/
|
||||
);
|
||||
});
|
||||
|
||||
it('cleans the replacement application after a partial restart assignment', async () => {
|
||||
const oldChild = new FakeChildProcess();
|
||||
const newChild = new FakeChildProcess();
|
||||
const oldApplication = {
|
||||
close: async () => {
|
||||
oldChild.exitCode = 0;
|
||||
oldChild.emit('exit', 0, null);
|
||||
},
|
||||
process: () => oldChild,
|
||||
};
|
||||
const newApplication = {
|
||||
close: async () => {
|
||||
newChild.exitCode = 0;
|
||||
newChild.emit('exit', 0, null);
|
||||
},
|
||||
process: () => newChild,
|
||||
};
|
||||
const app = {
|
||||
electronApp: oldApplication,
|
||||
} as unknown as LaunchedElectronApp;
|
||||
captureElectronProcess(oldApplication);
|
||||
captureElectronProcess(newApplication);
|
||||
await closeElectronApp(app);
|
||||
// Existing E2E callers replace the application/window fields only.
|
||||
app.electronApp =
|
||||
newApplication as unknown as LaunchedElectronApp['electronApp'];
|
||||
|
||||
await closeElectronApp(app);
|
||||
assert.equal(newChild.exitCode, 0);
|
||||
});
|
||||
|
||||
it('confirms an already exited process after Playwright disposes its dispatcher', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const app = {
|
||||
electronApp: {
|
||||
close: async () => {
|
||||
assert.fail(
|
||||
'an exited application must not be closed again'
|
||||
);
|
||||
},
|
||||
process: () => child,
|
||||
},
|
||||
} as unknown as LaunchedElectronApp;
|
||||
|
||||
captureElectronProcess(app.electronApp);
|
||||
child.exitCode = 0;
|
||||
app.electronApp.process = () => {
|
||||
throw new TypeError(
|
||||
"Cannot read properties of undefined (reading '_object')"
|
||||
);
|
||||
};
|
||||
await closeElectronApp(app);
|
||||
assert.equal(child.killCalls, 0);
|
||||
});
|
||||
|
||||
it('does not return from public cleanup when close and termination both fail', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
child.kill = () => {
|
||||
throw new Error('termination failed');
|
||||
};
|
||||
const app = {
|
||||
electronApp: {
|
||||
close: async () => {
|
||||
throw new Error('CDP disconnected');
|
||||
},
|
||||
process: () => child,
|
||||
},
|
||||
} as unknown as LaunchedElectronApp;
|
||||
|
||||
captureElectronProcess(app.electronApp);
|
||||
app.electronApp.process = () => {
|
||||
assert.fail('cleanup must use the retained child process');
|
||||
};
|
||||
await assert.rejects(
|
||||
closeElectronApp(app),
|
||||
/electron-process-exit-unconfirmed/
|
||||
);
|
||||
});
|
||||
it('closes and confirms exit when post-spawn launch preparation fails', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const launchFailure = new Error('renderer readiness failed');
|
||||
@@ -37,6 +140,7 @@ describe('Electron process lifecycle', () => {
|
||||
},
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await assert.rejects(
|
||||
prepareElectronApplication({
|
||||
@@ -56,6 +160,40 @@ describe('Electron process lifecycle', () => {
|
||||
assert.equal(child.exitCode, 0);
|
||||
});
|
||||
|
||||
it('preserves preparation failure when Electron has already exited', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const launchFailure = new Error('renderer closed before readiness');
|
||||
const application = {
|
||||
close: async () => {
|
||||
assert.fail('an exited application must not be closed again');
|
||||
},
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await assert.rejects(
|
||||
prepareElectronApplication({
|
||||
application,
|
||||
dispose: (app) =>
|
||||
closeElectronApplicationAndConfirmExit(app, {
|
||||
closeTimeoutMs: 10,
|
||||
exitTimeoutMs: 10,
|
||||
}),
|
||||
prepare: async () => {
|
||||
child.exitCode = 0;
|
||||
application.process = () => {
|
||||
assert.fail(
|
||||
'the Playwright dispatcher is already disposed'
|
||||
);
|
||||
};
|
||||
throw launchFailure;
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error === launchFailure
|
||||
);
|
||||
assert.equal(child.killCalls, 0);
|
||||
});
|
||||
|
||||
it('fails when process exit remains unconfirmed after forced teardown', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
child.shouldExitOnKill = false;
|
||||
@@ -63,6 +201,7 @@ describe('Electron process lifecycle', () => {
|
||||
close: () => new Promise<void>(() => undefined),
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await assert.rejects(
|
||||
closeElectronApplicationAndConfirmExit(application, {
|
||||
@@ -71,7 +210,7 @@ describe('Electron process lifecycle', () => {
|
||||
}),
|
||||
/electron-process-exit-unconfirmed/
|
||||
);
|
||||
assert.equal(child.killCalls, 1);
|
||||
assert.equal(child.killCalls, 2);
|
||||
});
|
||||
|
||||
it('preserves both preparation and unconfirmed-disposal failures', async () => {
|
||||
@@ -107,6 +246,7 @@ describe('Electron process lifecycle', () => {
|
||||
},
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await Promise.race([
|
||||
closeElectronApplicationAndConfirmExit(application, {
|
||||
@@ -129,6 +269,7 @@ describe('Electron process lifecycle', () => {
|
||||
close: () => new Promise<void>(() => undefined),
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
|
||||
await closeElectronApplicationAndConfirmExit(application, {
|
||||
closeTimeoutMs: 1,
|
||||
@@ -137,4 +278,25 @@ describe('Electron process lifecycle', () => {
|
||||
assert.equal(child.killCalls, 1);
|
||||
assert.equal(child.signalCode, 'SIGTERM');
|
||||
});
|
||||
|
||||
it('still observes exit after the first termination attempt throws', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const signals: NodeJS.Signals[] = [];
|
||||
const application = {
|
||||
close: () => new Promise<void>(() => undefined),
|
||||
process: () => child,
|
||||
};
|
||||
captureElectronProcess(application);
|
||||
await closeApplication(
|
||||
application,
|
||||
{ closeTimeoutMs: 1, exitTimeoutMs: 1 },
|
||||
(_child, signal) => {
|
||||
signals.push(signal);
|
||||
if (signal === 'SIGTERM') throw new Error('termination failed');
|
||||
child.kill();
|
||||
}
|
||||
);
|
||||
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
|
||||
assert.equal(child.signalCode, 'SIGTERM');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, type execFileSync } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { describe, it } from 'node:test';
|
||||
import { terminateElectronProcess } from '../electron-process-termination';
|
||||
|
||||
describe('Electron forced termination', () => {
|
||||
it('terminates the Windows shell and its Electron descendants together', () => {
|
||||
let shellAlive = true;
|
||||
let electronAlive = true;
|
||||
const child = {
|
||||
pid: 1234,
|
||||
kill: () => {
|
||||
shellAlive = false;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const run = ((file, args, options) => {
|
||||
assert.equal(file, 'taskkill.exe');
|
||||
assert.deepEqual(args, ['/pid', '1234', '/T', '/F']);
|
||||
assert.equal(options?.timeout, 5000);
|
||||
shellAlive = false;
|
||||
electronAlive = false;
|
||||
return Buffer.alloc(0);
|
||||
}) as typeof execFileSync;
|
||||
|
||||
terminateElectronProcess(child, 'SIGTERM', 'win32', run);
|
||||
|
||||
assert.equal(shellAlive, false);
|
||||
assert.equal(
|
||||
electronAlive,
|
||||
false,
|
||||
'Electron must release the profile before relaunch'
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves signal escalation on Unix', () => {
|
||||
const signals: NodeJS.Signals[] = [];
|
||||
const child = {
|
||||
pid: 1234,
|
||||
kill: (signal: NodeJS.Signals) => {
|
||||
signals.push(signal);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
terminateElectronProcess(child, 'SIGTERM', 'linux');
|
||||
terminateElectronProcess(child, 'SIGKILL', 'darwin');
|
||||
assert.deepEqual(signals, ['SIGTERM', 'SIGKILL']);
|
||||
});
|
||||
|
||||
it('does not fall back to killing only the shell when tree termination fails', () => {
|
||||
const failure = new Error('taskkill failed');
|
||||
const child = {
|
||||
pid: 1234,
|
||||
kill: () => assert.fail('would orphan the Electron descendant'),
|
||||
};
|
||||
const run = (() => {
|
||||
throw failure;
|
||||
}) as typeof execFileSync;
|
||||
assert.throws(
|
||||
() => terminateElectronProcess(child, 'SIGTERM', 'win32', run),
|
||||
(error) => error === failure
|
||||
);
|
||||
});
|
||||
|
||||
it(
|
||||
'reaps a real Windows shell child instead of orphaning its descendant',
|
||||
{
|
||||
skip: process.platform !== 'win32',
|
||||
timeout: 15000,
|
||||
},
|
||||
async () => {
|
||||
// Match Playwright's Windows launch topology: cmd.exe -> application.
|
||||
const shell = spawn(
|
||||
`"${process.execPath}"`,
|
||||
[
|
||||
'-e',
|
||||
'"setInterval(() => {}, 1000); console.log(process.pid)"',
|
||||
],
|
||||
{ shell: true, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
);
|
||||
try {
|
||||
const [output] = await once(shell.stdout, 'data', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const descendantPid = Number(String(output).trim());
|
||||
assert.ok(
|
||||
Number.isSafeInteger(descendantPid) && descendantPid > 0
|
||||
);
|
||||
assert.notEqual(descendantPid, shell.pid);
|
||||
process.kill(descendantPid, 0);
|
||||
|
||||
terminateElectronProcess(shell);
|
||||
await assert.rejects(
|
||||
async () => process.kill(descendantPid, 0),
|
||||
{
|
||||
code: 'ESRCH',
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
if (shell.exitCode === null && shell.signalCode === null) {
|
||||
// The successful taskkill can precede Node's exit event.
|
||||
try {
|
||||
terminateElectronProcess(shell);
|
||||
} catch {
|
||||
/* already exited */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -67,6 +67,30 @@ agent-browser connect ws://127.0.0.1:9222/devtools/page/<iptvnator-page-id>
|
||||
agent-browser screenshot /tmp/iptvnator-cdp.png
|
||||
```
|
||||
|
||||
## E2E process cleanup and packaged diagnostics
|
||||
|
||||
Playwright launches Electron through `cmd.exe` on Windows. If graceful E2E
|
||||
shutdown times out, terminate that process tree with `taskkill /T /F`; killing
|
||||
only `electronApp.process()` can leave Electron holding the temporary profile
|
||||
and make the next launch exit on the single-instance lock. The E2E workflow
|
||||
checks this against a real Windows shell and child process. Test cleanup must
|
||||
target only its own launch PID, never all Electron or IPTVnator processes.
|
||||
Cleanup confirms process exit even if Playwright's close promise fails or
|
||||
never settles. If termination fails, it retries and then throws instead of
|
||||
allowing a relaunch against a potentially locked profile.
|
||||
Capture the Node child-process handle immediately after launch and retain it
|
||||
in a WeakMap keyed by the Electron application for cleanup and preparation
|
||||
failures. This binding follows the application when restart callers replace
|
||||
only the application/window fields on their fixture. After the last window
|
||||
closes on Linux,
|
||||
Electron can exit before cleanup begins; Playwright disposes its dispatcher,
|
||||
so calling `electronApp.process()` at that point can throw even after a clean
|
||||
exit. The retained handle still provides the actual exit code and signal.
|
||||
|
||||
The Linux portable build uploads `packaged-frame-copy-smoke` reports and traces
|
||||
even when the smoke fails. Check the paused-frame screenshot and trace before
|
||||
classifying a zero rendered-frame signal as an infrastructure flake.
|
||||
|
||||
## Main-process ownership
|
||||
|
||||
The entry point is `apps/electron-backend/src/main.ts`; it bootstraps the database,
|
||||
|
||||
@@ -13,6 +13,7 @@ const PUBLISH_ACTION_ALLOWLIST = Object.freeze([
|
||||
PINNED_UPLOAD_ARTIFACT_ACTION,
|
||||
]);
|
||||
const BUILD_ACTION_ALLOWLIST = Object.freeze([
|
||||
PINNED_UPLOAD_ARTIFACT_ACTION,
|
||||
'actions/cache/restore@v6',
|
||||
'actions/cache/save@v6',
|
||||
'actions/cache@v6',
|
||||
|
||||
Reference in new issue
Block a user