Skip to content

Commit a1b0565

Browse files
hitenkaldaHiten Kalda
andauthored
fix: prevent vitest --typecheck from reporting a false success when the tsc process crashes (#10705)
Co-authored-by: Hiten Kalda <[email protected]>
1 parent d6ceca9 commit a1b0565

6 files changed

Lines changed: 102 additions & 5 deletions

File tree

‎packages/vitest/src/node/pools/workers/typecheckWorker.ts‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,44 @@ function createRunner(vitest: Vitest) {
119119
)
120120
}
121121

122-
const processError = !hasFailed(files) && !sourceErrors.length && checker.getExitCode()
123-
if (processError) {
124-
const error = new Error(checker.getOutput())
125-
error.stack = ''
126-
vitest.state.catchError(error, 'Typecheck Error')
122+
// The typechecker child process (tsc/vue-tsc) can terminate without producing
123+
// a complete set of diagnostics: a non-zero exit code, or being killed by a
124+
// signal (e.g. SIGABRT from an out-of-memory abort, which surfaces as exit
125+
// 134). We must not report the run as passing in that case, otherwise real
126+
// type errors slip through as a false green.
127+
if (!hasFailed(files) && !sourceErrors.length) {
128+
const exitCode = checker.getExitCode()
129+
const signal = checker.getSignal()
130+
131+
if (exitCode || signal) {
132+
const output = checker.getOutput()
133+
const looksLikeOom = signal === 'SIGABRT'
134+
|| /JavaScript heap out of memory|Reached heap limit|Allocation failed/i.test(output)
135+
136+
let message: string
137+
if (signal || looksLikeOom) {
138+
const reason = signal
139+
? `was terminated by signal ${signal}`
140+
: `exited with code ${exitCode}`
141+
message = `The ${checker.getChecker()} process ${reason} before type checking finished.`
142+
if (looksLikeOom) {
143+
message += ` This usually means it ran out of memory — try increasing the `
144+
+ `limit with NODE_OPTIONS=--max-old-space-size.`
145+
}
146+
if (output) {
147+
message += `\n\n${output}`
148+
}
149+
}
150+
else {
151+
// a plain non-zero exit with diagnostics we couldn't attribute to a
152+
// file (e.g. a tsconfig error) — surface the checker output as-is
153+
message = output
154+
}
155+
156+
const error = new Error(message)
157+
error.stack = ''
158+
vitest.state.catchError(error, 'Typecheck Error')
159+
}
127160
}
128161

129162
promisesMap.get(project)?.resolve()

‎packages/vitest/src/typecheck/typechecker.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,14 @@ export class Typechecker {
286286
return this.process?.exitCode != null && this.process.exitCode
287287
}
288288

289+
public getSignal(): NodeJS.Signals | null {
290+
return this.process?.signalCode ?? null
291+
}
292+
293+
public getChecker(): string {
294+
return this.project.config.typecheck.checker
295+
}
296+
289297
public getOutput(): string {
290298
return this._output
291299
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
#!/usr/bin/env node
2+
// A fake `tsc` used to simulate the type checker running out of memory.
3+
// It ignores all arguments, prints a fatal heap-limit message the way V8 does,
4+
// and then aborts (SIGABRT, exit 134) without emitting any diagnostics — exactly
5+
// what happens when `tsc` OOMs. Vitest must not treat this as "no type errors".
6+
import fs from 'node:fs'
7+
8+
// small delay so the process reliably survives the spawn/grace window before it
9+
// dies — mirrors a real type check that runs for a while and then OOMs
10+
setTimeout(() => {
11+
fs.writeSync(
12+
2,
13+
'\n<--- Last few GCs --->\n\n'
14+
+ 'FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\n',
15+
)
16+
process.abort()
17+
}, 500)
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { expectTypeOf, test } from 'vitest'
2+
3+
test('ok', () => {
4+
expectTypeOf(1).toEqualTypeOf<number>()
5+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"noEmit": true,
4+
"target": "es2020",
5+
"module": "ESNext",
6+
"moduleResolution": "Bundler",
7+
"strict": true,
8+
"verbatimModuleSyntax": true
9+
},
10+
"include": ["src", "test"],
11+
"exclude": ["node_modules"]
12+
}

‎test/typescript/test/typechecker.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,26 @@ describe('Typechecker', () => {
1515
// Should show proper error when typechecker doesn't exist
1616
expect(stderr).toContain('Spawning typechecker failed')
1717
})
18+
19+
it('fails the run when the typechecker crashes (OOM) instead of reporting a false green', async () => {
20+
const { stderr, exitCode } = await runVitest({
21+
root: resolve(import.meta.dirname, '../fixtures/typecheck-crash'),
22+
typecheck: {
23+
enabled: true,
24+
checker: resolve(
25+
import.meta.dirname,
26+
'../fixtures/typecheck-crash/fake-tsc.mjs',
27+
),
28+
},
29+
})
30+
31+
// A checker that aborts (OOM) without producing diagnostics must NOT be
32+
// reported as passing — the run has to fail with a clear error. The abort
33+
// surfaces as a signal (SIGABRT) on POSIX and as exit code 134 on Windows;
34+
// both paths must be treated as an abnormal, failing exit.
35+
expect(exitCode).toBe(1)
36+
expect(stderr).toContain('Typecheck Error')
37+
expect(stderr).toContain('before type checking finished')
38+
expect(stderr).toContain('ran out of memory')
39+
})
1840
})

0 commit comments

Comments
 (0)