Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/nine-pigs-laugh.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions benchmarks/intent/startup.bench.ts
Original file line number Diff line number Diff line change
@@ -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<string>): 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}`,
)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('cold start', () => {
bench(
'empty node process (baseline)',
() => {
runNode(['-e', ''])
},
coldStartBenchOptions,
)

bench(
'intent --help',
() => {
runNode([cliPath, '--help'])
},
coldStartBenchOptions,
)
})
4 changes: 2 additions & 2 deletions packages/intent/src/setup/workspace-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions packages/intent/src/shared/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dirent<string>>
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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions packages/intent/src/staleness/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,13 @@ function readLocalVersion(packageDir: string): string | null {
}
}

const NPM_REGISTRY_FETCH_TIMEOUT_MS = 5_000

async function fetchNpmVersion(packageName: string): Promise<string | null> {
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<string, unknown>
Expand Down
Loading