fix: possible ERR_STREAM_WRITE_AFTER_END quitting

Massimo Melina committed Apr 28, 2026 at 15:10 UTC 14194a5ff95e0bb11da6e87ae2dfaac9b4a91ad0
3 files changed +27 -22
package.json
+2 -2
@@ -22,7 +22,7 @@
22 "test": "node --import tsx --test tests/test.ts",
23 "test-with-server": "sh -c 'npm run port-is-free && tsc && rm -rf tests/work tests/tmp && (node dist/src --cwd tests/work --config tests & echo $! > .server_pid) && sleep 2 && node --import tsx --test \"$@\" tests/test.ts; _exit=$?; if [ -f ./.server_pid ]; then SERVER_PID=$(cat ./.server_pid); kill \"$SERVER_PID\" 2>/dev/null || true; rm -f ./.server_pid; fi; exit $_exit' --",
24 "port-is-free": "node -e \"const port=process.argv[1]||8081;process.exit(await fetch('http://localhost:'+port).then(() => console.log('BUSY')||1, () => 0))\" --",
25 - "test-ui": "rm -rf tests/work tests/work2 && npx playwright test frontend && npx playwright test serial && npx playwright test admin-vfs",
25 + "test-ui": "npm run port-is-free -- 8081 && rm -rf tests/work tests/work2 && npx playwright test frontend && npx playwright test serial && npx playwright test admin-vfs",
26 "test-with-ui": "sh -c 'npm run port-is-free -- 3005 && npm run start-frontend & npm run port-is-free -- 3006 && npm run start-admin & cross-env TEST_WITH_UI=1 npx playwright test --ui \"$@\"' --",
27 "pub": "cd dist && npm publish",
28 "dist": "STASHED=; if ! git diff-index --quiet HEAD --; then git stash push -m 'dist' && STASHED=1; fi; CI=1 FORCE_COLOR=1 npm run dist-uncommitted || (EXIT_CODE=$?; [ -n \"$STASHED\" ] && git stash pop; exit $EXIT_CODE); [ -n \"$STASHED\" ] && git stash pop",
@@ -80,7 +80,7 @@
80 },
81 "dependencies": {
82 "@gregoranders/csv": "^0.0.13",
83 - "@rejetto/kvstorage": "^0.17.6",
83 + "@rejetto/kvstorage": "^0.17.7",
84 "acme-client": "^5.4.0",
85 "busboy": "^1.6.0",
86 "crc-32": "^1.2.2",
src/first.ts
+23 -18
@@ -1,33 +1,38 @@
1 // should not import other sources that themselves import this file, to avoid circular dependencies
2 import { EventEmitter } from 'events'
3 +import _ from 'lodash'
4 +import assert from 'assert'
5
4 -type ProcessExitHandler = (signal:string) => any
5 -const cbsOnExit = new Set<ProcessExitHandler>()
6 -export function onProcessExit(cb: ProcessExitHandler) {
7 - cbsOnExit.add(cb)
8 - return () => cbsOnExit.delete(cb)
6 +type ProcessExitHandler = (signal: string) => any
7 +const cbsOnExit = new Set<{ cb: ProcessExitHandler, order: number }>()
8 +export function onProcessExit(cb: ProcessExitHandler, order=10) {
9 + assert(Number.isInteger(order) && order >= 0, 'order must be an integer >= 0')
10 + const rec = { cb, order }
11 + cbsOnExit.add(rec)
12 + return () => cbsOnExit.delete(rec)
13 }
14
15 export let quitting = false
16 // 'exit' event is handled as the last resort, but it's not compatible with async callbacks
13 -onFirstEvent(process, ['exit', 'SIGQUIT', 'SIGTERM', 'SIGINT', 'SIGHUP'], signal => {
17 +onFirstEvent(process, ['exit', 'SIGQUIT', 'SIGTERM', 'SIGINT', 'SIGHUP'], async signal => {
18 console.log('Quitting with signal:', signal || 'unknown')
19 quitting = true
16 - return Promise.allSettled(Array.from(cbsOnExit).map(cb => {
17 - try { return cb(signal) }
18 - // keep exit moving even when a synchronous cleanup fails after partially shutting down
19 - catch (e) {
20 - console.error("Error while quitting", e)
21 - return Promise.reject(e)
22 - }
23 - })).then(() => {
24 - console.debug('Process exit')
25 - process.exit(0)
26 - })
20 + const byOrder = _.groupBy(Array.from(cbsOnExit), 'order') // this will be inherently ordered because keys are positive integers
21 + for (const recs of Object.values(byOrder))
22 + await Promise.allSettled(recs.map(({ cb }) => {
23 + try { return cb(signal) }
24 + // keep exit moving even when a synchronous cleanup fails after partially shutting down
25 + catch (e) {
26 + console.error("Error while quitting", e)
27 + return Promise.reject(e)
28 + }
29 + }))
30 + console.debug('Process exit')
31 + process.exit(0)
32 })
33
34 // keep calling cb in a sync fashion – returning a promise instead would break the code for argv.updating (update.ts)
30 -export function onFirstEvent(emitter:EventEmitter, events: string[], cb: (...args:any[])=> void) {
35 +export function onFirstEvent(emitter:EventEmitter, events: string[], cb: (...args:any[]) => void) {
36 let already = false
37 const cleanup = () => {
38 events.forEach((e, i) => emitter.off(e, handlers[i]!))
src/listen.ts
+2 -2
@@ -29,8 +29,8 @@ interface ServerExtra { name: string, error?: string, busy?: Promise<string> }
29 let httpSrv: undefined | http.Server & ServerExtra
30 let httpsSrv: undefined | http.Server & ServerExtra
31
32 -// update relaunch can keep a bridge process alive, so we proactively close listeners here to release ports before the next binary binds
33 -onProcessExit(() => Promise.all([stopServer(httpSrv), stopServer(httpsSrv)]))
32 +// the update relaunch can keep a bridge process alive, so we proactively close listeners here to release ports before the next binary binds; do it before (5) the storage file is closed, because sockets write there
33 +onProcessExit(() => Promise.all([stopServer(httpSrv), stopServer(httpsSrv)]), 5)
34
35 const openBrowserAtStart = defineConfig('open_browser_at_start', true)
36