Skip to content

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

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

fix(utils): stop generateCodeFrame hanging on a CRLF source#23345
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.ts123 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.
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.

1 participant