Skip to content

--cache: warm run crashes (RangeError: Invalid array length) — config files re-stored during Map iteration on cached path #1811

Description

Note

Disclaimer: This report — including the root-cause analysis, line references, and suggested fix direction — was produced with the assistance of an AI agent investigating a crash in a private monorepo. The reproduction (cold-OK / warm-crash) and the 6.17.1 / 6.18.0 behaviors described below were observed directly, but the source-level explanation may contain inaccuracies. Please verify before acting on it.

Summary

With --cache, the first (cold) run succeeds but the second (warm) run fails deterministically:

RangeError: Invalid array length
    at Array.push (<anonymous>)
    at WorkspaceWorker.runPlugins (.../knip/dist/WorkspaceWorker.js:407:40)
    at async build (.../knip/dist/graph/build.js:133:35)

Running without --cache always works. This makes --cache unusable in a pre-commit hook: the first commit passes, every subsequent one fails until node_modules/.cache/knip is deleted.

Confirmed on both 6.17.1 (warm run throws the RangeError at ~26s) and 6.18.0 (warm run does not throw — instead it spins at 100% CPU with steadily climbing memory, i.e. the same runaway loop heading toward OOM rather than the array-length cap). Without --cache, both versions complete normally.

Root cause (traced in dist/WorkspaceWorker.js, 6.17.1)

runPlugins accumulates into a single array and drains a per-workspace plugin → config-files map:

const inputs = [];                                     // L221
const remainingPlugins = new Set(this.enabledPlugins); // L222
...
do {
  for (const [pluginName, dependencies] of configFiles) { // L403
    configFiles.delete(pluginName);
    if (this.enabledPlugins.includes(pluginName)) {
      for (const input of await runPlugin(pluginName, Array.from(dependencies)))
        inputs.push(input);                            // L407  <-- throws
    } else {
      for (const id of dependencies) inputs.push(toEntry(id));
    }
  }
} while (remainingPlugins.size > 0 && configFiles.size > 0); // L413

On a warm cache, each config file takes the cached branch, which re-adds the config back into the map being iterated and then continues without updating the seen set that the non-cached path uses to dedupe:

if (fd?.meta?.data && !fd.changed) {                   // L294
  const data = fd.meta.data;
  if (data.resolveConfig) for (const id of data.resolveConfig) addInput(id, configFilePath);
  if (data.resolveFromAST) for (const id of data.resolveFromAST) addInput(id, configFilePath);
  if (data.configFile) addInput(data.configFile);      // re-stores config file
  continue;                                             // L304 — seen never updated (cf. L375-377)
}
const addInput = (input, ...) => {
  if (isConfig(input)) storeConfigFilePath(input.pluginName, {...}); // re-inserts into configFilesMap
  else inputs.push(...);
};

So:

  1. The cached read calls addInput(data.configFile)storeConfigFilePathre-inserts the config-file entry into the same configFilesMap Map that the for…of at L403 is iterating.
  2. Per the JS spec, a Map iterator yields entries inserted during iteration. The re-added entry is visited again → plugin re-runs → re-adds it again → visited again → … The loop never terminates.
  3. On a cold cache the non-cached path updates seen (L375-377), so the cycle is broken and the run completes — exactly why cold passes and warm fails.

Each cycle calls inputs.push(...). On 6.17.1 the array length hits Array.prototype.push's hard ceiling of 2**32 - 1 (4,294,967,295) and throws RangeError: Invalid array length (~26s). On 6.18.0 the same loop instead grows memory without bound (100% CPU, RSS climbing past 2 GB within minutes), so it trends toward OOM before reaching the length cap. Either way the loop never terminates — this is not plugin output volume but the same handful of cached config entries re-processed indefinitely.

Cache evidence

The per-workspace cache (v8.serialized Map) for the affected workspace shows every entry carries a configFile field — the value re-stored on the warm path (only TypeScript + Vitest config files are involved here):

<workspace>/vitest.config.mts => { resolveConfig, configFile }
<workspace>/tsconfig.json      => { resolveConfig, configFile }
<workspace>/tsconfig.app.json  => { resolveConfig, configFile }
<workspace>/tsconfig.spec.json => { resolveConfig, configFile }
<root>/tsconfig.base.json      => { resolveConfig, configFile }

Reproduction

Large Nx + pnpm monorepo (nx: false, ~95 auto-discovered workspaces, includeEntryExports: true, ESLint/Vitest/Playwright/Storybook/Orval plugins). Could not reproduce on a trivial repo; appears to require a workspace whose cached config entries re-store config files.

rm -rf node_modules/.cache/knip
knip --cache --no-progress   # cold -> exit 0
knip --cache --no-progress   # warm -> RangeError (6.17.1) / runaway 100% CPU + memory growth (6.18.0)

--debug on the warm run (the workspace name is the first one where a cached config re-stores a config file):

[apps/<service>] Enabled plugins
RangeError: Invalid array length

Suggested fix direction

The cached branch (L294-304) should participate in the same dedupe/termination bookkeeping as the non-cached branch — e.g. update seen for re-stored config files, and/or guard the for…of at L403 against re-processing entries already handled in the current runPlugins invocation (snapshot the keys, or track processed pluginNames in a local set), so re-insertion during iteration cannot loop forever.

Environment

  • knip: reproduced on 6.17.1 and 6.18.0 (latest)
  • Node: v24.15.0
  • pnpm: 11.1.3
  • OS: macOS

Happy to test a patch/branch or provide more --debug output. The cold-OK / warm-fail cycle is 100% reproducible locally.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions