@@ -4,7 +4,7 @@ import { exec as execSync, spawn } from 'child_process';
44import log from 'electron-log/main' ;
55import { shell , utilityProcess } from 'electron' ;
66import treeKill from 'tree-kill' ;
7- import { future } from 'fp-future' ;
7+ import { future , type IFuture } from 'fp-future' ;
88import isRunning from 'is-running' ;
99import { ErrorBase } from '/shared/types/error' ;
1010import { createCircularBuffer } from '/shared/circular-buffer' ;
@@ -27,6 +27,12 @@ const exec = promisify(execSync);
2727
2828const 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+
3036type Error = 'COMMAND_FAILED' ;
3137
3238export class StreamError extends ErrorBase < Error > {
@@ -107,6 +113,7 @@ type RunOptions = {
107113export 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 } ;
0 commit comments