Skip to content

Commit 58834a2

Browse files
fix: prevent Windows NSIS auto-update stall by capping before-quit cleanup time (#1497)
* fix: cap before-quit cleanup to 2 s on Windows to prevent NSIS update stall * fix: shorten force-kill timeout to 500 ms on Windows in child process kill() * fix: bound all quit paths and confirm child death --------- Co-authored-by: Nicolas Echezarreta <nicoecheza@gmail.com>
1 parent c71226b commit 58834a2

3 files changed

Lines changed: 147 additions & 40 deletions

File tree

packages/creator-hub/main/src/index.ts

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { app } from 'electron';
44
import {
55
init as sentryInit,
66
captureException,
7+
flush as sentryFlush,
78
electronBreadcrumbsIntegration,
89
electronContextIntegration,
910
childProcessIntegration,
@@ -15,7 +16,8 @@ import {
1516
import log from 'electron-log/main';
1617

1718
import { restoreOrCreateMainWindow } from '/@/mainWindow';
18-
import { killAllUtilityProcesses } from '/@/modules/bin';
19+
import { delay } from '/shared/utils';
20+
import { FORCE_KILL_TIMEOUT_MS, killAllUtilityProcesses } from '/@/modules/bin';
1921
import { initIpc } from '/@/modules/ipc';
2022
import { deployServer, killAllPreviews } from '/@/modules/cli';
2123
import { killAllRealms } from '/@/modules/bevy-realm';
@@ -128,13 +130,18 @@ app
128130

129131
initIpc({
130132
beforeQuitCleanup: async () => {
133+
// raise the skip flag only after cleanup finishes: raising it earlier disarms
134+
// the before-quit guard for the whole cleanup window, and a concurrent quit
135+
// would tear the app down mid-killAll with the installer never launched
136+
await killAll();
137+
await flushTelemetry();
131138
setSkipBeforeQuitCleanup();
132-
try {
133-
await killAll();
134-
} catch (error) {
139+
// quitAndInstall() reports failure through an 'error' event instead of
140+
// throwing; if the app is still running after this window the install did
141+
// not happen, so re-arm cleanup instead of skipping it on every future quit
142+
setTimeout(() => {
135143
skipBeforeQuitCleanup = false;
136-
throw error;
137-
}
144+
}, SKIP_CLEANUP_REARM_MS);
138145
},
139146
});
140147
log.info('[IPC] Ready');
@@ -159,27 +166,72 @@ export function setSkipBeforeQuitCleanup() {
159166
skipBeforeQuitCleanup = true;
160167
}
161168

169+
// The quit budget must stay above bin.ts's per-child FORCE_KILL_TIMEOUT_MS: a smaller
170+
// cap would exit before the SIGKILL escalation fires and orphan any child that
171+
// survived the graceful signal. On Windows this lands on the 2 s NSIS close window.
172+
const QUIT_CLEANUP_TIMEOUT_MS = FORCE_KILL_TIMEOUT_MS + 1500;
173+
const TELEMETRY_FLUSH_TIMEOUT_MS = 1000;
174+
const SKIP_CLEANUP_REARM_MS = 5000;
175+
162176
export async function killAll() {
163177
const promises: Promise<unknown>[] = [killAllPreviews(), killAllRealms()];
164178
if (deployServer) {
165179
promises.push(deployServer.stop());
166180
}
167181
killInspectorServer();
168182
promises.push(killAllUtilityProcesses());
169-
await Promise.all(promises);
183+
184+
// Cap cleanup here so every quit path inherits the bound — before-quit,
185+
// window-all-closed and the in-app update flow. On Windows, NSIS has a fixed
186+
// window to close the app before the "cannot be closed" dialog appears.
187+
const cleanup = Promise.all(promises).then(
188+
() => 'done' as const,
189+
error => {
190+
captureException(error, { tags: { source: 'kill-all' } });
191+
log.error('[App] Failed to kill all servers:', error);
192+
return 'failed' as const;
193+
},
194+
);
195+
const result = await Promise.race([
196+
cleanup,
197+
delay(QUIT_CLEANUP_TIMEOUT_MS).then(() => 'timed-out' as const),
198+
]);
199+
if (result === 'timed-out') {
200+
log.warn(
201+
`[App] Cleanup still running after ${QUIT_CLEANUP_TIMEOUT_MS}ms, quitting without waiting for it`,
202+
);
203+
}
204+
}
205+
206+
/**
207+
* Segment batches events (15 events / 10 s flush) and Sentry buffers its transport, so
208+
* exiting right after a capped cleanup would drop anything still queued.
209+
*/
210+
async function flushTelemetry() {
211+
try {
212+
const analytics = getAnalytics();
213+
await Promise.all([
214+
analytics ? analytics.closeAndFlush({ timeout: TELEMETRY_FLUSH_TIMEOUT_MS }) : null,
215+
sentryFlush(TELEMETRY_FLUSH_TIMEOUT_MS),
216+
]);
217+
} catch (error) {
218+
log.error('[App] Failed to flush telemetry:', error);
219+
}
170220
}
171221

222+
let quitInProgress = false;
223+
172224
app.on('before-quit', async event => {
173225
if (skipBeforeQuitCleanup) {
174226
return;
175227
}
176228
event.preventDefault();
177-
try {
178-
await killAll();
179-
} catch (error) {
180-
captureException(error, { tags: { source: 'before-quit' } });
181-
log.error('[App] Failed to kill all servers:', error);
229+
if (quitInProgress) {
230+
return;
182231
}
232+
quitInProgress = true;
233+
await killAll();
234+
await flushTelemetry();
183235
log.info('[App] Quit');
184236
app.exit();
185237
});

packages/creator-hub/main/src/modules/bin.ts

Lines changed: 82 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { exec as execSync, spawn } from 'child_process';
44
import log from 'electron-log/main';
55
import { shell, utilityProcess } from 'electron';
66
import treeKill from 'tree-kill';
7-
import { future } from 'fp-future';
7+
import { future, type IFuture } from 'fp-future';
88
import isRunning from 'is-running';
99
import { ErrorBase } from '/shared/types/error';
1010
import { createCircularBuffer } from '/shared/circular-buffer';
@@ -27,6 +27,12 @@ const exec = promisify(execSync);
2727

2828
const MAX_BUFFER_SIZE = 2048;
2929

30+
// Window for a child to exit gracefully before the kill escalates.
31+
// On Windows, tree-kill already issues taskkill /F /T so a long graceful wait just delays
32+
// NSIS-triggered update installs; 500 ms is sufficient for the process tree to collapse.
33+
// The quit budget in index.ts is derived from this value and must stay above it.
34+
export const FORCE_KILL_TIMEOUT_MS = process.platform === 'win32' ? 500 : 5000;
35+
3036
type Error = 'COMMAND_FAILED';
3137

3238
export class StreamError extends ErrorBase<Error> {
@@ -107,6 +113,7 @@ type RunOptions = {
107113
export function run(pkg: string, bin: string, options: RunOptions = {}): Child {
108114
let isKilling = false;
109115
let alive = true;
116+
let killPromise: IFuture<void> | null = null;
110117

111118
const promise = future<Awaited<ReturnType<Child['wait']>>>();
112119
const matchers: Matcher[] = [];
@@ -145,10 +152,38 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child {
145152
delete childEnv.ELECTRON_RUN_AS_NODE;
146153
}
147154

155+
const ready = future<void>();
156+
148157
const forked: ChildProcessLike = nodePath
149158
? spawn(nodePath, [binPath, ...args], { cwd, stdio: 'pipe', env: childEnv })
150159
: utilityProcess.fork(binPath, [...args], { cwd, stdio: 'pipe', env: childEnv });
151160

161+
// A plain child process reports a failed launch through 'error', and no 'exit' follows
162+
// it; settle everything here so wait() and kill() never block on a process that never
163+
// ran. Electron utility processes only emit 'spawn'/'exit'.
164+
if (nodePath) {
165+
(forked as ReturnType<typeof spawn>).on('error', error => {
166+
if (!alive) return;
167+
alive = false;
168+
log.error(`[UtilityProcess] Process "${name}" failed to start:`, error);
169+
if (isKilling) {
170+
promise.resolve(Buffer.concat(stdout.getAll()));
171+
} else {
172+
promise.reject(
173+
new StreamError(
174+
'COMMAND_FAILED',
175+
`Error: process "${name}" failed to start: ${error.message}`,
176+
Buffer.concat(stdout.getAll()),
177+
Buffer.concat(stderr.getAll()),
178+
),
179+
);
180+
}
181+
cleanup();
182+
ready.resolve();
183+
killPromise?.resolve();
184+
});
185+
}
186+
152187
const cleanup = () => {
153188
for (const matcher of matchers) {
154189
matcher.enabled = false;
@@ -173,8 +208,6 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child {
173208
stdall.push(Uint8Array.from(data));
174209
});
175210

176-
const ready = future<void>();
177-
178211
const name = `${bin} ${args.join(' ')}`.trim();
179212
let spawnedPid: number | undefined;
180213

@@ -212,6 +245,11 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child {
212245
promise.resolve(stdoutBuf);
213246
}
214247
cleanup();
248+
// a spawn that never happened still needs kill() unblocked, and an exit landing
249+
// mid-kill() must settle the kill instead of leaving it to the pid poll, which
250+
// can stay truthy forever on Windows pid reuse
251+
ready.resolve();
252+
killPromise?.resolve();
215253
});
216254

217255
const child: Child = {
@@ -269,52 +307,69 @@ export function run(pkg: string, bin: string, options: RunOptions = {}): Child {
269307
}
270308
}),
271309
kill: async () => {
310+
// a repeat caller shares the in-flight kill instead of getting an instantly
311+
// resolved undefined that would let shutdown truncate the first one's cleanup
312+
if (killPromise) return killPromise;
313+
if (!alive) return;
314+
315+
const pending = (killPromise = future<void>());
316+
isKilling = true;
317+
272318
await ready;
273-
const pid = forked.pid!;
274319

275-
// if child is being killed or already killed then return
276-
if (isKilling || !alive) return;
320+
const pid = spawnedPid;
321+
if (!alive || !pid) {
322+
pending.resolve();
323+
return pending;
324+
}
277325

278-
isKilling = true;
279326
log.info(`[UtilityProcess] Killing process "${name}" with pid=${pid}...`);
280327

281-
// create promise to kill child
282-
const killPromise = future<void>();
283-
284328
// kill child gracefully
285329
treeKill(pid);
286330

287-
// child successfully killed
288-
const die = (force: boolean = false) => {
331+
let forced = false;
332+
333+
// child confirmed dead: settle wait() too — 'exit' early-returns once alive is
334+
// false, and may never fire at all after a forced tree kill
335+
const die = () => {
336+
if (!pending.isPending) return;
289337
alive = false;
338+
processes.delete(pid);
339+
log.info(
340+
`[UtilityProcess] Process "${name}" with pid=${pid} ${
341+
forced ? 'forcefully' : 'gracefully'
342+
} killed`,
343+
);
344+
promise.resolve(Buffer.concat(stdout.getAll()));
290345
cleanup();
291-
clearInterval(interval);
292-
clearTimeout(timeout);
293-
if (force) {
294-
log.info(`[UtilityProcess] Process "${name}" with pid=${pid} forcefully killed`);
295-
treeKill(pid, 'SIGKILL');
296-
} else {
297-
log.info(`[UtilityProcess] Process "${name}" with pid=${pid} gracefully killed`);
298-
}
299-
killPromise.resolve();
346+
pending.resolve();
300347
};
301348

302349
// interval to check if child still running and flag it as dead when is not running anymore
303350
const interval = setInterval(() => {
304-
if (!pid || !isRunning(pid)) {
351+
if (!isRunning(pid)) {
305352
die();
306353
}
307354
}, 100);
308355

309-
// timeout to stop checking if child still running, kill it with fire
356+
// timeout to stop waiting for a graceful exit, kill it with fire. The poll keeps
357+
// running afterwards: resolving right here would declare success while the tree
358+
// can still be alive holding the very files an NSIS update needs to replace.
310359
const timeout = setTimeout(() => {
311360
if (alive) {
312-
die(true);
361+
forced = true;
362+
treeKill(pid, 'SIGKILL');
313363
}
314-
}, 5000);
364+
}, FORCE_KILL_TIMEOUT_MS);
365+
366+
// whether death is confirmed by the poll or by the 'exit' event, stop the timers
367+
void pending.then(() => {
368+
clearInterval(interval);
369+
clearTimeout(timeout);
370+
});
315371

316-
// return promise
317-
return killPromise;
372+
return pending;
318373
},
319374
alive: () => alive,
320375
};

packages/creator-hub/main/src/modules/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ export async function legacyDeploy({
615615

616616
process.waitFor(/close the terminal/gi).then(() => process.kill());
617617

618-
process.wait().catch(); // handle rejection of main promise to avoid warnings in console
618+
process.wait().catch(() => {}); // handle rejection of main promise to avoid warnings in console
619619

620620
deployServer = { stop: () => process.kill() };
621621

0 commit comments

Comments
 (0)