qwen-code/scripts/daemon-dev.js
Shaojin Wen 5c7a5a84e0
fix(scripts): allow multiple dev:daemon instances by probing Vite port (#7212)
* fix(scripts): allow multiple dev:daemon instances by probing Vite port

The Vite dev server port was hardcoded to 5173 with --strictPort, so a
second npm run dev:daemon would crash immediately. Now the launcher
probes for an available port (starting from 5173) and passes it
explicitly via --port, matching how the daemon port is already handled.

* fix(scripts): fail cleanly when the dev Vite/daemon port probe is exhausted

The daemon and Vite port probes were bare top-level awaits, so an
exhausted range (e.g. 5173–5182 all taken by other dev:daemon instances)
rejected as an unhandled promise rejection — a raw stack instead of an
actionable message. Wrap both probes and print the launcher's usual
`[daemon-dev] <message>` before exiting 1, matching the validateLauncherArgs
handling. findAvailablePort already rejects with the exhausted range, so
the message names exactly which ports to free.

* fix(scripts): let Vite handle port selection atomically

Remove the probe-then-bind approach for the Vite port. The probe had a
TOCTOU race (two launchers could pick the same port before either
binds) and a host mismatch (probed 127.0.0.1 while Vite binds
localhost which may resolve to ::1).

Instead, drop --strictPort and let Vite auto-increment from its
configured port 5173. Vite's --open uses the actual bound port, so the
token URL is always correct.

* fix(scripts): don't print a potentially wrong Vite URL in banner

The banner hardcoded http://localhost:5173/ but with --strictPort
removed the actual port may differ. Point at Vite's own output instead,
which always prints the real bound port.

---------

Co-authored-by: wenshao <wenshao@example.com>
2026-07-19 12:49:47 +00:00

344 lines
8.7 KiB
JavaScript
Raw Permalink 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;
let port;
try {
port = hasOption('--port')
? String(startPort)
: String(await findAvailablePort(probeHostname, startPort));
} catch (err) {
console.error(
`[daemon-dev] ${err instanceof Error ? err.message : String(err)}`,
);
process.exit(1);
}
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,
// QWEN_CODE_CLI — the entry a `qwen …` subprocess should call to reach this
// build — is NOT set here: `scripts/dev.js`, which the daemon below is launched
// through, stamps it unconditionally. Setting it here too gave it two writers,
// and this one deferred to an inherited value (`??`) — so a daemon started from
// inside another qwen session's shell pointed every subprocess at the OUTER
// session's CLI: the exact skew the variable exists to prevent, one level up.
};
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: opening in browser (auto-increments from 5173 if busy — see Vite output for the actual URL, 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)}`,
],
{
cwd: root,
env: webEnv,
shell: isWin,
},
);
})
.catch((err) => {
console.error(`[daemon] ${err.message}`);
shutdown(1);
});