Skip to content

Commit 243f3b4

Browse files
fix: clean up edge functions server (#310)
* fix: clean up edge functions server * add escape hatch * fix: avoid killing the process twice * swallow error if server has been stopped
1 parent bbcf94e commit 243f3b4

File tree

7 files changed

+94
-7
lines changed

7 files changed

+94
-7
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/dev-utils/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"@types/node": "^18.19.110",
4747
"@types/parse-gitignore": "^1.0.2",
4848
"@types/write-file-atomic": "^4.0.3",
49+
"execa": "^8.0.1",
4950
"tmp-promise": "^3.0.3",
5051
"tsup": "^8.0.0",
5152
"vitest": "^3.0.0"
@@ -58,10 +59,11 @@
5859
"dot-prop": "9.0.0",
5960
"env-paths": "^3.0.0",
6061
"find-up": "7.0.0",
61-
"js-image-generator": "^1.0.4",
6262
"image-size": "^2.0.2",
63+
"js-image-generator": "^1.0.4",
6364
"lodash.debounce": "^4.0.8",
6465
"parse-gitignore": "^2.0.0",
66+
"semver": "^7.7.2",
6567
"uuid": "^11.1.0",
6668
"write-file-atomic": "^5.0.1"
6769
}

packages/dev-utils/src/lib/process.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { platform } from 'node:os'
2+
3+
import type { ExecaChildProcess } from 'execa'
4+
import { satisfies } from 'semver'
5+
6+
// 1 second
7+
const SERVER_KILL_TIMEOUT = 1e3
8+
9+
export interface ProcessRef {
10+
ps?: ExecaChildProcess
11+
}
12+
13+
export const killProcess = (ps?: ExecaChildProcess) => {
14+
// If the process is no longer running, there's nothing left to do.
15+
if (!ps || ps.exitCode !== null) {
16+
return
17+
}
18+
19+
return new Promise<void>((resolve, reject) => {
20+
void ps.on('close', () => {
21+
resolve()
22+
})
23+
void ps.on('error', reject)
24+
25+
// On Windows with Node 21+, there's a bug where attempting to kill a child process
26+
// results in an EPERM error. Ignore the error in that case.
27+
// See: https://github.com/nodejs/node/issues/51766
28+
// We also disable execa's `forceKillAfterTimeout` in this case
29+
// which can cause unhandled rejection.
30+
try {
31+
ps.kill('SIGTERM', {
32+
forceKillAfterTimeout:
33+
platform() === 'win32' && satisfies(process.version, '>=21') ? false : SERVER_KILL_TIMEOUT,
34+
})
35+
} catch {
36+
// no-op
37+
}
38+
})
39+
}

packages/dev-utils/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { Handler } from './lib/handler.js'
1010
export { LocalState } from './lib/local-state.js'
1111
export { type Logger, netlifyCommand, netlifyCyan, netlifyBanner } from './lib/logger.js'
1212
export { memoize, MemoizeCache } from './lib/memoize.js'
13+
export { killProcess, type ProcessRef } from './lib/process.js'
1314
export { HTTPServer } from './server/http_server.js'
1415
export { watchDebounced } from './lib/watch-debounced.js'
1516

packages/dev/src/main.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ export class NetlifyDev {
470470
return acc
471471
}, {})
472472

