mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* 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
331 lines
8 KiB
JavaScript
331 lines
8 KiB
JavaScript
/**
|
||
* @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 0–65535, 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);
|
||
});
|