Skip to content

Commit c770b07

Browse files
committed
fix(devframe): reject on server listen errors instead of hanging
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)
1 parent de44144 commit c770b07

4 files changed

Lines changed: 68 additions & 3 deletions

File tree

docs/errors/DF0052.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF0052: HTTP Server Failed to Listen
6+
7+
## Message
8+
9+
> Failed to listen on `{host}:{port}`: `{reason}`
10+
11+
## Cause
12+
13+
`startHttpAndWs` tried to bind the HTTP server it owns to `host:port` and the underlying `listen()` call failed — most commonly `EADDRINUSE` (another process, often a previous devframe instance, is already bound to that port) or `EACCES` (insufficient permissions, typically a privileged port). The WS RPC transport is torn down before this error surfaces, so nothing is leaked.
14+
15+
## Example
16+
17+
```ts
18+
// A previous instance is still bound to 4096:
19+
// await startHttpAndWs({ context, host: 'localhost', port: 4096 }) → DF0052
20+
```
21+
22+
## Fix
23+
24+
- Free the port, or pick another via `--port`, `cli.port` / `cli.portRange` on the definition, or `devMiddleware.port` on `viteDevBridge`.
25+
- The original node error is available as `error.cause` — check `error.cause.code` (e.g. `'EADDRINUSE'`) to branch on the failure kind programmatically.
26+
27+
## Source
28+
29+
- [`packages/devframe/src/node/server.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/server.ts)`startHttpAndWs()` throws this when its owned HTTP server's `listen()` fails.

packages/devframe/src/node/__tests__/server.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,19 @@ describe('startHttpAndWs rpcOptions passthrough', () => {
100100
}
101101
})
102102
})
103+
104+
describe('startHttpAndWs listen failures', () => {
105+
it('rejects when the port is already taken instead of hanging', async () => {
106+
const host = '127.0.0.1'
107+
const first = await startHttpAndWs({ context: await createTestContext(), host, port: 0, auth: false })
108+
109+
try {
110+
await expect(
111+
startHttpAndWs({ context: await createTestContext(), host, port: first.port, auth: false }),
112+
).rejects.toThrow(expect.objectContaining({ code: 'DF0052' }))
113+
}
114+
finally {
115+
await first.close()
116+
}
117+
})
118+
})

packages/devframe/src/node/diagnostics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,5 +112,9 @@ export const diagnostics = defineDiagnostics({
112112
why: (p: { port: number }) => `The devframe instance on port ${p.port} has no MCP endpoint.`,
113113
fix: 'Restart the instance with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.',
114114
},
115+
DF0052: {
116+
why: (p: { host: string, port: number, reason: string }) => `Failed to listen on ${p.host}:${p.port}: ${p.reason}`,
117+
fix: 'The port is likely already taken by another process (often a previous devframe instance). Free it, or pick another via `--port`, `cli.port` / `cli.portRange` on the definition, or `devMiddleware.port` on `viteDevBridge`. The original node error is available as `error.cause`.',
118+
},
115119
},
116120
})

packages/devframe/src/node/server.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,25 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
254254
// Only start listening on a server we created. A shared server is already
255255
// (or about to be) listening under the caller's control.
256256
if (ownsHttpServer) {
257-
await new Promise<void>((resolveListen) => {
258-
httpServer.listen(port, bindHost, () => resolveListen())
259-
})
257+
try {
258+
await new Promise<void>((resolve, reject) => {
259+
const onError = (error: Error): void => reject(error)
260+
// Without this listener a failed bind emits `error` with nobody
261+
// attached — an uncaughtException — and the `listen` callback never
262+
// fires, so this promise never settles.
263+
httpServer.once('error', onError)
264+
httpServer.listen(port, bindHost, () => {
265+
httpServer.removeListener('error', onError)
266+
resolve()
267+
})
268+
})
269+
}
270+
catch (error) {
271+
// The WS transport is already attached above, so tear it down before
272+
// surfacing the failure rather than leaking it and its peers.
273+
await closeWs().catch(() => {})
274+
throw diagnostics.DF0052({ host: bindHost, port, reason: (error as Error).message, cause: error as Error })
275+
}
260276
}
261277

262278
const address = httpServer.address()

0 commit comments

Comments
 (0)