fix(sync-ui): quit reliably after standalone sync window closes

- Re-issue post-cleanup quit via setImmediate so Electron doesn't drop it while unwinding the prevented quit
- Use requestAppQuit (not app.quit) on sync window close for forced-exit fallback coverage
- Settle cancelled/timed-out runs immediately when child already exited to avoid hanging on close held open by descendants
This commit is contained in:
2026-07-12 23:59:20 -07:00
parent 8ae77b296d
commit 8c97b48721
6 changed files with 81 additions and 2 deletions
@@ -165,6 +165,56 @@ test('runSyncLauncher cancel kills the child and resolves as cancelled', async (
assert.match(result.error ?? '', /cancel/i);
});
test('runSyncLauncher cancel settles a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--json'],
onEvent: () => {},
spawn,
});
const child = children[0]!;
// The child is gone, but a descendant still holds the inherited stdio pipes,
// so `close` never arrives.
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
handle.cancel();
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync cancelled.' });
assert.equal(child.killed, false);
});
test('runSyncLauncher times out a child that already exited without a result', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
command: ['subminer'],
args: ['sync', 'media-box', '--check', '--json'],
onEvent: () => {},
spawn,
timeoutMs: 1,
});
const child = children[0]!;
child.kill = () => {
child.killed = true;
return true;
};
child.emit('exit', null, 'SIGTERM');
const result = await Promise.race([
handle.done,
new Promise<null>((resolve) => setTimeout(() => resolve(null), 25)),
]);
assert.deepEqual(result, { ok: false, error: 'Sync operation timed out.' });
});
test('runSyncLauncher times out a child that never completes', async () => {
const { spawn, children } = makeSpawn();
const handle = runSyncLauncher({
+11
View File
@@ -75,6 +75,7 @@ export function runSyncLauncher(options: {
let resultEvent: Extract<SyncProgressEvent, { type: 'result' }> | null = null;
let terminationError: string | null = null;
let settleAfterTerminalEvent: (() => void) | null = null;
let settleAfterTermination: (() => boolean) | null = null;
child.stdout?.on('data', (chunk) => {
stdoutBuffer += chunk.toString();
@@ -137,6 +138,11 @@ export function runSyncLauncher(options: {
settleAfterTerminalEvent = () => {
if (exitObserved) settleFromExit(exitCode);
};
settleAfterTermination = () => {
if (!exitObserved) return false;
settleFromExit(exitCode);
return true;
};
// Electron descendants can retain inherited stdio pipes after the main
// child exits, delaying `close` indefinitely. Once terminal NDJSON has
// arrived, `exit` is authoritative; keep `close` as the fallback.
@@ -153,6 +159,11 @@ export function runSyncLauncher(options: {
const terminate = (error: string): void => {
if (terminationError) return;
terminationError = error;
// A child that already exited without terminal NDJSON is still waiting on
// `close`, which descendants holding inherited pipes can delay
// indefinitely. There is nothing left to signal, so settle now instead of
// leaving `done` (and the quit-time shutdown that awaits it) pending.
if (settleAfterTermination?.()) return;
killTimer = setTimeout(() => {
killTimer = null;
try {