Skip to content

Commit 2ac050b

Browse files
committed
Merge remote-tracking branch 'origin/main' into dvcol/on-peer-disconnect
# Conflicts: # packages/devframe/src/node/__tests__/server.test.ts
2 parents 8a550ed + 5fa8014 commit 2ac050b

9 files changed

Lines changed: 106 additions & 9 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.

docs/errors/DF8206.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
outline: deep
3+
---
4+
5+
# DF8206: Terminal Session Restart on Closed Stream
6+
7+
## Message
8+
9+
> Terminal session "`{id}`" cannot be restarted — its output stream is already closed
10+
11+
## Cause
12+
13+
`restart()` was called on a `startChildProcess()` or `startPtySession()` session whose output stream is already closed. The stream closes irreversibly on a natural process exit or after `terminate()` — it backs a single-use `ReadableStream` controller that cannot be reopened, so restarting in place is not possible once it has closed.
14+
15+
## Fix
16+
17+
Drop the spent session with `ctx.terminals.remove(session)`, then spawn a replacement via `ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` with a fresh id.
18+
19+
## Source
20+
21+
- [`packages/hub/src/node/host-terminals.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/host-terminals.ts) — the `restart()` handle returned by `startChildProcess()` and `startPtySession()` throws this once the session's stream has closed.

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,19 @@ describe('startHttpAndWs onPeerConnect / onPeerDisconnect', () => {
144144
}
145145
})
146146
})
147+
148+
describe('startHttpAndWs listen failures', () => {
149+
it('rejects when the port is already taken instead of hanging', async () => {
150+
const host = '127.0.0.1'
151+
const first = await startHttpAndWs({ context: await createTestContext(), host, port: 0, auth: false })
152+
153+
try {
154+
await expect(
155+
startHttpAndWs({ context: await createTestContext(), host, port: first.port, auth: false }),
156+
).rejects.toThrow(expect.objectContaining({ code: 'DF0052' }))
157+
}
158+
finally {
159+
await first.close()
160+
}
161+
})
162+
})

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
@@ -263,9 +263,30 @@ export async function startHttpAndWs(options: StartHttpAndWsOptions): Promise<St
263263
// Only start listening on a server we created. A shared server is already
264264
// (or about to be) listening under the caller's control.
265265
if (ownsHttpServer) {
266-
await new Promise<void>((resolveListen) => {
267-
httpServer.listen(port, bindHost, () => resolveListen())
268-
})
266+
try {
267+
await new Promise<void>((resolve, reject) => {
268+
const onError = (error: Error): void => reject(error)
269+
// Without this listener a failed bind emits `error` with nobody
270+
// attached — an uncaughtException — and the `listen` callback never
271+
// fires, so this promise never settles.
272+
httpServer.once('error', onError)
273+
httpServer.listen(port, bindHost, () => {
274+
httpServer.removeListener('error', onError)
275+
resolve()
276+
})
277+
})
278+
}
279+
catch (error) {
280+
// The WS transport is already attached above, so tear it down before
281+
// surfacing the failure rather than leaking it and its peers.
282+
await closeWs().catch(() => {})
283+
throw diagnostics.DF0052({
284+
host: bindHost,
285+
port,
286+
reason: error instanceof Error ? error.message : String(error),
287+
cause: error,
288+
})
289+
}
269290
}
270291

271292
const address = httpServer.address()

packages/hub/src/node/__tests__/host-terminals.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ describe('devframeTerminalHost stream lifecycle', () => {
163163
expect(session.buffer!.includes('line-0')).toBe(false)
164164
})
165165

166-
it('does not restart a terminated child-process session', async () => {
166+
it('rejects restarting a terminated child-process session', async () => {
167167
const { host, sinks } = createTerminalHost()
168168
const session = await host.startChildProcess(
169169
{ command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] },
@@ -173,7 +173,7 @@ describe('devframeTerminalHost stream lifecycle', () => {
173173
await waitUntil(() => {
174174
expect(sinks.get('child')?.closed).toBe(true)
175175
})
176-
await session.restart()
176+
await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8206' }))
177177
// Stream stays closed; no orphan output stream.
178178
expect(sinks.get('child')?.closed).toBe(true)
179179
})
@@ -509,9 +509,9 @@ describe('devframeTerminalHost PTY status lifecycle', () => {
509509
await waitUntil(() => {
510510
expect(session.status).toBe('stopped')
511511
})
512-
// The stream is closed for good, so `restart()` is a no-op — the session
512+
// The stream is closed for good, so `restart()` rejects — the session
513513
// stays reported as stopped rather than flipping back to running.
514-
await session.restart()
514+
await expect(session.restart()).rejects.toThrow(expect.objectContaining({ code: 'DF8206' }))
515515
expect(session.status).toBe('stopped')
516516
})
517517
})

packages/hub/src/node/diagnostics.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export const diagnostics = defineDiagnostics({
6767
why: (p: { id: string }) => `Terminal session "${p.id}" is not restartable`,
6868
fix: 'It was registered with `restartable: false`; restart it through its owner\'s controls, or spawn it with `restartable: true` (the default) to allow in-place restarts.',
6969
},
70+
DF8206: {
71+
why: (p: { id: string }) => `Terminal session "${p.id}" cannot be restarted — its output stream is already closed`,
72+
fix: 'The session already exited (or was terminated) and its stream is spent. Drop it with `ctx.terminals.remove(session)`, then spawn a replacement via `ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` with a fresh id.',
73+
},
7074
DF8400: {
7175
why: (p: { id: string }) => `Command "${p.id}" is already registered`,
7276
},

packages/hub/src/node/host-terminals.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
322322

323323
const restart = async () => {
324324
if (streamClosed)
325-
return
325+
throw diagnostics.DF8206({ id: terminal.id })
326326
cp?.kill()
327327
cp = createChildProcess()
328328
markStatus('running')
@@ -497,7 +497,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType {
497497
},
498498
restart: async () => {
499499
if (streamClosed)
500-
return
500+
throw diagnostics.DF8206({ id: terminal.id })
501501
pty?.kill()
502502
pty = spawnPty()
503503
markStatus('running')

packages/hub/src/types/terminals.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ export interface DevframeChildProcessTerminalSession extends DevframeTerminalSes
114114
*/
115115
getResult: () => DevframeChildProcessResult
116116
terminate: () => Promise<void>
117+
/** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — drop it with `ctx.terminals.remove(session)` and start a fresh session instead. */
117118
restart: () => Promise<void>
118119
}
119120

@@ -139,5 +140,6 @@ export interface DevframePtyTerminalSession extends DevframeTerminalSession {
139140
/** Current foreground process name, when the backend can resolve it. */
140141
getProcessName: () => string | undefined
141142
terminate: () => Promise<void>
143+
/** Throws `DF8206` once the session's output stream has closed (after a natural exit or `terminate()`) — drop it with `ctx.terminals.remove(session)` and start a fresh session instead. */
142144
restart: () => Promise<void>
143145
}

0 commit comments

Comments
 (0)