From 0041d0fbfeb081c9dc2321c24a679a512a726df7 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Wed, 5 Aug 2026 08:46:48 +0200 Subject: [PATCH 1/2] fix(devframe): reject on server listen errors instead of hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/errors/DF0052.md | 29 +++++++++++++++++++ .../src/node/__tests__/server.test.ts | 16 ++++++++++ packages/devframe/src/node/diagnostics.ts | 4 +++ packages/devframe/src/node/server.ts | 22 ++++++++++++-- 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 docs/errors/DF0052.md 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..45086e5f 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -255,9 +255,25 @@ 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 as Error).message, cause: error as Error }) + } } const address = httpServer.address() From 3c2009a16ec18781184f90cc2d9bfc04a170f768 Mon Sep 17 00:00:00 2001 From: dvcolomban Date: Wed, 5 Aug 2026 11:44:08 +0200 Subject: [PATCH 2/2] fix(devframe): handle non-Error listen failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/devframe/src/node/server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/devframe/src/node/server.ts b/packages/devframe/src/node/server.ts index 45086e5f..a30965f3 100644 --- a/packages/devframe/src/node/server.ts +++ b/packages/devframe/src/node/server.ts @@ -272,7 +272,12 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise {}) - throw diagnostics.DF0052({ host: bindHost, port, reason: (error as Error).message, cause: error as Error }) + throw diagnostics.DF0052({ + host: bindHost, + port, + reason: error instanceof Error ? error.message : String(error), + cause: error, + }) } }