473-
this.#edgeFunctionsHandler = new EdgeFunctionsHandler({
473+
const edgeFunctionsHandler = new EdgeFunctionsHandler({
474474
configDeclarations: this.#config?.config.edge_functions ?? [],
475475
directories: [this.#config?.config.build.edge_functions].filter(Boolean) as string[],
476476
env,
@@ -479,6 +479,9 @@ export class NetlifyDev {
479479
siteID,
480480
siteName: config?.siteInfo.name,
481481
})
482+
this.#edgeFunctionsHandler = edgeFunctionsHandler
483+
484+
this.#cleanupJobs.push(() => edgeFunctionsHandler.stop())
482485
}
483486

484487
if (this.#features.functions) {

packages/edge-functions/dev/node/main.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import path from 'node:path'
22
import { fileURLToPath, pathToFileURL } from 'node:url'
33

4-
import { Logger, renderFunctionErrorPage, type Geolocation } from '@netlify/dev-utils'
4+
import { Logger, renderFunctionErrorPage, killProcess, type Geolocation, type ProcessRef } from '@netlify/dev-utils'
55
import {
66
find,
77
generateManifest,
@@ -46,6 +46,7 @@ export type EdgeFunctionsMatch = Awaited<ReturnType<EdgeFunctionsHandler['getFun
4646

4747
export class EdgeFunctionsHandler {
4848
private configDeclarations: Declaration[]
49+
private denoServerProcess?: ProcessRef
4950
private directories: string[]
5051
private geolocation: Geolocation
5152
private initialization: ReturnType<typeof this.initialize>
@@ -257,7 +258,8 @@ export class EdgeFunctionsHandler {
257258
private async initialize(env: Record<string, string>) {
258259
let success = true
259260

260-
const processRef = {}
261+
const processRef: ProcessRef = {}
262+
this.denoServerProcess = processRef
261263

262264
// If we ran the server on a random port, we wouldn't know how to reach it.
263265
// Compute the port upfront and pass it on to the server.
@@ -329,6 +331,11 @@ export class EdgeFunctionsHandler {
329331
method: 'HEAD',
330332
})
331333
} catch {
334+
// If we've already stopped the server, swallow the error.
335+
if (!this.denoServerProcess) {
336+
return
337+
}
338+
332339
if ((count + 1) * DENO_SERVER_POLL_INTERVAL > DENO_SERVER_POLL_TIMEOUT) {
333340
throw new Error('Could not establish a connection to the Netlify Edge Functions local development server')
334341
}
@@ -338,6 +345,20 @@ export class EdgeFunctionsHandler {
338345
return this.waitForDenoServer(port, count + 1)
339346
}
340347
}
348+
349+
async stop() {
350+
if (!this.denoServerProcess) {
351+
return
352+
}
353+
354+
const { ps } = this.denoServerProcess
355+
356+
this.denoServerProcess = undefined
357+
358+
try {
359+
await killProcess(ps)
360+
} catch {}
361+
}
341362
}
342363

343364
export { type Declaration } from '@netlify/edge-bundler'

packages/vite-plugin/src/main.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,15 @@ export default function netlify(options: NetlifyPluginOptions = {}): any {
2222
return []
2323
}
2424

25+
let netlifyDev: NetlifyDev | undefined
26+
2527
const plugin: vite.Plugin = {
2628
name: 'vite-plugin-netlify',
2729
async configureServer(viteDevServer) {
2830
const logger = createLoggerFromViteLogger(viteDevServer.config.logger)
2931
const { blobs, edgeFunctions, functions, middleware = true, redirects, staticFiles } = options
30-
const netlifyDev = new NetlifyDev({
32+
33+
netlifyDev = new NetlifyDev({
3134
blobs,
3235
edgeFunctions,
3336
functions,
@@ -46,6 +49,17 @@ export default function netlify(options: NetlifyPluginOptions = {}): any {
4649

4750
if (middleware) {
4851
viteDevServer.middlewares.use(async function netlifyPreMiddleware(nodeReq, nodeRes, next) {
52+
// This should never happen, but adding this escape hatch just in case.
53+
if (!netlifyDev) {
54+
logger.error(
55+
'Some primitives will not work as expected due to an unknown error. Please restart your application.',
56+
)
57+
58+
next()
59+
60+
return
61+
}
62+
4963
const headers: Record<string, string> = {}
5064
const result = await netlifyDev.handleAndIntrospectNodeRequest(nodeReq, {
5165
headersCollector: (key, value) => {
@@ -69,6 +83,7 @@ export default function netlify(options: NetlifyPluginOptions = {}): any {
6983

7084
next()
7185
})
86+
7287
logger.log(`Middleware loaded. Emulating features: ${netlifyDev.getEnabledFeatures().join(', ')}.`)
7388
}
7489

@@ -78,6 +93,12 @@ export default function netlify(options: NetlifyPluginOptions = {}): any {
7893
)
7994
}
8095
},
96+
97+
async closeBundle() {
98+
await netlifyDev?.stop()
99+
100+
netlifyDev = undefined
101+
},
81102
}
82103

83104
return [plugin]

0 commit comments

Comments
 (0)