diff --git a/.changeset/nine-pigs-laugh.md b/.changeset/nine-pigs-laugh.md new file mode 100644 index 00000000..8b1cc55d --- /dev/null +++ b/.changeset/nine-pigs-laugh.md @@ -0,0 +1,9 @@ +--- +'@tanstack/intent': patch +--- + +Fix potential hangs on slow or large environments: + +- Bound the npm registry staleness check with a request timeout so `intent list` and other staleness checks can't hang indefinitely on a slow or unreachable registry. +- Bound global package-manager detection with a command timeout so it can't hang indefinitely when the environment's global `node_modules` is slow to resolve. +- Avoid enumerating a workspace package's entire skill tree just to check whether it has any skills, reducing filesystem work in large monorepos. diff --git a/benchmarks/intent/startup.bench.ts b/benchmarks/intent/startup.bench.ts new file mode 100644 index 00000000..cf0159d2 --- /dev/null +++ b/benchmarks/intent/startup.bench.ts @@ -0,0 +1,42 @@ +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { bench, describe } from 'vitest' + +const cliPath = fileURLToPath( + new URL('../../packages/intent/dist/cli.mjs', import.meta.url), +) + +const coldStartBenchOptions = { + warmupIterations: 20, + time: 3_000, +} + +function runNode(args: Array): void { + const result = spawnSync(process.execPath, args, { + stdio: 'ignore', + timeout: 10_000, + }) + if (result.status !== 0) { + throw new Error( + `spawn ${[process.execPath, ...args].join(' ')} exited with code ${result.status}`, + ) + } +} + +describe('cold start', () => { + bench( + 'empty node process (baseline)', + () => { + runNode(['-e', '']) + }, + coldStartBenchOptions, + ) + + bench( + 'intent --help', + () => { + runNode([cliPath, '--help']) + }, + coldStartBenchOptions, + ) +}) diff --git a/packages/intent/src/setup/workspace-patterns.ts b/packages/intent/src/setup/workspace-patterns.ts index 1285775e..08005eed 100644 --- a/packages/intent/src/setup/workspace-patterns.ts +++ b/packages/intent/src/setup/workspace-patterns.ts @@ -2,7 +2,7 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' import { parse as parseYaml } from 'yaml' -import { findSkillFiles } from '../shared/utils.js' +import { hasAnySkillFile } from '../shared/utils.js' import type { ParseError } from 'jsonc-parser' function normalizeWorkspacePattern(pattern: string): string { @@ -227,7 +227,7 @@ export function getWorkspaceInfo(root: string): WorkspaceInfo | null { const packageDirs = readWorkspacePackageDirs(root) ?? [] const packageDirsWithSkills = packageDirs.filter((dir) => { const skillsDir = join(dir, 'skills') - return existsSync(skillsDir) && findSkillFiles(skillsDir).length > 0 + return existsSync(skillsDir) && hasAnySkillFile(skillsDir) }) const info = { root, diff --git a/packages/intent/src/shared/utils.ts b/packages/intent/src/shared/utils.ts index 2bc8d300..f3d6930e 100644 --- a/packages/intent/src/shared/utils.ts +++ b/packages/intent/src/shared/utils.ts @@ -114,6 +114,30 @@ function collectSkillFiles( } } +/** + * Like `findSkillFiles`, but stops at the first SKILL.md found instead of + * enumerating the whole tree. + */ +export function hasAnySkillFile(dir: string, fs: ReadFs = nodeReadFs): boolean { + let entries: Array> + try { + entries = fs.readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }) + } catch { + return false + } + + for (const entry of entries) { + const fullPath = join(dir, entry.name) + if (entry.isDirectory()) { + if (hasAnySkillFile(fullPath, fs)) return true + } else if (entry.name === 'SKILL.md') { + return true + } + } + + return false +} + /** * Read dependencies and peerDependencies (and optionally devDependencies) from * a parsed package.json object. @@ -232,6 +256,8 @@ export function listNestedNodeModulesPackageDirs( return packageDirs } +const GLOBAL_NODE_MODULES_COMMAND_TIMEOUT_MS = 5_000 + export function detectGlobalNodeModules(packageManager: string): { path: string | null source?: string @@ -267,6 +293,7 @@ export function detectGlobalNodeModules(packageManager: string): { const output = execFileSync(candidate.command, candidate.args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + timeout: GLOBAL_NODE_MODULES_COMMAND_TIMEOUT_MS, }).trim() if (!output) continue diff --git a/packages/intent/src/staleness/check.ts b/packages/intent/src/staleness/check.ts index ec3191be..561b0b3f 100644 --- a/packages/intent/src/staleness/check.ts +++ b/packages/intent/src/staleness/check.ts @@ -90,10 +90,13 @@ function readLocalVersion(packageDir: string): string | null { } } +const NPM_REGISTRY_FETCH_TIMEOUT_MS = 5_000 + async function fetchNpmVersion(packageName: string): Promise { try { const res = await fetch( `https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, + { signal: AbortSignal.timeout(NPM_REGISTRY_FETCH_TIMEOUT_MS) }, ) if (!res.ok) return null const data = (await res.json()) as Record