Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions docs/developers/daemon/02-serve-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,19 +77,20 @@
12. **Build `fsFactory`**: `runQwenServe` defaults to `trusted: true`; direct `createServeApp` callers default to `trusted: false` and warn once.
13. **`createHttpAcpBridge`**, see [`03-acp-bridge.md`](./03-acp-bridge.md).
14. **`createServeApp`** assembles Express.
15. **`server.listen(port, hostname)`**, then resolve the actual `getPort()` for host allowlist.
16. **Register SIGINT / SIGTERM handlers** for graceful shutdown.
15. **Create and lifecycle-bind the HTTP(S) server before listening**, then call `server.listen(port, hostname)` and resolve the actual `getPort()` for host allowlist. Conversations ownership cannot start until this listener and the remaining host startup gates are ready.
16. **Register SIGINT / SIGTERM handlers** for graceful shutdown through the shared app lifecycle.

### Graceful shutdown

1. **Phase 1 - bridge teardown** on first signal:
1. **Seal admission and begin all drains** on the first signal:
- Dispose the device-flow registry and cancel pending flows.
- `bridge.shutdown()` marks each channel `isDying = true`, sends graceful close to each ACP child stdin, waits `KILL_HARD_DEADLINE_MS` (10s) per channel, then calls `channel.kill()` if needed.
2. **Phase 2 - HTTP teardown**:
2. **Close the listener while app and host drains run**:
- `server.close()` stops accepting new connections and lets in-flight requests finish.
- `SHUTDOWN_FORCE_CLOSE_MS` (5s) triggers `server.closeAllConnections()`.
Comment thread
doudouOUC marked this conversation as resolved.
- A second 2s deadline escalates again if needed.
3. **Second signal while exiting**:
3. **Release Conversations ownership only after positive shutdown proof** from the listener, app-local work, host-owned work, Live discovery cleanup, and runtime drains. Any incomplete proof rejects shutdown instead of allowing an unsafe handoff.
4. **Second signal while exiting**:
- `bridge.killAllSync()` + `process.exit(1)` to avoid orphaned children blocking daemon exit.

## State and lifecycle
Expand All @@ -98,9 +99,9 @@

- `url`: resolved listen URL, after ephemeral port resolution.
- `port`: actual port, including `0` resolution.
- `close({ timeoutMs? })`: programmatic shutdown for embedders and tests.
- `close()`: programmatic shutdown for embedders and tests.

Calling `createServeApp` directly returns only an `Application`; the embedder owns `listen` and shutdown.
Calling `createServeApp` directly still returns only an `Application`. An embedder that needs Live/Conversations must create the actual Node server, call `getServeAppLifecycle(app).bindServer(server)` before its first `listen()`, and await `lifecycle.close()` during shutdown. Without binding, ordinary routes remain available but Live/Conversations fail closed. Calling raw `server.close()` triggers event-driven cleanup, but the embedder must still await `lifecycle.close()` to observe drain or ownership-release failures.

## Dependencies

Expand Down
37 changes: 28 additions & 9 deletions docs/developers/daemon/20-quickstart-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,9 @@ serve/server.ts createServeApp() - builds Express app (**does
| `- return app
|
v
serve/run-qwen-serve.ts server = app.listen(port, hostname, cb)
serve/run-qwen-serve.ts server = createServer(app) / https.createServer(..., app)
| |- lifecycle.bindServer(server, { startupReady, drainHost })
| |- server.listen(port, hostname)
| |- server.maxConnections = cap
| |- actualPort = server.address().port
| |- write "qwen serve listening on ..."
Expand All @@ -272,8 +274,8 @@ commands/serve.ts await blockForever() // block forever unti

Key facts:

- **`createServeApp` only builds; it does not listen.** It returns an `express()` instance with middleware and routes mounted. The caller owns `app.listen()`. `server.test.ts` uses the factory this way across roughly 25 cases, so the factory intentionally avoids owning lifecycle.
- **`() => actualPort` is a lazy closure.** `actualPort` is assigned in the `app.listen` callback. The `hostAllowlist` middleware reads it on demand, so ephemeral ports (`--port 0`) still gate the `Host` header correctly.
- **`createServeApp` only builds; it does not listen.** It returns an `express()` instance with middleware and routes mounted. Ordinary-only embedders may continue to own `app.listen()`. Embedders that use Live/Conversations must bind the actual Node server to the exported app lifecycle before listening and await that lifecycle during shutdown.
- **`() => actualPort` is a lazy closure.** `actualPort` is assigned in the `server.listen` callback. The `hostAllowlist` middleware reads it on demand, so ephemeral ports (`--port 0`) still gate the `Host` header correctly.
- **`await blockForever()` is intentional.** If `yargs.parse()` resolves, the CLI top level falls through into the interactive TUI entrypoint (`gemini.tsx`). SIGINT / SIGTERM exit through `runQwenServe`'s `onSignal` path.

## 10. HTTP route file split
Expand Down Expand Up @@ -325,29 +327,46 @@ console.log(`Daemon at ${handle.url}`);
await handle.close(); // programmatic shutdown
```

Or get the Express app directly and listen yourself:
Or get the Express app directly and bind the listener lifecycle yourself. This form is required when the embed uses Live/Conversations:

```ts
import { createServeApp } from '@qwen-code/qwen-code/serve';

import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import {
createServeApp,
getServeAppLifecycle,
} from '@qwen-code/qwen-code/serve';
Comment thread
doudouOUC marked this conversation as resolved.

let actualPort = 0;
const app = createServeApp(
{
port: 0,
hostname: '127.0.0.1',
mode: 'http-bridge',
maxSessions: 20,
},
() => 0,
() => actualPort,
{
/* deps: bridge, fsFactory, ... */
},
);

const server = app.listen(0, '127.0.0.1', () => {
console.log('listening on', server.address());
const lifecycle = getServeAppLifecycle(app);
const server = createServer(app);
lifecycle.bindServer(server);
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => resolve());
});
actualPort = (server.address() as AddressInfo).port;
console.log('listening on', server.address());

// Stop admission, drain app work, close the listener, and release ownership.
await lifecycle.close();
```

Calling raw `server.close()` also starts the same event-driven cleanup, but it is only best effort unless the process remains alive; always await `lifecycle.close()` to receive shutdown errors. If no server is bound, Live/Conversations requests fail closed while ordinary-only app behavior is unchanged.

Note: when calling `createServeApp` directly, the default `fsFactory.trusted = false`. Agent-side ACP `writeTextFile` is rejected as `untrusted_workspace`, and a stderr warning is printed once. Either inject `deps.fsFactory` with explicit trust, inject `deps.bridge`, or accept the trust-gated default behavior.

## 13. Debugging recipes
Expand Down
Loading
Loading