Skip to content

fix(devframe): reject on server listen errors instead of hanging - #163

Merged
antfu merged 2 commits into
devframes:mainfrom
dvcolomban:dvcol/fix-server-listen-error
Aug 6, 2026
Merged

fix(devframe): reject on server listen errors instead of hanging#163
antfu merged 2 commits into
devframes:mainfrom
dvcolomban:dvcol/fix-server-listen-error

Conversation

@dvcolomban

@dvcolomban dvcolomban commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

What

startHttpAndWs's owned-server listen() never had an 'error' listener anywhere in the file. On a failed bind — most commonly EADDRINUSE, another process (often a previous devframe instance) already on the port — two things happen at once:

  • net.Server emits 'error' with nobody attached, which becomes an uncaughtException.
  • The await new Promise(...) around the listen call never settles, so startHttpAndWs (and createDevServer) hangs forever. try { await createDevServer(...) } catch {} can't see the failure at all.

This attaches a listen-scoped 'error' handler and rejects with it instead.

The one subtlety

By the time listen() runs, attachWsRpcTransport has already bound the WS transport, and (through createDevServer) the definition's setup() has already run. A bare reject would leak the WS server and its peers, so the fix awaits closeWs() — already in scope — before throwing.

Why a diagnostic, not the raw error

The rejection is a DF0052 diagnostic rather than the bare node error, to match the repo's structured-diagnostics convention (same { ...identity, reason } + cause shape as DF0045/DF0046). The original error is still there as error.cause, so branching on error.cause.code === 'EADDRINUSE' keeps working, and the message already contains the code too (listen EADDRINUSE 127.0.0.1:9999).

Note for maintainers

packages/devframe/src/rpc/transports/ws-server.ts has the identical shape (ownedServer.listen(port, host), unawaited, no handler) on its dedicated-port path. Left it alone here — attachWsRpcTransport is a sync public export with several call sites, so awaiting its listen is a signature change that's your call, not mine to fold into a fix PR. Happy to open a follow-up if useful.

One more knock-on worth flagging: packages/next/src/handler.ts builds its ready promise eagerly, so a host that never calls fetch() or close() will now see an unhandled rejection logged instead of a silent hang — still better, but a visible change.

Tests

New case in packages/devframe/src/node/__tests__/server.test.ts: start one startHttpAndWs instance, start a second on the same resolved port, assert DF0052 instead of a hang.

pnpm lint && pnpm knip && pnpm test && pnpm typecheck && pnpm build — all green (1055 tests).

Copilot AI lite review requested due to automatic review settings August 5, 2026 09:02
@netlify

netlify Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploy Preview for devfra ready!

Name Link
🔨 Latest commit 3c2009a
🔍 Latest deploy log https://app.netlify.com/projects/devfra/deploys/6a7306d0a7b3bd00080aa78a
😎 Deploy Preview https://deploy-preview-163--devfra.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a failure mode in startHttpAndWs() where an owned HTTP server listen() bind error could previously cause an uncaught exception and leave the returned promise pending forever. It introduces a listen-scoped error handler that rejects properly, tears down the already-attached WS transport, and surfaces the failure via a new structured diagnostic DF0052.

Changes:

  • Attach an 'error' listener during the owned-server listen() and reject/throw DF0052 on bind failures (with WS teardown).
  • Add DF0052 to node diagnostics and document it under docs/errors/.
  • Add a regression test ensuring a second server on the same port rejects instead of hanging.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.

File Description
packages/devframe/src/node/server.ts Adds listen-scoped error handling + WS teardown, throwing DF0052 on bind failures.
packages/devframe/src/node/diagnostics.ts Introduces the new DF0052 diagnostic definition.
packages/devframe/src/node/tests/server.test.ts Adds a regression test for “port already taken” rejection behavior.
docs/errors/DF0052.md Documents DF0052 message, cause, fix, and source.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +258 to +268
await new Promise<void>((resolve, reject) => {
const onError = (error: Error): void => reject(error)
// Without this listener a failed bind emits `error` with nobody
// attached — an uncaughtException — and the `listen` callback never
// fires, so this promise never settles.
httpServer.once('error', onError)
httpServer.listen(port, bindHost, () => {
httpServer.removeListener('error', onError)
resolve()
})
})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one as-is — I think it's inert here.

listen() only throws synchronously for ERR_SOCKET_BAD_PORT / ERR_SERVER_ALREADY_LISTEN. That throw happens inside the Promise executor, so the promise rejects, the catch below wraps it as DF0052, and startHttpAndWs throws — httpServer is never handed back to anyone. This block only runs under ownsHttpServer, so that server was created a few lines up and its only reference is the closure being unwound: the server and the listener become garbage together, and a listener on an unreachable object isn't a leak. Even if something did somehow emit 'error' on it afterwards, reject on a settled promise is a no-op.

The leak you're describing would be real on a caller-supplied server — but that's the branch this code never takes.

Comment thread packages/devframe/src/node/server.ts
startHttpAndWs's owned-server listen had no 'error' listener, so a
failed bind (e.g. EADDRINUSE) emitted 'error' with nobody attached —
an uncaughtException — while the listen promise never settled,
leaving createDevServer permanently pending. A caller doing
try { await createDevServer(...) } catch {} could not observe the
failure at all.

Attach a listen-scoped 'error' handler and reject with it. The WS RPC
transport is already attached by the time listen runs, so tear it
down via closeWs() before throwing to avoid leaking it and its peers.

The rejection is a new DF0052 diagnostic carrying the original node
error as `cause`, so callers can still branch on `error.cause.code`
(e.g. 'EADDRINUSE') while getting an actionable message and fix hint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@dvcolomban
dvcolomban force-pushed the dvcol/fix-server-listen-error branch from c770b07 to 0041d0f Compare August 5, 2026 09:24
The DF0052 catch assumed the caught value was an Error and cast both
`message` and `cause`. `listen()` can throw arbitrary values, so derive
the reason with the `error instanceof Error ? error.message : String(error)`
pattern already used at both DF0045 call sites in instance-registry.ts,
and pass the original value through as `cause` uncast.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Copilot AI review requested due to automatic review settings August 5, 2026 09:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@antfu
antfu merged commit 10d3a10 into devframes:main Aug 6, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants