Skip to content

Commit 10d3a10

Browse files
authored
fix(devframe): reject on server listen errors instead of hanging (#163)
1 parent 5669caf commit 10d3a10

4 files changed

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

263284
const address = httpServer.address()

0 commit comments

Comments
 (0)