Skip to content

Commit c16f02b

Browse files
committed
fix(dev): reap every pooled fork on shutdown
1 parent 747c898 commit c16f02b

4 files changed

Lines changed: 61 additions & 3 deletions

File tree

‎packages/nuxt-cli/src/dev/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class IPC {
2727
constructor(){
2828
// only kill process if it is a fork
2929
if(this.enabled){
30+
// Without a parent there is nobody to reap this process, and it may be
31+
// holding the dev server port or the inspector port.
32+
process.once('disconnect',()=>{
33+
process.exit(0)
34+
})
3035
process.once('unhandledRejection',(reason)=>{
3136
this.send({type: 'nuxt:internal:dev:rejection',message: reasoninstanceofError ? reason.toString() : 'Unhandled Rejection'})
3237
process.exit()

‎packages/nuxt-cli/src/dev/pool.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import type { NuxtDevContext, NuxtDevIPCMessage } from './utils'
55

66
import{fork}from'node:child_process'
77
importprocessfrom'node:process'
8-
import{isDeno}from'std-env'
98
import{debug}from'../utils/logger'
109

1110
interfaceForkPoolOptions{
@@ -195,7 +194,8 @@ export class ForkPool {
195194
constwasAlive=fork.state!=='dead'&&!!fork.process&&fork.process.exitCode===null
196195
fork.state='dead'
197196
if(fork.process){
198-
fork.process.kill(signal===0&&isDeno ? 'SIGTERM' : signal)
197+
// signal 0 only probes for liveness, so map the `exit` case onto a real signal
198+
fork.process.kill(signal===0 ? 'SIGTERM' : signal)
199199
}
200200
this.removeFork(fork)
201201

@@ -223,7 +223,8 @@ export class ForkPool {
223223
}
224224

225225
privatekillAll(signal: NodeJS.Signals|number): void{
226-
for(constforkofthis.pool){
226+
// `killFork` mutates the pool, so iterate over a snapshot
227+
for(constforkof[...this.pool]){
227228
this.killFork(fork,signal)
228229
}
229230
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
importprocessfrom'node:process'
2+
3+
// Node refs the IPC channel while a `message` listener is attached, which is
4+
// what keeps this stand-in for a warm fork alive after it reports readiness.
5+
process.on('message',()=>{})
6+
process.send({type: 'nuxt:internal:dev:fork-ready'})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
importtype{ChildProcess}from'node:child_process'
2+
import{fileURLToPath}from'node:url'
3+
import{afterEach,describe,expect,it,vi}from'vitest'
4+
5+
import{ForkPool}from'../../src/dev/pool'
6+
7+
constdevEntry=fileURLToPath(newURL('../fixtures/fork-pool-entry.mjs',import.meta.url))
8+
9+
constpools: ForkPool[]=[]
10+
11+
functioncreatePool(poolSize?: number){
12+
globalThis.__nuxt_cli__={ ...globalThis.__nuxt_cli__, devEntry }astypeofglobalThis.__nuxt_cli__
13+
constpool=newForkPool({rawArgs: [], poolSize,listenOverrides: {}})
14+
pools.push(pool)
15+
returnpool
16+
}
17+
18+
asyncfunctionwaitForReady(pool: ForkPool,count: number){
19+
awaitvi.waitFor(()=>{
20+
expect(pool.getStats().ready).toBe(count)
21+
},{timeout: 20_000,interval: 25})
22+
}
23+
24+
afterEach(async()=>{
25+
for(constpoolofpools.splice(0)){
26+
await(poolasunknownas{killAll: (signal: number)=>void}).killAll(0)
27+
}
28+
})
29+
30+
describe('forkPool',()=>{
31+
it('should kill every fork on shutdown',{timeout: 30_000},async()=>{
32+
constpool=createPool(3)
33+
pool.startWarming()
34+
awaitwaitForReady(pool,3)
35+
36+
constprocesses=(poolasunknownas{pool: Array<{process: ChildProcess}>}).pool.map(f=>f.process)
37+
;(poolasunknownas{killAll: (signal: number)=>void}).killAll(0)
38+
39+
awaitvi.waitFor(()=>{
40+
for(constchildofprocesses){
41+
expect(child.killed).toBe(true)
42+
}
43+
},{timeout: 10_000,interval: 25})
44+
expect(pool.getStats().total).toBe(0)
45+
})
46+
})

0 commit comments

Comments
 (0)