qwen-code/scripts/daemon-dev.js
Shaojin Wen 271664b34b
feat(cli): auto-retry next port when serve port is in use (#6513)
* feat(cli): auto-retry next port when serve port is in use

When `qwen serve` or `npm run dev:daemon` encounters EADDRINUSE on the
default port (4170), automatically try the next available port (up to 10
attempts) instead of failing immediately. This allows running multiple
daemon instances side-by-side without manual port management.

- run-qwen-serve.ts: replace single listen() with recursive tryListen()
  that retries on EADDRINUSE; --port 0 (ephemeral) skips retry
- daemon-dev.js: pre-scan for an available port via net probe before
  spawning the daemon child, ensuring health-poll and web-shell target
  the correct URL
- Tests: retry-then-succeed, non-EADDRINUSE immediate-fail, and existing
  all-ports-exhausted test all pass (138 tests)

* test(cli): fix EADDRINUSE mock to create fresh server per listen attempt

The existing test reused a single fakeServer across all retry attempts,
accumulating 10+ once('listening') listeners and triggering a
MaxListenersExceededWarning. Create a new server per call to match
production behavior where each tryListen creates a fresh server.

* fix: address review feedback on port retry

- daemon-dev.js: strip IPv6 brackets before probe (ENOTFOUND fix),
  add port range/NaN validation
- run-qwen-serve.ts: remove duplicate runtime error listener
  (onListening already installs one via removeAllListeners + on)
- tests: add exhaustion (all 10 ports), port 0 EADDRINUSE no-retry,
  and stderr retry message assertion (140 tests pass)

* fix: update stale comment referencing removed server.once('error', reject)

* fix: address R2 review — port cap, TLS reuse, exhaustion summary

- daemon-dev.js: cap probe at port 65535, skip probe when user
  specifies --port, add --compacted-replay-max-bytes to whitelist
- run-qwen-serve.ts: move https.createServer before tryListen (avoid
  recreating TLS context per retry), cap retry at 65535, log summary
  "all ports X–Y are in use" on exhaustion
- tests: verify exhaustion summary stderr message

* fix: clear stale listening listeners on httpsServer before retry
2026-07-08 10:31:52 +00:00

331 lines
8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawn } from 'node:child_process';
import crypto from 'node:crypto';
import http from 'node:http';
import net from 'node:net';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { platform } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const args = process.argv.slice(2);
const isWin = platform() === 'win32';
const serveOptionNames = new Set([
'--port',
'--hostname',
'--token',
'--max-sessions',
'--workspace',
'--max-connections',
'--require-auth',
'--event-ring-size',
'--compacted-replay-max-bytes',
]);
function readOption(name) {
const prefix = `${name}=`;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === name) {
const value = args[i + 1];
return value && !value.startsWith('--') ? value : undefined;
}
if (arg.startsWith(prefix)) return arg.slice(prefix.length) || undefined;
}
return undefined;
}
function hasOption(name) {
return readOption(name) !== undefined;
}
function validateLauncherArgs() {
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const [name, inlineValue] = arg.split('=', 2);
if (!serveOptionNames.has(name)) {
throw new Error(`Unsupported daemon-dev option: ${arg}`);
}
if (arg === '--require-auth') continue;
if (arg.includes('=')) {
if (!inlineValue) throw new Error(`${name} requires a value.`);
continue;
}
const value = args[i + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${arg} requires a value.`);
}
i += 1;
}
}
function serveArgsFromLauncherArgs() {
const result = ['serve'];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const name = arg.split('=', 1)[0];
if (!serveOptionNames.has(name)) continue;
result.push(arg);
if (!arg.includes('=') && arg !== '--require-auth') {
result.push(args[i + 1]);
i += 1;
}
}
return result;
}
function daemonUrl(hostname, port) {
const host =
hostname.includes(':') && !hostname.startsWith('[')
? `[${hostname}]`
: hostname;
return `http://${host}:${port}`;
}
const MAX_PORT_ATTEMPTS = 10;
function findAvailablePort(host, startPort) {
return new Promise((resolveFind, rejectFind) => {
let attempt = 0;
const tryNext = () => {
const port = startPort + attempt;
if (port > 65535 || attempt >= MAX_PORT_ATTEMPTS) {
rejectFind(
new Error(
`No available port found in range ${startPort}${Math.min(startPort + MAX_PORT_ATTEMPTS - 1, 65535)}`,
),
);
return;
}
const probe = net.createServer();
probe.once('error', (err) => {
probe.close();
if (err.code === 'EADDRINUSE') {
console.log(
`[daemon-dev] port ${port} is in use, trying ${port + 1}...`,
);
attempt++;
tryNext();
} else {
rejectFind(err);
}
});
probe.listen(port, host, () => {
probe.close(() => resolveFind(port));
});
};
tryNext();
});
}
function spawnDevProcess(label, command, commandArgs, options) {
const child = spawn(command, commandArgs, {
stdio: 'inherit',
shell: options.shell ?? false,
detached: !isWin,
...options,
});
child.on('error', (err) => {
console.error(`[${label}] failed to start: ${err.message}`);
shutdown(1);
});
child.on('close', (code, signal) => {
if (shuttingDown) return;
if (signal) {
console.error(`[${label}] exited by signal ${signal}`);
shutdown(1);
return;
}
if (code !== 0) {
console.error(`[${label}] exited with code ${code}`);
}
shutdown(code ?? 0);
});
children.push(child);
return child;
}
function killChild(child) {
if (child.killed) return;
try {
if (!isWin && child.pid) {
process.kill(-child.pid, 'SIGTERM');
} else {
child.kill();
}
} catch {
child.kill();
}
}
function waitForDaemon(url) {
const healthUrl = new URL('/health', url);
const deadline = Date.now() + 30_000;
return new Promise((resolve, reject) => {
const check = () => {
let retried = false;
const retryOnce = () => {
if (retried) return;
retried = true;
retry();
};
const req = http.get(healthUrl, (res) => {
res.resume();
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve();
return;
}
retryOnce();
});
req.on('error', retryOnce);
req.setTimeout(1_000, () => {
req.destroy();
retryOnce();
});
};
const retry = () => {
if (shuttingDown) return;
if (Date.now() >= deadline) {
reject(new Error(`Timed out waiting for ${healthUrl.href}`));
return;
}
setTimeout(check, 250);
};
check();
});
}
function shutdown(code) {
if (shuttingDown) return;
shuttingDown = true;
let pending = 0;
for (const child of children) {
if (child.exitCode !== null || child.signalCode !== null) continue;
pending += 1;
child.on('close', () => {
pending -= 1;
if (pending <= 0) process.exit(code);
});
killChild(child);
}
if (pending === 0) process.exit(code);
setTimeout(() => process.exit(code), 5_000).unref();
}
const children = [];
let shuttingDown = false;
try {
validateLauncherArgs();
} catch (err) {
console.error(
`[daemon-dev] ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
const hostname = readOption('--hostname') || '127.0.0.1';
const rawPort = readOption('--port') || '4170';
const startPort = parseInt(rawPort, 10);
if (!Number.isInteger(startPort) || startPort < 0 || startPort > 65535) {
console.error(
`daemon-dev: --port must be an integer 065535, got "${rawPort}".`,
);
process.exit(1);
}
if (startPort === 0) {
console.error(
'daemon-dev: --port 0 is not supported; the launcher needs a fixed port to poll for health.',
);
process.exit(1);
}
const probeHostname =
hostname.startsWith('[') && hostname.endsWith(']')
? hostname.slice(1, -1)
: hostname;
const port = hasOption('--port')
? String(startPort)
: String(await findAvailablePort(probeHostname, startPort));
const token =
readOption('--token') ||
process.env.QWEN_SERVER_TOKEN ||
crypto.randomBytes(16).toString('hex');
const workspace = resolve(readOption('--workspace') || process.cwd());
const serveArgs = serveArgsFromLauncherArgs();
if (!hasOption('--port')) serveArgs.push('--port', port);
if (!hasOption('--workspace')) serveArgs.push('--workspace', workspace);
const tsxLoaderUrl = pathToFileURL(
join(root, 'node_modules', 'tsx', 'dist', 'esm', 'index.mjs'),
).href;
const nodeOptions = [process.env.NODE_OPTIONS, `--import ${tsxLoaderUrl}`]
.filter(Boolean)
.join(' ');
const serveEnv = {
...process.env,
QWEN_SERVER_TOKEN: token,
QWEN_CODE_NO_RELAUNCH: 'true',
NODE_OPTIONS: nodeOptions,
};
const webEnv = {
...process.env,
QWEN_DAEMON_URL: daemonUrl(hostname, port),
};
console.log(`qwen daemon dev`);
console.log(` daemon: ${webEnv.QWEN_DAEMON_URL}`);
console.log(` workspace: ${workspace}`);
console.log(
` web-shell: http://localhost:5173/ (token: ${token.slice(0, 4)}...)`,
);
console.log('');
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
spawnDevProcess('daemon', 'node', ['scripts/dev.js', ...serveArgs], {
cwd: root,
env: serveEnv,
});
waitForDaemon(webEnv.QWEN_DAEMON_URL)
.then(() => {
spawnDevProcess(
'web-shell',
'npm',
[
'run',
'dev',
'--workspace=packages/web-shell',
'--',
'--open',
`/?token=${encodeURIComponent(token)}`,
'--strictPort',
],
{
cwd: root,
env: webEnv,
shell: isWin,
},
);
})
.catch((err) => {
console.error(`[daemon] ${err.message}`);
shutdown(1);
});