qwen-code/scripts/dev.js
yao f9080e44fb
fix(cli,core): harden OOM prevention — idempotent compaction tests, explicit GC, debug log defaults (#4914)
* test(cli): add compactOldItems idempotency regression tests

Cover the scenario fixed in commit 595701096 where already-compacted
tool groups (resultDisplay === UI_COMPACT_CLEARED_MESSAGE) were
incorrectly counted as having real output, causing over-compaction.

Three new test cases:
- Already-compacted groups are not re-compacted; second call is a no-op
- All tool groups already compacted → no-op
- Mixed tool group (some tools real, some cleared) → only groups with
  real output are compacted

* fix(cli,core): enable explicit GC and disable debug log by default

- enableExplicitGC defaults to true, --expose-gc added to start/dev scripts
- isDebugLogFileEnabled() defaults to false (opt-in via QWEN_DEBUG_LOG_FILE=1)
- Add safety tests: trigger_gc only in critical tier, global.gc() only in
  memoryPressureMonitor.ts trigger_gc case

* fix: address R1 review comments for memory pressure monitor

- Replace brittle source-parsing test with behavioral tests for global.gc()
- Export UI_COMPACT_CLEARED_MESSAGE constant and use in tests
- Remove redundant NODE_OPTIONS override from start script
- Add production bin wrapper with --expose-gc for OOM protection
- Remove unused path import from memoryPressureMonitor.test.ts

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix: forward --expose-gc to all deployment modes

Standalone package shims and daemon-spawned sessions (AcpBridge,
httpAcpBridge) were missing --expose-gc, causing explicit GC to
silently fail under critical memory pressure.

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix: forward child process signal in cli-entry wrapper

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>

* fix(cli,channels): filter --inspect flags when forwarding execArgv to daemon children

* fix: make cli-entry.js executable (mode 100755)

* fix(core): reject whitespace-only QWEN_DEBUG_LOG_FILE and add QWEN_MEMORY_ENABLE_GC=0 opt-out

* fix(scripts): include cli-entry.js wrapper in dist package for npm publish

* fix(acp-bridge): forward --expose-gc and filter --inspect in spawnChannel

- Add --expose-gc to getAcpMemoryArgs() so daemon-spawned ACP children
  have global.gc() available for critical memory pressure cleanup
- Filter --inspect/-brk flags from process.execArgv to prevent port
  conflicts in multi-session daemon mode
- Update spawnChannel.test.ts for new getAcpMemoryArgs() return shape

This change was previously in httpAcpBridge.ts but lost during the
daemon refactor merge (#4490) that moved spawn logic to acp-bridge.

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-06-14 10:40:53 +08:00

153 lines
4.1 KiB
JavaScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Development entry point for Qwen Code CLI.
*
* Runs the CLI directly from TypeScript source files without requiring a build step.
* Changes to packages/core or packages/cli are reflected immediately.
*
* Usage: npm run dev -- [args]
* Example: npm run dev -- help
*/
import { spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
writeFileSync,
mkdtempSync,
rmSync,
existsSync,
symlinkSync,
mkdirSync,
} from 'node:fs';
import { tmpdir, platform } from 'node:os';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const cliPackageDir = join(root, 'packages', 'cli');
// Ensure qc-helper bundled skill can find user docs in dev mode.
// In dev, import.meta.url resolves to the source tree, so the bundled skill
// directory is packages/core/src/skills/bundled/qc-helper/. We create a
// symlink from there to docs/users/ so the skill can read docs at runtime.
const qcHelperDocsLink = join(
root,
'packages',
'core',
'src',
'skills',
'bundled',
'qc-helper',
'docs',
);
const userDocsTarget = join(root, 'docs', 'users');
if (existsSync(userDocsTarget) && !existsSync(qcHelperDocsLink)) {
mkdirSync(dirname(qcHelperDocsLink), { recursive: true });
try {
symlinkSync(userDocsTarget, qcHelperDocsLink);
} catch {
// Symlink may fail on some systems; non-critical for dev
}
}
// Entry point for the CLI
const cliEntry = join(cliPackageDir, 'index.ts');
// Create a temporary loader file
const tmpDir = mkdtempSync(join(tmpdir(), 'qwen-dev-'));
const loaderPath = join(tmpDir, 'loader.mjs');
const coreSourcePath = join(root, 'packages', 'core', 'index.ts');
const coreSourceUrl = pathToFileURL(coreSourcePath).href;
const loaderCode = `
import { pathToFileURL } from 'node:url';
const coreSourceUrl = '${coreSourceUrl}';
export function resolve(specifier, context, nextResolve) {
if (specifier === '@qwen-code/qwen-code-core') {
return {
shortCircuit: true,
url: coreSourceUrl,
format: 'module',
};
}
return nextResolve(specifier, context);
}
`;
writeFileSync(loaderPath, loaderCode);
// Create the register script that uses the new register() API
const registerPath = join(tmpDir, 'register.mjs');
const loaderUrl = pathToFileURL(loaderPath).href;
const registerCode = `
import { register } from 'node:module';
import { pathToFileURL } from 'node:url';
register('${loaderUrl}', pathToFileURL('./'));
`;
writeFileSync(registerPath, registerCode);
// Preserve existing NODE_OPTIONS (e.g. VS Code debugger injects --inspect flags via NODE_OPTIONS)
const existingNodeOptions = process.env.NODE_OPTIONS || '';
const importFlag = `--import ${pathToFileURL(registerPath).href}`;
const env = {
...process.env,
DEV: 'true',
CLI_VERSION: 'dev',
NODE_ENV: 'development',
NODE_OPTIONS: `${existingNodeOptions} --expose-gc ${importFlag}`.trim(),
};
// On Windows, use tsx.cmd; on Unix, use tsx directly
const isWin = platform() === 'win32';
const tsxBinName = isWin ? 'tsx.cmd' : 'tsx';
const localTsxCli = join(root, 'node_modules', 'tsx', 'dist', 'cli.mjs');
const localTsxCmd = join(root, 'node_modules', '.bin', tsxBinName);
const hasLocalTsxCli = existsSync(localTsxCli);
const tsxCmd = hasLocalTsxCli
? process.execPath
: existsSync(localTsxCmd)
? localTsxCmd
: tsxBinName;
const tsxArgs = [
...(hasLocalTsxCli ? [localTsxCli] : []),
cliEntry,
...process.argv.slice(2),
];
const useShell = isWin && !hasLocalTsxCli;
const child = spawn(tsxCmd, tsxArgs, {
stdio: 'inherit',
env,
cwd: process.cwd(),
shell: useShell, // Needed only when falling back to tsx.cmd on Windows.
});
child.on('error', (err) => {
console.error('Failed to start dev server:', err.message);
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
process.exit(1);
});
child.on('close', (code) => {
// Cleanup temp directory
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
process.exit(code ?? 0);
});