mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* fix(serve): bound command snapshot replay memory Suppress unchanged command snapshots and retain only the latest full skill details so periodic refreshes cannot grow daemon heap indefinitely. Bound replay rings by serialized bytes while preserving ACP live delivery and reconnect IDs, and expose retention diagnostics. * fix(serve): avoid redundant replay seed serialization Size only retained complete command snapshots when seeding replay history, and forward the replay byte limit through the development daemon launcher. Pin snapshot accounting and lifetime eviction counters with independent regression assertions, and document the count/byte retention bounds and conditional command notifications. Co-authored-by: Qwen-Coder <[email protected]> --------- Co-authored-by: Qwen-Coder <[email protected]>
347 lines
8.8 KiB
JavaScript
347 lines
8.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',
|
||
'--event-ring-max-bytes',
|
||
'--compacted-replay-max-bytes',
|
||
'--initialize-timeout-ms',
|
||
]);
|
||
|
||
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;
|
||
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);
|
||
});
|