qwen-code/scripts/prepare-package.js
callmeYe be31e8493c
perf(filesearch): move AsyncFzf index construction to a worker thread (#4621)
* perf(filesearch): move AsyncFzf index construction to a worker thread

`new AsyncFzf(allFiles, ...)` is misleadingly named — its constructor is
synchronous and dominates the @-picker's main-thread cost on workspaces
with >20k files (where the v2→v1 algorithm switch kicks in). It's the
remaining freeze source after PR #3214 moved the crawl onto async spawn-
based git ls-files / ripgrep / fdir.

This change hosts the AsyncFzf instance in a worker_threads worker so the
construction happens off the main thread; only completed find() results
cross the message channel. Below ~5k files the in-thread path is kept
because worker spawn + IPC overhead exceeds the construction cost there.

Notes:
- `FzfWorkerHandle.create()` awaits the worker `ready` event before
  returning, so init errors surface at create() time rather than as a
  surprise on the first find().
- Worker is `unref()`-ed so a mid-find @-picker doesn't pin process exit.
- `FileSearch` interface gains an optional `dispose?(): Promise<void>`
  to release worker resources. `FileMessageHandler.clearFileSearchCache`
  calls it so the long-running VS Code extension host doesn't accumulate
  workers across watcher-triggered evictions.
- `installInProcessFzfTransport()` is provided for tests / sandboxed
  environments that need to skip worker spawn entirely.
- Bundle gains a second esbuild build that emits `dist/fzfWorker.js`
  alongside `dist/cli.js`; `prepare-package.js` ships it in the tarball.
- In `npm run dev` (tsx) the bundled worker file isn't on disk, so the
  handle silently falls back to in-thread — acceptable for a dev script.

Closes the door on #3455's two open `FileIndexService` review items by
design: there is no `whenReady()` (handle awaits ready inside `create()`)
and there is no in-worker crawl (crawl stays on main, only fzf moves).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(filesearch): address review feedback on fzf worker PR

- Fix worker thread leak in useAtCompletion: dispose previous FileSearch
  instance on re-init and component cleanup
- Fix worker leak on init failure: wrap inst.ready() in try/catch with
  timeout, dispose on failure
- Fix find()-after-dispose hang: reject immediately when worker is disposed
- Add limit field to FindMessage protocol to cap IPC payload before
  postMessage serialization
- Add real worker_threads test coverage (spawn/find/dispose lifecycle,
  concurrent finds, init failure, post-dispose rejection)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(filesearch): address second round of review feedback

- Fix useAtCompletion cleanup: move dispose() to [cwd,config] effect so
  it only fires on workspace change, not every keystroke/status transition
- Clear setTimeout in Promise.race on happy path to prevent timer leak
- Dispose FileSearch instances in FileMessageHandler on panel close
- Fix incorrect comment in prepare-package.js (resolveBundleDir, not URL)
- Add worker crash test: verify failAll rejects pending find() on exit
- Add RecursiveFileSearch.dispose() test in fileSearch.test.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(core): close missing braces in formatDateForContext test

The second test case in the formatDateForContext describe block was
missing its closing `});`, causing a TS1005 parse error that broke CI
on all platforms. This was introduced upstream in 6a99ed150.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(filesearch): guard initialize() against races and stale completions

- Add cancelled flag to initialize() effect so a superseded or
  unmounted init disposes the newly-created searcher instead of
  writing it into fileSearch.current with no owner
- Add fzfWorker.js test artifact to .gitignore to prevent accidental
  commits if the test process crashes before afterAll cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(filesearch): mark worker handle as failed on post-init crash

failAll() previously only set readyState to 'failed' when the worker
crashed during init (state 'pending'). A crash after successful init
left the handle in 'ready' state, causing subsequent find() calls to
silently return empty results via the catch handler. Now failAll()
unconditionally marks the handle as 'failed' (unless already disposed),
and find() rejects immediately on a failed handle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-12 11:47:16 +08:00

217 lines
6.3 KiB
JavaScript

/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Prepares the bundled CLI package for npm publishing
* This script adds publishing metadata (package.json, README, LICENSE) to dist/
* All runtime assets (cli.js, vendor/, *.sb) are already in dist/ from the bundle step
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const defaultRootDir = path.resolve(__dirname, '..');
export function preparePackage({ rootDir = defaultRootDir } = {}) {
const distDir = path.join(rootDir, 'dist');
verifyBundleArtifacts(rootDir, distDir);
copyDocumentationFiles(rootDir, distDir);
copyLocales(rootDir, distDir);
copyExtensionExamples(rootDir, distDir);
writeDistPackageJson(rootDir, distDir);
printPackageStructure(distDir);
}
if (isDirectRun()) {
preparePackage();
}
function isDirectRun() {
return process.argv[1]
? fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
: false;
}
function verifyBundleArtifacts(rootDir, distDir) {
const requiredPaths = [
path.join(distDir, 'cli.js'),
path.join(distDir, 'vendor'),
path.join(distDir, 'bundled', 'qc-helper', 'docs'),
];
if (!fs.existsSync(distDir)) {
console.error('Error: dist/ directory not found');
console.error('Please run "npm run bundle" first');
process.exit(1);
}
for (const requiredPath of requiredPaths) {
if (!fs.existsSync(requiredPath)) {
console.error(
`Error: Required package artifact not found: ${requiredPath}`,
);
console.error('Please run "npm run bundle" first');
process.exit(1);
}
}
}
function copyDocumentationFiles(rootDir, distDir) {
console.log('Copying documentation files...');
const filesToCopy = ['README.md', 'LICENSE'];
for (const file of filesToCopy) {
const sourcePath = path.join(rootDir, file);
const destPath = path.join(distDir, file);
if (fs.existsSync(sourcePath)) {
fs.copyFileSync(sourcePath, destPath);
console.log(`Copied ${file}`);
} else {
console.warn(`Warning: ${file} not found at ${sourcePath}`);
}
}
}
function copyLocales(rootDir, distDir) {
console.log('Copying locales folder...');
const localesSourceDir = path.join(
rootDir,
'packages',
'cli',
'src',
'i18n',
'locales',
);
const localesDestDir = path.join(distDir, 'locales');
if (fs.existsSync(localesSourceDir)) {
copyRecursiveSync(localesSourceDir, localesDestDir);
console.log('Copied locales folder');
} else {
console.warn(`Warning: locales folder not found at ${localesSourceDir}`);
}
}
function copyExtensionExamples(rootDir, distDir) {
console.log('Copying extension examples folder...');
const extensionExamplesDir = path.join(
rootDir,
'packages',
'cli',
'src',
'commands',
'extensions',
'examples',
);
const extensionExamplesDestDir = path.join(distDir, 'examples');
if (fs.existsSync(extensionExamplesDir)) {
copyRecursiveSync(extensionExamplesDir, extensionExamplesDestDir);
console.log('Copied extension examples folder');
} else {
console.warn(
`Warning: extension examples folder not found at ${extensionExamplesDir}`,
);
}
}
function writeDistPackageJson(rootDir, distDir) {
console.log('Creating package.json for distribution...');
const rootPackageJson = JSON.parse(
fs.readFileSync(path.join(rootDir, 'package.json'), 'utf-8'),
);
const distPackageJson = {
name: rootPackageJson.name,
version: rootPackageJson.version,
description:
rootPackageJson.description || 'Qwen Code - AI-powered coding assistant',
repository: rootPackageJson.repository,
type: 'module',
main: 'cli.js',
bin: {
qwen: 'cli.js',
},
files: [
'cli.js',
// Worker thread entry loaded by FzfWorkerHandle at runtime via
// `resolveBundleDir(import.meta.url)` + `path.join(dir, 'fzfWorker.js')`.
// Must ship in the tarball or the @-picker silently falls back to the
// in-thread AsyncFzf path on big workspaces in npm-installed CLIs.
'fzfWorker.js',
'chunks',
'vendor',
'*.sb',
'README.md',
'LICENSE',
'locales',
'examples',
'bundled',
],
config: rootPackageJson.config,
dependencies: {},
optionalDependencies: {
'@lydell/node-pty': '1.2.0-beta.10',
'@lydell/node-pty-darwin-arm64': '1.2.0-beta.10',
'@lydell/node-pty-darwin-x64': '1.2.0-beta.10',
'@lydell/node-pty-linux-x64': '1.2.0-beta.10',
'@lydell/node-pty-win32-arm64': '1.2.0-beta.10',
'@lydell/node-pty-win32-x64': '1.2.0-beta.10',
'@teddyzhu/clipboard': '0.0.5',
'@teddyzhu/clipboard-darwin-arm64': '0.0.5',
'@teddyzhu/clipboard-darwin-x64': '0.0.5',
'@teddyzhu/clipboard-linux-x64-gnu': '0.0.5',
'@teddyzhu/clipboard-linux-arm64-gnu': '0.0.5',
'@teddyzhu/clipboard-win32-x64-msvc': '0.0.5',
'@teddyzhu/clipboard-win32-arm64-msvc': '0.0.5',
},
engines: rootPackageJson.engines,
};
fs.writeFileSync(
path.join(distDir, 'package.json'),
JSON.stringify(distPackageJson, null, 2) + '\n',
);
}
function printPackageStructure(distDir) {
console.log('\n✅ Package prepared for publishing at dist/');
console.log('\nPackage structure:');
// Use Node.js to list directory contents (cross-platform)
const distFiles = fs.readdirSync(distDir);
for (const file of distFiles) {
const filePath = path.join(distDir, file);
const stats = fs.statSync(filePath);
const size = stats.isDirectory() ? '<DIR>' : formatBytes(stats.size);
console.log(` ${size.padEnd(12)} ${file}`);
}
}
function copyRecursiveSync(src, dest) {
const stats = fs.statSync(src);
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
const srcPath = path.join(src, entry);
const destPath = path.join(dest, entry);
copyRecursiveSync(srcPath, destPath);
}
} else {
fs.copyFileSync(src, dest);
}
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}