benchmark-npm-resolution.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551
  1. /** Benchmark npm's dependency-tree resolution against an all-local registry. */
  2. import { execFileSync, spawn, spawnSync, type ChildProcess } from 'node:child_process'
  3. import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  4. import { createServer, type Server } from 'node:http'
  5. import { tmpdir } from 'node:os'
  6. import { join, resolve } from 'node:path'
  7. import { performance } from 'node:perf_hooks'
  8. import { parseArgs } from 'node:util'
  9. const TARGET_PACKAGE = '@deepseek-ai/dsh'
  10. const DEFAULT_TIMEOUT_MS = 300_000
  11. const TERMINATION_GRACE_MS = 1_000
  12. const FORCED_EXIT_TIMEOUT_MS = 5_000
  13. const WORKSPACE_MANIFEST_GLOBS = [
  14. 'apps/*/package.json',
  15. 'packages/*/*/package.json',
  16. 'vendor/*/package.json',
  17. 'native/landlock-run/package.json',
  18. 'native/landlock-run/packages/*/package.json',
  19. ]
  20. const INSTALLED_MANIFEST_GLOBS = [
  21. 'node_modules/.pnpm/*/node_modules/*/package.json',
  22. 'node_modules/.pnpm/*/node_modules/@*/*/package.json',
  23. ]
  24. const PUBLISHED_FIELDS = [
  25. 'dependencies',
  26. 'optionalDependencies',
  27. 'peerDependencies',
  28. 'peerDependenciesMeta',
  29. 'engines',
  30. 'os',
  31. 'cpu',
  32. 'bin',
  33. ] as const
  34. interface PackageManifest {
  35. readonly name?: unknown
  36. readonly version?: unknown
  37. readonly dependencies?: Record<string, string>
  38. readonly optionalDependencies?: Record<string, string>
  39. readonly peerDependencies?: Record<string, string>
  40. readonly peerDependenciesMeta?: Record<string, unknown>
  41. readonly engines?: unknown
  42. readonly os?: unknown
  43. readonly cpu?: unknown
  44. readonly bin?: unknown
  45. }
  46. interface RegistryVersion extends PackageManifest {
  47. readonly name: string
  48. readonly version: string
  49. }
  50. /** Package versions served by the local benchmark registry. */
  51. export type RegistryIndex = ReadonlyMap<string, ReadonlyMap<string, RegistryVersion>>
  52. /** Parsed command-line options for one benchmark invocation. */
  53. export interface BenchmarkOptions {
  54. readonly ref?: string
  55. readonly runs: number
  56. readonly timeoutMs: number
  57. readonly maxMs?: number
  58. }
  59. /** One measured npm resolution. */
  60. export interface BenchmarkRun {
  61. readonly durationMs: number
  62. readonly registryRequests: number
  63. readonly archiveRequests: number
  64. readonly unknownPackages: readonly string[]
  65. }
  66. /** Published-package fields retained in npm's package-lock layout. */
  67. export interface NpmLockPackage {
  68. readonly name?: string
  69. readonly version?: string
  70. readonly dependencies?: Readonly<Record<string, string>>
  71. readonly optionalDependencies?: Readonly<Record<string, string>>
  72. readonly peerDependencies?: Readonly<Record<string, string>>
  73. readonly peerDependenciesMeta?: Readonly<Record<string, { readonly optional?: boolean }>>
  74. }
  75. /** The installed paths selected by npm without materializing package archives. */
  76. export interface NpmPackageLock {
  77. readonly lockfileVersion: number
  78. readonly packages: Readonly<Record<string, NpmLockPackage>>
  79. }
  80. /** npm resolution observations together with its computed install layout. */
  81. export interface NpmPackageLockResolution extends BenchmarkRun {
  82. readonly packageLock: NpmPackageLock
  83. }
  84. /** Parse one positive-integer command-line option or use its default. */
  85. export function parsePositiveIntegerOption(raw: string | undefined, fallback: number, name: string): number {
  86. if (raw === undefined) return fallback
  87. const value = Number.parseInt(raw, 10)
  88. if (!Number.isSafeInteger(value) || value < 1 || String(value) !== raw) {
  89. throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`)
  90. }
  91. return value
  92. }
  93. /**
  94. * Parse supported benchmark arguments.
  95. * @param args - Command-line arguments after the script path.
  96. * @returns Validated benchmark options.
  97. */
  98. export function parseBenchmarkOptions(args: readonly string[]): BenchmarkOptions {
  99. const normalized = args[0] === '--' ? args.slice(1) : args
  100. const { values } = parseArgs({
  101. args: [...normalized],
  102. options: {
  103. ref: { type: 'string' },
  104. runs: { type: 'string' },
  105. 'timeout-ms': { type: 'string' },
  106. 'max-ms': { type: 'string' },
  107. },
  108. allowPositionals: false,
  109. })
  110. const maxMs = values['max-ms'] === undefined
  111. ? undefined
  112. : parsePositiveIntegerOption(values['max-ms'], 0, '--max-ms')
  113. return {
  114. runs: parsePositiveIntegerOption(values.runs, 1, '--runs'),
  115. timeoutMs: parsePositiveIntegerOption(values['timeout-ms'], DEFAULT_TIMEOUT_MS, '--timeout-ms'),
  116. ...(values.ref === undefined ? {} : { ref: values.ref }),
  117. ...(maxMs === undefined ? {} : { maxMs }),
  118. }
  119. }
  120. function workspaceManifestPath(path: string): boolean {
  121. return /^(?:apps\/[^/]+|packages\/[^/]+\/[^/]+|vendor\/[^/]+|native\/landlock-run(?:\/packages\/[^/]+)?)\/package\.json$/.test(path)
  122. }
  123. function workspaceManifestPaths(root: string, ref: string | undefined): string[] {
  124. if (ref === undefined) return globSync(WORKSPACE_MANIFEST_GLOBS, { cwd: root }).sort()
  125. return execFileSync('git', ['ls-tree', '-r', '--name-only', ref, '--', 'apps', 'packages', 'vendor', 'native'], {
  126. cwd: root,
  127. encoding: 'utf8',
  128. }).split('\n').filter(workspaceManifestPath).sort()
  129. }
  130. function readGitFiles(root: string, ref: string, paths: readonly string[]): ReadonlyMap<string, string> {
  131. const output = execFileSync('git', ['cat-file', '--batch'], {
  132. cwd: root,
  133. input: paths.map(path => `${ref}:${path}\n`).join(''),
  134. maxBuffer: 64 * 1024 * 1024,
  135. })
  136. const contents = new Map<string, string>()
  137. let offset = 0
  138. for (const path of paths) {
  139. const headerEnd = output.indexOf(0x0a, offset)
  140. if (headerEnd < 0) throw new Error(`git cat-file returned no header for ${ref}:${path}`)
  141. const header = output.subarray(offset, headerEnd).toString('utf8')
  142. if (header.endsWith(' missing')) throw new Error(`git ref ${ref} has no ${path}`)
  143. const size = Number.parseInt(header.split(' ')[2] ?? '', 10)
  144. if (!Number.isSafeInteger(size) || size < 0) {
  145. throw new Error(`git cat-file returned an invalid size for ${ref}:${path}`)
  146. }
  147. const contentStart = headerEnd + 1
  148. const contentEnd = contentStart + size
  149. if (output[contentEnd] !== 0x0a) throw new Error(`git cat-file truncated ${ref}:${path}`)
  150. contents.set(path, output.subarray(contentStart, contentEnd).toString('utf8'))
  151. offset = contentEnd + 1
  152. }
  153. return contents
  154. }
  155. /**
  156. * Convert a workspace protocol range to the range published by pnpm pack.
  157. * @param range - Dependency range from a workspace manifest.
  158. * @param targetVersion - Current version of the referenced workspace package.
  159. * @returns The registry-facing semver range.
  160. */
  161. export function publishWorkspaceRange(range: string, targetVersion: string): string {
  162. if (range === 'workspace:*') return targetVersion
  163. if (range === 'workspace:^') return `^${targetVersion}`
  164. if (range === 'workspace:~') return `~${targetVersion}`
  165. if (range.startsWith('workspace:')) return range.slice('workspace:'.length)
  166. return range
  167. }
  168. function copyPublishedManifest(
  169. source: PackageManifest,
  170. workspaceVersions: ReadonlyMap<string, string>,
  171. ): RegistryVersion | undefined {
  172. if (typeof source.name !== 'string' || typeof source.version !== 'string') return undefined
  173. const output: Record<string, unknown> = { name: source.name, version: source.version }
  174. for (const field of PUBLISHED_FIELDS) {
  175. const value = source[field]
  176. if (value === undefined) continue
  177. if (field === 'dependencies' || field === 'optionalDependencies' || field === 'peerDependencies') {
  178. output[field] = Object.fromEntries(Object.entries(value as Record<string, string>).map(([name, range]) => {
  179. const targetVersion = workspaceVersions.get(name)
  180. return [name, targetVersion === undefined ? range : publishWorkspaceRange(range, targetVersion)]
  181. }))
  182. } else {
  183. output[field] = structuredClone(value)
  184. }
  185. }
  186. return output as unknown as RegistryVersion
  187. }
  188. function addManifest(index: Map<string, Map<string, RegistryVersion>>, manifest: RegistryVersion): void {
  189. const versions = index.get(manifest.name) ?? new Map<string, RegistryVersion>()
  190. versions.set(manifest.version, manifest)
  191. index.set(manifest.name, versions)
  192. }
  193. /**
  194. * Build registry metadata from installed external packages and workspace manifests.
  195. * @param root - Repository root containing the pnpm virtual store.
  196. * @param ref - Optional Git ref used instead of working-tree workspace manifests.
  197. * @returns Package metadata served by the benchmark registry.
  198. */
  199. export function buildRegistryIndex(root: string, ref?: string): RegistryIndex {
  200. const index = new Map<string, Map<string, RegistryVersion>>()
  201. for (const path of globSync(INSTALLED_MANIFEST_GLOBS, { cwd: root }).sort()) {
  202. const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as PackageManifest
  203. const copied = copyPublishedManifest(manifest, new Map())
  204. if (copied !== undefined) addManifest(index, copied)
  205. }
  206. const paths = workspaceManifestPaths(root, ref)
  207. const refContents = ref === undefined ? undefined : readGitFiles(root, ref, paths)
  208. const workspace = paths.map(path =>
  209. JSON.parse(refContents?.get(path) ?? readFileSync(resolve(root, path), 'utf8')) as PackageManifest)
  210. const workspaceVersions = new Map(workspace.flatMap(manifest =>
  211. typeof manifest.name === 'string' && typeof manifest.version === 'string'
  212. ? [[manifest.name, manifest.version] as const]
  213. : []))
  214. for (const manifest of workspace) {
  215. const copied = copyPublishedManifest(manifest, workspaceVersions)
  216. if (copied !== undefined) addManifest(index, copied)
  217. }
  218. return index
  219. }
  220. function latestVersion(versions: ReadonlyMap<string, RegistryVersion>): string {
  221. const sorted = [...versions.keys()].sort((left, right) => left.localeCompare(right, 'en', { numeric: true }))
  222. const latest = sorted.at(-1)
  223. if (latest === undefined) throw new Error('local registry package has no versions')
  224. return latest
  225. }
  226. function listen(server: Server): Promise<number> {
  227. return new Promise((resolveListen, reject) => {
  228. server.once('error', reject)
  229. server.listen(0, '127.0.0.1', () => {
  230. server.off('error', reject)
  231. const address = server.address()
  232. if (address === null || typeof address === 'string') {
  233. reject(new Error('local registry did not expose a TCP port'))
  234. return
  235. }
  236. resolveListen(address.port)
  237. })
  238. })
  239. }
  240. function close(server: Server): Promise<void> {
  241. return new Promise((resolveClose, reject) => {
  242. server.close((error) => {
  243. if (error === undefined) resolveClose()
  244. else reject(error)
  245. })
  246. })
  247. }
  248. function npmExecutable(): string {
  249. return process.platform === 'win32' ? 'npm.cmd' : 'npm'
  250. }
  251. function delay(ms: number): Promise<void> {
  252. return new Promise(resolveDelay => setTimeout(resolveDelay, ms))
  253. }
  254. function signalProcessTree(child: ChildProcess, signal: 'SIGTERM' | 'SIGKILL'): void {
  255. if (child.pid === undefined) {
  256. child.kill(signal)
  257. return
  258. }
  259. if (process.platform === 'win32') {
  260. const force = signal === 'SIGKILL' ? ['/F'] : []
  261. const result = spawnSync('taskkill', ['/PID', String(child.pid), '/T', ...force], {
  262. stdio: 'ignore',
  263. windowsHide: true,
  264. })
  265. if (result.error !== undefined) throw result.error
  266. if (result.status !== 0 && child.exitCode === null && child.signalCode === null) child.kill(signal)
  267. return
  268. }
  269. try {
  270. process.kill(-child.pid, signal)
  271. } catch (error) {
  272. if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
  273. }
  274. }
  275. /**
  276. * Run one command with bounded process-tree termination after its deadline.
  277. * @param command - Executable path or name.
  278. * @param args - Arguments passed without shell interpolation on POSIX.
  279. * @param options - Working directory, environment, timeout, and termination grace.
  280. * @returns Exit facts, captured output, duration, and whether timeout handling began.
  281. */
  282. export async function runCommandWithTimeout(
  283. command: string,
  284. args: readonly string[],
  285. options: {
  286. readonly cwd: string
  287. readonly env: NodeJS.ProcessEnv
  288. readonly timeoutMs: number
  289. readonly terminationGraceMs?: number
  290. },
  291. ): Promise<{ status: number | null; signal: NodeJS.Signals | null; durationMs: number; output: string; timedOut: boolean }> {
  292. const started = performance.now()
  293. const child = spawn(command, [...args], {
  294. cwd: options.cwd,
  295. detached: process.platform !== 'win32',
  296. env: options.env,
  297. shell: process.platform === 'win32',
  298. stdio: ['ignore', 'pipe', 'pipe'],
  299. })
  300. let output = ''
  301. child.stdout.setEncoding('utf8')
  302. child.stderr.setEncoding('utf8')
  303. child.stdout.on('data', (chunk) => { output += String(chunk) })
  304. child.stderr.on('data', (chunk) => { output += String(chunk) })
  305. const exited = new Promise<{ status: number | null; signal: NodeJS.Signals | null }>((resolveExit, reject) => {
  306. child.once('error', reject)
  307. child.once('close', (status, signal) => { resolveExit({ status, signal }) })
  308. })
  309. let timeout: NodeJS.Timeout | undefined
  310. try {
  311. const first = await Promise.race([
  312. exited.then(outcome => ({ type: 'exit' as const, outcome })),
  313. new Promise<{ type: 'timeout' }>((resolveTimeout) => {
  314. timeout = setTimeout(() => { resolveTimeout({ type: 'timeout' }) }, options.timeoutMs)
  315. }),
  316. ])
  317. if (first.type === 'exit') {
  318. return { ...first.outcome, durationMs: performance.now() - started, output, timedOut: false }
  319. }
  320. signalProcessTree(child, 'SIGTERM')
  321. await delay(options.terminationGraceMs ?? TERMINATION_GRACE_MS)
  322. signalProcessTree(child, 'SIGKILL')
  323. const forced = await Promise.race([
  324. exited,
  325. delay(FORCED_EXIT_TIMEOUT_MS).then(() => undefined),
  326. ])
  327. if (forced === undefined) throw new Error('timed-out process tree did not exit after SIGKILL')
  328. return { ...forced, durationMs: performance.now() - started, output, timedOut: true }
  329. } finally {
  330. if (timeout !== undefined) clearTimeout(timeout)
  331. }
  332. }
  333. function readNpmPackageLock(path: string): NpmPackageLock {
  334. const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
  335. if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
  336. throw new Error('npm produced an invalid package-lock.json')
  337. }
  338. const { lockfileVersion, packages } = parsed as Record<string, unknown>
  339. if (!Number.isSafeInteger(lockfileVersion) || packages === null
  340. || typeof packages !== 'object' || Array.isArray(packages)) {
  341. throw new Error('npm produced an invalid package-lock.json')
  342. }
  343. return parsed as NpmPackageLock
  344. }
  345. async function runNpm(
  346. cwd: string,
  347. registry: string,
  348. timeoutMs: number,
  349. ): Promise<{ durationMs: number; output: string; timedOut: boolean }> {
  350. const npmrc = join(cwd, '.npmrc')
  351. const globalNpmrc = join(cwd, '.npmrc-global')
  352. writeFileSync(npmrc, `registry=${registry}\n@deepseek-ai:registry=${registry}\n`)
  353. writeFileSync(globalNpmrc, '')
  354. const inheritedEnvironment = Object.fromEntries(Object.entries(process.env)
  355. .filter(([name]) => !name.toLowerCase().startsWith('npm_config_')))
  356. const result = await runCommandWithTimeout(npmExecutable(), [
  357. 'install',
  358. '--package-lock-only',
  359. '--ignore-scripts',
  360. '--no-audit',
  361. '--no-fund',
  362. '--loglevel=error',
  363. '--include=peer',
  364. '--install-strategy=hoisted',
  365. '--legacy-peer-deps=false',
  366. `--registry=${registry}`,
  367. ], {
  368. cwd,
  369. env: {
  370. ...inheritedEnvironment,
  371. npm_config_cache: join(cwd, '.npm-cache'),
  372. npm_config_globalconfig: globalNpmrc,
  373. npm_config_userconfig: npmrc,
  374. npm_config_update_notifier: 'false',
  375. },
  376. timeoutMs,
  377. })
  378. if (result.timedOut) return result
  379. if (result.status !== 0) {
  380. throw new Error(`npm install exited ${String(result.status)} after ${result.durationMs.toFixed(0)} ms\n${result.output.trim()}`)
  381. }
  382. return result
  383. }
  384. /**
  385. * Ask npm to compute an install layout without downloading package archives.
  386. * @param index - Package metadata exposed through the local registry.
  387. * @param dependencies - Root dependencies whose install layout npm computes.
  388. * @param timeoutMs - Hard wall-clock limit for the npm child process.
  389. * @returns The package lock plus timing and registry-request observations.
  390. */
  391. export async function resolveNpmPackageLock(
  392. index: RegistryIndex,
  393. dependencies: Readonly<Record<string, string>>,
  394. timeoutMs: number,
  395. ): Promise<NpmPackageLockResolution> {
  396. let registryRequests = 0
  397. let archiveRequests = 0
  398. const unknownPackages = new Set<string>()
  399. let registry = ''
  400. const server = createServer((request, response) => {
  401. registryRequests++
  402. const pathname = new URL(request.url ?? '/', registry).pathname
  403. if (pathname.startsWith('/tarballs/')) {
  404. archiveRequests++
  405. response.writeHead(500, { 'content-type': 'application/json' })
  406. response.end(JSON.stringify({ error: 'package-lock-only benchmark requested an archive' }))
  407. return
  408. }
  409. const name = decodeURIComponent(pathname.slice(1))
  410. const versions = index.get(name)
  411. if (versions === undefined) {
  412. unknownPackages.add(name)
  413. response.writeHead(404, { 'content-type': 'application/json' })
  414. response.end(JSON.stringify({ error: 'not_found' }))
  415. return
  416. }
  417. const materialized = Object.fromEntries([...versions].map(([version, manifest]) => [version, {
  418. ...manifest,
  419. dist: { tarball: `${registry}tarballs/${encodeURIComponent(name)}-${version}.tgz` },
  420. }]))
  421. const body = JSON.stringify({
  422. name,
  423. 'dist-tags': { latest: latestVersion(versions) },
  424. versions: materialized,
  425. })
  426. response.writeHead(200, {
  427. 'content-type': 'application/json',
  428. 'content-length': Buffer.byteLength(body),
  429. })
  430. response.end(body)
  431. })
  432. const port = await listen(server)
  433. registry = `http://127.0.0.1:${String(port)}/`
  434. const consumer = mkdtempSync(join(tmpdir(), 'dsh-npm-resolution-'))
  435. try {
  436. writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({
  437. name: 'dsh-npm-resolution-benchmark',
  438. version: '0.0.0',
  439. private: true,
  440. dependencies,
  441. }, null, 2)}\n`)
  442. const result = await runNpm(consumer, registry, timeoutMs)
  443. if (result.timedOut) throw new Error(`npm resolution exceeded ${String(timeoutMs)} ms`)
  444. return {
  445. durationMs: result.durationMs,
  446. registryRequests,
  447. archiveRequests,
  448. unknownPackages: [...unknownPackages].sort(),
  449. packageLock: readNpmPackageLock(join(consumer, 'package-lock.json')),
  450. }
  451. } finally {
  452. server.closeAllConnections()
  453. await close(server)
  454. rmSync(consumer, { recursive: true, force: true })
  455. }
  456. }
  457. /**
  458. * Resolve the CLI install graph once without downloading package archives.
  459. * @param index - Package metadata exposed through the local registry.
  460. * @param targetVersion - Version of `@deepseek-ai/dsh` to install.
  461. * @param timeoutMs - Hard wall-clock limit for the npm child process.
  462. * @returns Timing and registry-request observations.
  463. */
  464. export async function benchmarkNpmResolution(
  465. index: RegistryIndex,
  466. targetVersion: string,
  467. timeoutMs: number,
  468. ): Promise<BenchmarkRun> {
  469. const result = await resolveNpmPackageLock(index, { [TARGET_PACKAGE]: targetVersion }, timeoutMs)
  470. return {
  471. durationMs: result.durationMs,
  472. registryRequests: result.registryRequests,
  473. archiveRequests: result.archiveRequests,
  474. unknownPackages: result.unknownPackages,
  475. }
  476. }
  477. async function main(): Promise<void> {
  478. const options = parseBenchmarkOptions(process.argv.slice(2))
  479. const root = resolve(import.meta.dirname, '..')
  480. const started = performance.now()
  481. const index = buildRegistryIndex(root, options.ref)
  482. const targetVersions = index.get(TARGET_PACKAGE)
  483. if (targetVersions === undefined) throw new Error(`local registry contains no ${TARGET_PACKAGE}`)
  484. const targetVersion = latestVersion(targetVersions)
  485. const npmVersion = execFileSync(npmExecutable(), ['--version'], { encoding: 'utf8' }).trim()
  486. console.log(
  487. `benchmark-npm-resolution: npm ${npmVersion}, ${options.ref === undefined ? 'working tree' : options.ref}, `
  488. + `${String(index.size)} package name(s), setup ${(performance.now() - started).toFixed(0)} ms.`,
  489. )
  490. const durations: number[] = []
  491. for (let run = 1; run <= options.runs; run++) {
  492. const result = await benchmarkNpmResolution(index, targetVersion, options.timeoutMs)
  493. durations.push(result.durationMs)
  494. console.log(
  495. `benchmark-npm-resolution: run ${String(run)}/${String(options.runs)} resolved ${TARGET_PACKAGE}@${targetVersion}`
  496. + ` in ${(result.durationMs / 1000).toFixed(2)} s with ${String(result.registryRequests)} metadata request(s)`
  497. + ` and ${String(result.unknownPackages.length)} local 404 package name(s).`,
  498. )
  499. if (result.archiveRequests > 0) throw new Error('npm requested package archives during the metadata-only benchmark')
  500. }
  501. const minimum = Math.min(...durations)
  502. const maximum = Math.max(...durations)
  503. console.log(
  504. `benchmark-npm-resolution: ${String(options.runs)} run(s), min ${(minimum / 1000).toFixed(2)} s, max ${(maximum / 1000).toFixed(2)} s.`,
  505. )
  506. if (options.maxMs !== undefined && maximum > options.maxMs) {
  507. throw new Error(`npm resolution exceeded --max-ms=${String(options.maxMs)} (max ${maximum.toFixed(0)} ms)`)
  508. }
  509. }
  510. if (import.meta.main) {
  511. try {
  512. await main()
  513. } catch (error) {
  514. console.error(`benchmark-npm-resolution: ${error instanceof Error ? error.message : String(error)}`)
  515. process.exitCode = 1
  516. }
  517. }