Skip to content

fix(utils): stop generateCodeFrame hanging on a CRLF source - #23345

Closed
NgoQuocViet2001 wants to merge 1 commit into
vitejs:mainfrom
NgoQuocViet2001:fix-codeframe-crlf-hang
Closed

NgoQuocViet2001 wants to merge 1 commit into
vitejs:mainfrom
NgoQuocViet2001:fix-codeframe-crlf-hang

Conversation

@NgoQuocViet2001

Copy link
Copy Markdown
Contributor

Description

generateCodeFrame() never returns — a 100% CPU spin, no allocation, no throw — when the source uses CRLF line endings and the highlighted range reaches the end of the file.

generateCodeFrame("import foo from './foo'\r\nfoo()", 0, 30)   // never returns
generateCodeFrame('a\r\nb', 0, 4)                              // never returns

The same calls on the LF equivalent return normally, which is why it has gone unnoticed: every existing test in the generateCodeFrames block that passes an end uses the LF source.

Why it loops

The inner loop has no upper bound on j:

for (let j = i - range; j <= i + range || end > count; j++) {
  if (j < 0 || j >= lines.length) continue
  ...
    count += lineLength + 1     // only reached when j is in range
}

Once j passes the last line, the continue skips the count update at the bottom of the body, so count freezes at sum(line lengths) + (lines.length - 1) while end stays larger. end > count is then permanently true and j increments forever.

CRLF is what makes end unreachable: count advances by one per line break, but end is clamped to source.length, which counts two characters per CRLF. So on a CRLF source end can exceed anything count will ever reach. On LF the two always meet and the loop exits.

Instrumented, capped at 200k iterations:

LF   len 29, end=29  -> terminated after 5 iterations
CRLF len 30, end=30  -> still looping at j=199998 (count stuck at 29, lines.length=2)

The fix bounds j by lines.length in the second disjunct, so the loop cannot outrun the input.

This matters because the function runs on the error path — formatError / buildErrorMessage — so a CRLF file (ordinary on Windows, and what git config core.autocrlf=true checks out) turns a build or dev-server error into a hung process rather than a message.

Validation

The regression test genuinely guards it: on main it hangs, so I ran it under an external timeout.

Before the fix

$ timeout 120 npx vitest run .../utils.spec.ts -t "end with CRLF"
  Tests  122 skipped (123)
  Duration 118.82s
EXIT=124        <- killed by timeout, never completed

After the fix

$ npx vitest run .../utils.spec.ts -t "generateCodeFrames"
  Tests  14 passed | 109 skipped (123)
  Duration 478ms
  • npx vitest run packages/vite/src/node/__tests__/utils.spec.ts — 123 passed (123).
  • The snapshot file is +18 / −0: the two new snapshots are additions and no existing snapshot changed.
  • npx eslint packages/vite/src/node/utils.ts packages/vite/src/node/__tests__/utils.spec.ts — clean.
  • npx oxfmt --check on both — "All matched files use the correct format."
  • pnpm --filter vite build — succeeds (including build-types-check).

Scope

This is the minimal fix: it makes the loop terminate. It does not change the frames that already worked, hence no snapshot churn.

There is a deeper issue in the same function that I deliberately left alone: count hardcodes + 1 per separator, so on CRLF the underline lengths are computed against a position space two characters narrower than end. Making count and end agree on the real separator width would fix that too, but it moves existing output and belongs in its own PR. Happy to follow up if you want it.

The inner loop had no upper bound on j:

    for (let j = i - range; j <= i + range || end > count; j++) {
      if (j < 0 || j >= lines.length) continue

Once j runs past the last line the continue skips the count update at
the bottom of the body, so count freezes while end stays larger, and
the loop spins forever at 100% CPU.

It only shows on CRLF input. count advances by one per line break, but
end is clamped to source.length, which counts two characters per CRLF
-- so on a CRLF source end can exceed anything count reaches. On LF the
two always meet and the loop exits.

Bound j by lines.length in the second disjunct.
@bluwy

bluwy commented Sep 1, 2026

Copy link
Copy Markdown
Member

Is this also fixed by #23219?

@NgoQuocViet2001

Copy link
Copy Markdown
Contributor Author

Yes — #23219 fixes it, and it's the better fix. I checked rather than guessed: I lifted generateCodeFrame/posToNumber out of main, out of #23219 and out of this branch, and ran the repro against all three.

                                    main        #23219      this PR
end == source.length  (CRLF)      hangs           ok           ok
end  > source.length  (CRLF)      hangs           ok           ok
end == source.length  (LF)           ok           ok           ok

The reason it works is that #23219 removes the cause rather than the symptom. The loop only fails to terminate because count advances one character per line break while end is clamped to source.length, which counts two per CRLF — so end > count can never go false. #23219 makes count advance by the real terminator width, so the condition falls out on its own and my j < lines.length bound becomes unnecessary.

It's also more correct beyond termination. Taking "the frame should point to the same location regardless of the line endings" as the criterion (from #23219's own test) and comparing every start/end pair over a 7-line fixture:

CRLF frame identical to the LF frame, 1326 ranges
  #23219   764 match
  this PR  348 match

Mine leaves the offset arithmetic wrong and only stops the spin; #23219 fixes posToNumber too, which this PR does not touch at all.

So I'm happy to close this in favour of #23219 — say the word, or close it yourself if that's easier.

One thing worth leaving on the record either way: even after #23219, the end > count disjunct has no bound of its own, so termination rests on count landing exactly on end. If you'd like that made structural rather than invariant-dependent, the j < lines.length guard is output-neutral on top of #23219 — 0 differences across 5253 (source, start, end) combinations, including mixed endings, a lone \r mid-line, \r\r\n, a trailing CRLF and the empty string. Entirely your call; the bug itself is #23219's.

@bluwy bluwy closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot: maybe Maybe a bot, LLM, or agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants