diff --git a/docs/errors/DF0052.md b/docs/errors/DF0052.md new file mode 100644 index 00000000..9922ef89 --- /dev/null +++ b/docs/errors/DF0052.md @@ -0,0 +1,29 @@ +--- +outline: deep +--- + +# DF0052: HTTP Server Failed to Listen + +## Message + +> Failed to listen on `{host}:{port}`: `{reason}` + +## Cause + +`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. + +## Example + +```ts +// A previous instance is still bound to 4096: +// await startHttpAndWs({ context, host: 'localhost', port: 4096 }) → DF0052 +``` + +## Fix + +- Free the port, 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` — check `error.cause.code` (e.g. `'EADDRINUSE'`) to branch on the failure kind programmatically. + +## Source + +- [`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. diff --git a/packages/devframe/src/node/__tests__/server.test.ts b/packages/devframe/src/node/__tests__/server.test.ts index 0a497730..1888bb5c 100644 --- a/packages/devframe/src/node/__tests__/server.test.ts +++ b/packages/devframe/src/node/__tests__/server.test.ts @@ -100,3 +100,19 @@ describe('startHttpAndWs rpcOptions passthrough', () => { } }) }) + +describe('startHttpAndWs listen failures', () => { + it('rejects when the port is already taken instead of hanging', async () => { + const host = '127.0.0.1' + const first = await startHttpAndWs({ context: await createTestContext(), host, port: 0, auth: false }) + + try { + await expect( + startHttpAndWs({ context: await createTestContext(), host, port: first.port, auth: false }), + ).rejects.toThrow(expect.objectContaining({ code: 'DF0052' })) + } + finally { + await first.close() + } + }) +}) diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts index f28dfc6e..f16ed74a 100644 --- a/packages/devframe/src/node/diagnostics.ts +++ b/packages/devframe/src/node/diagnostics.ts @@ -112,5 +112,9 @@ export const diagnostics = defineDiagnostics({ why: (p: { port: number }) => `The devframe instance on port ${p.port} has no MCP endpoint.`, fix: 'Restart the instance with the --mcp flag (or set `cli.mcp: true` on its definition) to expose its tools, then list instances again.', }, + DF0052: { + why: (p: { host: string, port: number, reason: string }) => `Failed to listen on ${p.host}:${p.port}: ${p.reason}`, + 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`.', + }, }, }) diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index 17c53854..a30965f3 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -255,9 +255,30 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise((resolveListen) => { - httpServer.listen(port, bindHost, () => resolveListen()) - }) + try { + await new Promise((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() + }) + }) + } + catch (error) { + // The WS transport is already attached above, so tear it down before + // surfacing the failure rather than leaking it and its peers. + await closeWs().catch(() => {}) + throw diagnostics.DF0052({ + host: bindHost, + port, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) + } } const address = httpServer.address()