Skip to content

ci(lint): fix the intermittent ESLint worker OOM - #312

Merged
lucas77778 merged 4 commits into
masterfrom
xuan/code-468
Jul 29, 2026
Merged

ci(lint): fix the intermittent ESLint worker OOM#312
lucas77778 merged 4 commits into
masterfrom
xuan/code-468

Conversation

@AprilNEA

Copy link
Copy Markdown
Member

Closes CODE-463's blocker. Fixes CODE-468.

Why CI kept dying

pnpm lint runs eslint --concurrency=auto. auto resolves to os.availableParallelism() >> 1, and ESLint creates those workers with no resourceLimits, so each inherits the main thread's heap ceiling. On a public-repo ubuntu-latest (4 vCPU / 16 GB) that is exactly 2 workers, each capped at the workflow's --max-old-space-size=4096.

Every worker builds its own typescript-eslint project-service programs, so the type graph is duplicated per worker rather than split across them. Two workers each need more than 4 GB, and the job sat just under that cliff — which is why it was intermittent (12 failures / 2 successes since 09:00, including a cargo-only bump that passed while its neighbours failed) rather than a clean break. It stayed green on dev machines only because a high-core box resolves auto to many more workers, each with a smaller share.

Measured on this repo

--concurrency --max-old-space-size result wall peak RSS
off 4096 OOM 42 s† 4.9 GB
off 6144 pass 54 s 6.2 GB
2 (= CI's auto) 4096 OOM 49 s† 9.5 GB
2 6144 pass 40 s 11.7 GB
4 4096 pass 33 s 18.0 GB
8 4096 pass 31 s 32.6 GB

† died mid-run. The CI failure reproduces locally at --concurrency=2.

Lowering concurrency is not the fix and raising it does not fit: total RSS scales with worker count because each worker re-materializes the programs, so 16 GB cannot host more than 2 workers, while 2 workers each need more ceiling than they have. Single-threaded also uses less total CPU here (78 s user vs 107–158 s for 2 workers) — the duplicated program building outweighs the parallelism. This matches typescript-eslint's own guidance to disable concurrency for typed linting and lean on --cache instead.

What changed

  • lint:ci — a new script, eslint --concurrency=off --cache --cache-strategy content. pnpm lint / lint:fix keep --concurrency=auto, so local behavior is untouched; only the workflow's Lint step switches.
  • --cache-strategy content is load-bearing. The default metadata strategy keys on mtime+size, and a fresh checkout rewrites every mtime, so it would score a 0 % hit rate in CI. Verified: after touch-ing every .ts/.tsx in the repo, a warm run still finished in 2.25 s.
  • actions/cache on .eslintcache, keyed per run with a restore-keys prefix so each run restores the newest prior entry and writes a fresh one.
  • NODE_OPTIONS 4096 → 6144. Still required: a cold cache walks the whole repo, and single-threaded at 4096 OOMs. It is a ceiling, not an allocation, so other jobs are unaffected in practice.

Cold vs warm, both at the CI ceiling: 53.7 s / 6.2 GB2.4 s / 0.44 GB, same 344 findings.

Cache correctness

ESLint invalidates an entry when the file's content hash changes or when its resolved config hash changes — and that config hash folds in the ESLint and Node versions — so the cache key deliberately doesn't try to encode any of that. Verified by adding a debugger; to a cached file: the cached run reported 345 problems / 1 error, matching an uncached control run of the same file.

One real limitation to be aware of: the cache tracks each file's own content, not cross-file type dependencies. With type-aware rules, editing file A can change what a rule should report in an unchanged file B, and B's entry stays valid. pnpm typecheck runs uncached in the same job, so genuine type breakage is still caught; what could be missed is a type-derived lint finding in a file nobody touched. Worth revisiting if that ever bites.

Second, unrelated breakage in the same job

With Lint fixed, the job runs far enough to reach Test, which then failed on packages/host/assets — the committed pi closure manifest no longer matched the lockfile after chore(deps): bump ws from 7.5.11 to 8.21.1 (#289). Lint was failing first and short-circuiting the job, so this never surfaced on CI. Regenerated via pnpm -F @linkcode/assets run generate:pi-closure (second commit); the real diff is three nested ws@8.21.0 entries.

Verification

pnpm check:ci exit 0 and pnpm test 2056 passed / 5 skipped, both after the regeneration. The cache behavior above was driven directly rather than inferred.

@linear-code

linear-code Bot commented Jul 29, 2026

Copy link
Copy Markdown

CODE-463

CODE-468

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 502bb460ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread package.json
@AprilNEA

Copy link
Copy Markdown
Member Author

Heads-up on the merge just pushed: master picked up --concurrency=2 for lint / lint:fix in f763ea65 (rode in on #299, unrelated to that PR's feature). I resolved the conflict against it, because measurement says it is the worst of the three options:

  • On CI it changes nothing. auto already resolved to availableParallelism() >> 1 = 2 on a 4-vCPU public-repo runner, so pinning 2 reproduces the exact configuration that OOMs. That is the config I reproduced the CI failure with locally.

  • It breaks lint on dev machines. auto on a high-core box picks many workers, each with a small share; forcing 2 makes each worker carry half the repo's type graph. Just verified on a 128 GB / 18-core Mac with no NODE_OPTIONS set:

    $ eslint --concurrency=2 --format=sukka .
    Error [ERR_WORKER_OUT_OF_MEMORY]: Worker terminated due to reaching memory limit: JS heap out of memory
    

So pnpm lint is currently broken locally on master for everyone, silently traded for no CI benefit. This branch restores auto for the local scripts and routes CI through lint:ci (--concurrency=off), which is the direction the measurements and typescript-eslint's own guidance both point.

Worth a look from whoever added it in case there was a constraint I am not seeing — full numbers are in CODE-468.

@lucas77778
lucas77778 self-requested a review July 29, 2026 12:34
@lucas77778
lucas77778 merged commit 7d32e2c into master Jul 29, 2026
10 checks passed
@lucas77778
lucas77778 deleted the xuan/code-468 branch July 29, 2026 12:35
AprilNEA added a commit that referenced this pull request Jul 29, 2026
The runbook still pinned --concurrency=2 and cited 2-vCPU runners; CI has
since moved to lint:ci with concurrency off and a bigger heap (#312).
lucas77778 pushed a commit that referenced this pull request Jul 29, 2026
The runbook still pinned --concurrency=2 and cited 2-vCPU runners; CI has
since moved to lint:ci with concurrency off and a bigger heap (#312).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants