Skip to content

[Bug] One-shot checkers spawn unbounded concurrent lint subprocesses on fs bursts #706

@Faithfinder

Description

@Faithfinder

Summary

The one-shot checker family (oxlint, biome, eslint, stylelint) subscribes to chokidar change events and calls exec(lintCommand) directly from every handler, with no debounce, no concurrency cap, no abort of in-flight runs, and no retained child-process handle. A burst of filesystem events — routine with multi-file saves, git operations, formatters-on-save, and especially AI-assisted editing — spawns one full lint subprocess per event. They run concurrently.

The cost scales linearly with burst size. For cheap linters this is wasteful. For oxlint with oxlint-tsgolint, each cold invocation is ~0.5 s / 560 MB RSS even when linting a single file; a 10-file burst produces ~10 concurrent processes, ~5.6 GB peak; a 30-file burst puts the system into memory pressure. Observed end-state on a large TS project: VSCode/Vite tree pushed well past available RAM, macOS force-quit dialog.

Environment

  • vite-plugin-checker 0.13.0 (main is unchanged regarding concurrency as of this writing)
  • Node v22, macOS 26.4 (Apple Silicon), TS project with oxlint-tsgolint enabled
  • checker({ oxlint: { lintCommand: "oxlint", watchPath: "./src" }, ... }) (correctly scoped per-file lint)

Observed behavior

Baseline per-file lint (isolated):

$ /usr/bin/time -l oxlint --format json src/index.tsx
  0.49 real   1.70 user   0.65 sys
  586579968  maximum resident set size   (≈560 MB)

10-file burst via a small writer script (mimics a single AI multi-file edit):

  t+1s  oxlint-oneshots=10  tsgolint-Go=9
  t+2s  oxlint-oneshots= 8  tsgolint-Go=8
  t+3s  oxlint-oneshots= 7  tsgolint-Go=6
  t+4s  oxlint-oneshots= 2  tsgolint-Go=2
  t+5s  oxlint-oneshots= 0  tsgolint-Go=0

10 concurrent oxlint processes + 9 concurrent tsgolint subprocesses, each ~560 MB, all parented to the single Vite process. They eventually drain — no leaks, no zombies — but peak concurrent memory scales linearly with burst size.

Root cause

packages/vite-plugin-checker/src/checkers/oxlint/server.ts:

watcher.on('change', async (filePath) => {
  await handleFileChange(root, options.command, filePath, manager)
  dispatchDiagnostics(...)
})

And cli.ts:

export function runOxlint(command: string, cwd: string) {
  return new Promise<NormalizedDiagnostic[]>((resolve) => {
    exec(command, { cwd, maxBuffer: Number.POSITIVE_INFINITY }, (_error, stdout) => {
      parseOxlintOutput(stdout, cwd).then(resolve).catch(() => resolve([]))
    })
  })
}

No AbortController. No retained ChildProcess. No debounce. No in-flight tracker. grep -r 'AbortController\|debounce\|abort\|kill\|cancel' src/checkers/ finds nothing in oxlint/biome/stylelint/eslint. The gap is shared across the whole one-shot-checker family. typescript uses a long-running tsc --watch and is unaffected.

PR #216 (open since 2023) proposes an opt-in dev.debounce number, for eslint and typescript only. Debounce is necessary but not sufficient: a single burst wider than the debounce window, or sustained bursts crossing window boundaries, still produces concurrent runs.

Why it matters more now

AI-assisted editors (Copilot Edits, Cursor, Claude Code, Aider) routinely write 5–15 files in a single turn, within ~1 s. Combined with format-on-save (2 events per file) and git operations, modern dev sessions produce fs bursts that the "one subprocess per event" pattern was never designed for. Even when each lint is cheap, the work is wasted (the previous result is discarded the moment a new file changes).

Related: #216 (debounce PR, open since 2023), #671 (watch-root fix, merged in 0.13.0).

Reproducer

Vite project with oxlint-tsgolint enabled, vite-plugin-checker in config. Trigger with either (a) any AI editor applying a multi-file change, or (b) a one-liner:

for f in $(ls src/**/*.tsx | head -10); do echo "// $(date +%s)" >> $f & done; wait

Observe via ps -ax | grep tsgolint immediately after: N concurrent tsgolint headless processes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions