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