diff --git a/docs/typecheck.md b/docs/typecheck.md index 830b084df..1c00419e2 100644 --- a/docs/typecheck.md +++ b/docs/typecheck.md @@ -52,6 +52,16 @@ A solution-style `tsconfig.json` (one that only lists `references`) is type-chec In build mode, type information from each run is stored in the `tsBuildInfoFile` of every project referenced by `tsconfig.json`, so later runs only re-check what changed. From Nuxt v4.7, Nuxt's generated project configs store it in `.nuxt/cache/typecheck`, which is kept by `nuxt prepare` and `nuxt dev`. The cache is discarded when the type checker or TypeScript version changes, and is not used if any referenced project has no `tsBuildInfoFile`. Pass `--no-incremental` to remove the cached type information and type-check from scratch. +## Memory + +The type checker runs in its own process. Unless you set `--max-old-space-size`, the heap limit of `vue-tsc` is raised to half of the memory of your machine (or of the container it runs in), up to 8 GB and at most the memory currently available. It is never set lower than Node's default. Golar type-checks outside of the JavaScript heap, so its limit is left alone. + +If the type checker is killed, for example because it ran out of memory, `nuxt typecheck` reports the signal and exits with `128` plus the signal number (`134` for `SIGABRT`, `137` for `SIGKILL`). To raise the heap limit yourself: + +```bash [Terminal] +NODE_OPTIONS=--max-old-space-size=8192 npx nuxt typecheck +``` + ::note This command sets `process.env.NODE_ENV` to `production`. To override, define `NODE_ENV` in a [`.env`](/docs/directory-structure/env) file or as a command-line argument. :: diff --git a/packages/nuxt-cli/src/commands/typecheck.ts b/packages/nuxt-cli/src/commands/typecheck.ts index 142a08240..6ad670244 100644 --- a/packages/nuxt-cli/src/commands/typecheck.ts +++ b/packages/nuxt-cli/src/commands/typecheck.ts @@ -2,9 +2,11 @@ import type { DetectResult } from 'package-manager-detector' import type { TSConfig } from 'pkg-types' import { existsSync, readFileSync } from 'node:fs' import { writeFile } from 'node:fs/promises' +import os from 'node:os' import process from 'node:process' import { styleText } from 'node:util' +import v8 from 'node:v8' import { cancel, confirm, isCancel, select } from '@clack/prompts' import { defineCommand } from 'citty' import { resolveModulePath } from 'exsolve' @@ -192,11 +194,22 @@ export default defineCommand({ } } + // Golar type-checks in Go, outside of the V8 heap. + const heap = typechecker.checker === 'vue-tsc' ? resolveCheckerHeap() : undefined const start = Date.now() - const result = await x(typechecker.bin, args, { - nodeOptions: { stdio: 'inherit', cwd }, + const proc = x(typechecker.bin, args, { + nodeOptions: { stdio: 'inherit', cwd, ...(heap?.nodeOptions && { env: { NODE_OPTIONS: heap.nodeOptions } }) }, }) + const result = await proc const duration = formatDuration(Date.now() - start) + // On Windows, Node exits with code 134 instead of raising `SIGABRT`. + const signal = proc.signalCode ?? (process.platform === 'win32' && result.exitCode === 134 ? 'SIGABRT' : null) + + if (signal) { + const exitCode = reportKilledChecker(signal, duration, heap?.limit) + process.exitCode = result.exitCode ?? exitCode + return + } if (result.exitCode === 0) { if (hasTTY) { @@ -213,6 +226,51 @@ export default defineCommand({ }) const NUXT_PROJECT_REFERENCE_RE = /(?:^|[/\\])tsconfig\.(?:app|server|shared|node)\.json$/ +const MAX_OLD_SPACE_SIZE_RE = /--max[-_]old[-_]space[-_]size[=\s]+(\d+)/ +const MAX_CHECKER_HEAP_MB = 8192 +const MB = 1024 * 1024 + +/** + * Report a checker killed by a signal, even without a TTY, and return the exit code to use: + * `128 + signal number`, following the shell convention. + */ +function reportKilledChecker(signal: string, duration: string, heapLimit?: number): number { + // `SIGKILL` is sent by the kernel OOM killer, `SIGABRT` by V8 when it reaches its heap limit. + const outOfMemory = signal === 'SIGKILL' || signal === 'SIGABRT' ? ', which usually means it ran out of memory' : '' + const heapHint = signal === 'SIGABRT' && heapLimit ? ` Its heap was limited to ${styleText('cyan', `${heapLimit} MB`)}. Set ${styleText('bold', 'NODE_OPTIONS=--max-old-space-size=')} to raise it.` : '' + logger.error(`Type-checking was killed by ${styleText('bold', signal)} after ${styleText('cyan', duration)}${outOfMemory}.${heapHint}`) + + const signalNumber = os.constants.signals[signal as NodeJS.Signals] as number | undefined + return signalNumber ? 128 + signalNumber : 1 +} + +/** + * Heap limit of the checker in MB, with the `NODE_OPTIONS` that set it if they differ from the inherited ones. + * + * Unless set by the user, this is half of the memory of the machine or container, bounded by the memory + * currently available and by `MAX_CHECKER_HEAP_MB`, and never below V8's default. + */ +function resolveCheckerHeap(): { limit: number, nodeOptions?: string } { + const nodeOptions = process.env.NODE_OPTIONS + const inherited = nodeOptions?.match(MAX_OLD_SPACE_SIZE_RE) + if (inherited) { + return { limit: Number(inherited[1]) } + } + + const withLimit = (limit: number) => ({ limit, nodeOptions: [nodeOptions, `--max-old-space-size=${limit}`].filter(Boolean).join(' ') }) + + const own = process.execArgv.join(' ').match(MAX_OLD_SPACE_SIZE_RE) + if (own) { + return withLimit(Number(own[1])) + } + + const defaultLimit = Math.round(v8.getHeapStatistics().heap_size_limit / MB) + const total = os.totalmem() + const constrained = process.constrainedMemory() + const memory = constrained > 0 && constrained < total ? constrained : total + const limit = Math.floor(Math.min(memory / 2, process.availableMemory(), MAX_CHECKER_HEAP_MB * MB) / MB) + return limit > defaultLimit ? withLimit(limit) : { limit: defaultLimit } +} /** * Whether a tsconfig is a solution-style config, as used by Nuxt's project references setup. diff --git a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts index 43237934e..ca75c8eed 100644 --- a/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/typecheck.spec.ts @@ -1,18 +1,20 @@ import { EventEmitter } from 'node:events' import { mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import os, { tmpdir } from 'node:os' import process from 'node:process' +import v8 from 'node:v8' import { join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { runInstall, answers, resolveModulePath, spawn, tinyexec, writeTypes } = vi.hoisted(() => ({ +const { runInstall, answers, resolveModulePath, spawn, tinyexec, writeTypes, tty } = vi.hoisted(() => ({ runInstall: vi.fn((_options: Record) => Promise.resolve({ success: true, output: '', command: '', ignoredBuilds: [] as string[] })), answers: { select: [] as unknown[], confirm: [] as unknown[] }, resolveModulePath: vi.fn(), spawn: vi.fn(), tinyexec: vi.fn(() => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })), writeTypes: vi.fn(() => Promise.resolve()), + tty: { value: true }, })) vi.mock('tinyexec', () => ({ x: tinyexec })) @@ -48,7 +50,9 @@ vi.mock('../../../src/utils/kit', () => ({ vi.mock('std-env', async importOriginal => ({ ...await importOriginal(), - hasTTY: true, + get hasTTY() { + return tty.value + }, })) vi.mock('@clack/prompts', async (importOriginal) => { @@ -97,10 +101,16 @@ async function runTypecheck(argv: string[] = [], data?: { overrides?: Record { cwd = await mkdtemp(join(tmpdir(), 'nuxt-typecheck-')) answers.select.length = 0 answers.confirm.length = 0 + tty.value = true vi.clearAllMocks() tinyexec.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) typegenExits(0) @@ -239,6 +249,153 @@ describe('typecheck results', () => { expect(output).toContain('Type check failed') expect(exitCode).toBe(2) }) + + it('should report a checker killed by the OOM killer', async () => { + tinyexec.mockReturnValueOnce(killed('SIGKILL')) + + const { output, exitCode } = await runTypecheck() + + expect(output).toContain('Type-checking was killed by SIGKILL') + expect(output).toContain('out of memory') + expect(output).not.toContain('Type check failed') + expect(exitCode).toBe(137) + }) + + it('should treat SIGABRT as running out of memory', async () => { + tinyexec.mockReturnValueOnce(killed('SIGABRT')) + + const { output, exitCode } = await runTypecheck() + + expect(output).toContain('was killed by SIGABRT') + expect(output).toContain('out of memory') + expect(exitCode).toBe(128 + os.constants.signals.SIGABRT) + }) + + it('should report other signals without an out of memory hint', async () => { + tinyexec.mockReturnValueOnce(killed('SIGTERM')) + + const { output, exitCode } = await runTypecheck() + + expect(output).toContain('was killed by SIGTERM') + expect(output).not.toContain('out of memory') + expect(exitCode).toBe(143) + }) + + it('should report a killed checker without a TTY', async () => { + tty.value = false + tinyexec.mockReturnValueOnce(killed('SIGKILL')) + + const { output, exitCode } = await runTypecheck() + + expect(output).toContain('was killed by SIGKILL') + expect(exitCode).toBe(137) + }) + + it('should treat exit code 134 on Windows as SIGABRT', async () => { + const platform = process.platform + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) + tinyexec.mockResolvedValueOnce({ exitCode: 134, stdout: '', stderr: '' }) + + try { + const { output, exitCode } = await runTypecheck() + + expect(output).toContain('was killed by SIGABRT') + expect(output).toContain('out of memory') + expect(output).not.toContain('Type check failed') + expect(exitCode).toBe(134) + } + finally { + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + } + }) +}) + +describe('typecheck heap limit', () => { + const MB = 1024 ** 2 + const GB = 1024 ** 3 + const execArgv = process.execArgv + + function memory({ total = 12 * GB, constrained = 2 ** 64, available = 10 * GB, heapLimit = 2240 * MB } = {}) { + vi.spyOn(os, 'totalmem').mockReturnValue(total) + vi.spyOn(process, 'constrainedMemory').mockReturnValue(constrained) + vi.spyOn(process, 'availableMemory').mockReturnValue(available) + vi.spyOn(v8, 'getHeapStatistics').mockReturnValue({ ...v8.getHeapStatistics(), heap_size_limit: heapLimit }) + } + + function checkerNodeOptions(): string | undefined { + const options = (tinyexec.mock.calls[0] as unknown[])[2] as { nodeOptions: { env?: Record } } + return options.nodeOptions.env?.NODE_OPTIONS + } + + beforeEach(() => { + vi.stubEnv('NODE_OPTIONS', '') + }) + + afterEach(() => { + vi.unstubAllEnvs() + process.execArgv = execArgv + }) + + it.each([ + ['half of the total memory', {}, 6144], + ['half of the memory limit of the process', { constrained: 6 * GB }, 3072], + ['the memory currently available', { available: 3 * GB }, 3072], + ['at most 8 GB', { total: 32 * GB, available: 30 * GB, heapLimit: 4144 * MB }, 8192], + ])('should raise the checker heap limit to %s', async (_, options, limit) => { + memory(options) + + await runTypecheck() + + expect(checkerNodeOptions()).toBe(`--max-old-space-size=${limit}`) + }) + + it('should keep existing node options', async () => { + memory() + vi.stubEnv('NODE_OPTIONS', '--enable-source-maps') + + await runTypecheck() + + expect(checkerNodeOptions()).toBe('--enable-source-maps --max-old-space-size=6144') + }) + + it('should pass on a heap limit set for the nuxt process', async () => { + memory() + process.execArgv = ['--max-old-space-size=3000'] + + await runTypecheck() + + expect(checkerNodeOptions()).toBe('--max-old-space-size=3000') + }) + + it.each([ + ['the raised heap limit', {}, '', 6144, '--max-old-space-size=6144'], + ['the heap limit set in NODE_OPTIONS', {}, '--max-old-space-size=3000', 3000, undefined], + ['V8\'s default heap limit', { total: 4 * GB }, '', 2240, undefined], + ])('should name %s when the checker runs out of heap', async (_, options, nodeOptions, limit, passed) => { + memory(options) + vi.stubEnv('NODE_OPTIONS', nodeOptions) + tinyexec.mockReturnValueOnce(killed('SIGABRT')) + + const { output } = await runTypecheck() + + expect(checkerNodeOptions()).toBe(passed) + expect(output).toContain(`Its heap was limited to ${limit} MB`) + expect(output).toContain('NODE_OPTIONS=--max-old-space-size=') + }) + + it('should leave the heap limit of golar alone', async () => { + memory() + await mkdir(join(cwd, 'node_modules/golar'), { recursive: true }) + await writeFile(join(cwd, 'node_modules/golar/package.json'), JSON.stringify({ name: 'golar', bin: './bin.js' })) + installed('golar/unstable', '@golar/vue') + tinyexec.mockReturnValueOnce(killed('SIGABRT')) + + const { output } = await runTypecheck(['--checker=golar']) + + expect(checkerNodeOptions()).toBeUndefined() + expect(output).toContain('out of memory') + expect(output).not.toContain('heap was limited') + }) }) describe('typecheck type generation', () => {