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

@dvcolombandvcolomban 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).

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

netlifyBot commented Aug 5, 2026

Copy link
Copy Markdown

Deploy Preview for devfra ready!

NameLink
🔨 Latest commit3c2009a
🔍 Latest deploy loghttps://app.netlify.com/projects/devfra/deploys/6a7306d0a7b3bd00080aa78a
😎 Deploy Previewhttps://deploy-preview-163--devfra.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

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

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

CopilotAI 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.

FileDescription
packages/devframe/src/node/server.tsAdds listen-scoped error handling + WS teardown, throwing DF0052 on bind failures.
packages/devframe/src/node/diagnostics.tsIntroduces the new DF0052 diagnostic definition.
packages/devframe/src/node/tests/server.test.tsAdds a regression test for “port already taken” rejection behavior.
docs/errors/DF0052.mdDocuments 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
ContributorAuthor

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 threadpackages/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
dvcolombanforce-pushed the dvcol/fix-server-listen-error branch from c770b07 to 0041d0fCompareAugust 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)
CopilotAI review requested due to automatic review settings August 5, 2026 09:47

CopilotAI 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:mainAug 6, 2026
12 checks passed
Sign up for freeto 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

@dvcolomban@antfu