Skip to content

Commit c3ba16b

Browse files
fix(vm): fall back to compiling from source when a module's code cache is rejected (#11031)
1 parent 1d365db commit c3ba16b

5 files changed

Lines changed: 115 additions & 7 deletions

File tree

packages/vitest/src/runtime/vm/code-cache.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,24 @@ export class CodeCache {
4949
delete(identifier: string): void {
5050
this.entries.delete(identifier)
5151
}
52+
53+
clear(): void {
54+
this.entries.clear()
55+
}
56+
}
57+
58+
/**
59+
* `node:v8` as seen inside the vm context: changing V8 flags invalidates every
60+
* code cache produced so far, so `setFlagsFromString` also empties ours.
61+
*/
62+
export function createV8ModuleWithCacheReset<T extends { setFlagsFromString: (flags: string) => void }>(
63+
v8: T,
64+
codeCache: CodeCache,
65+
): T {
66+
const patched = Object.create(Object.getPrototypeOf(v8), Object.getOwnPropertyDescriptors(v8)) as T
67+
patched.setFlagsFromString = function setFlagsFromString(flags: string): void {
68+
v8.setFlagsFromString(flags)
69+
codeCache.clear()
70+
}
71+
return patched
5272
}

packages/vitest/src/runtime/vm/commonjs-executor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { VMSyntheticModule } from './types'
44
import { Module as _Module, createRequire, isBuiltin } from 'node:module'
55
import vm from 'node:vm'
66
import { basename, dirname, extname } from 'pathe'
7+
import { createV8ModuleWithCacheReset } from './code-cache'
78
import {
89
activeImportModuleDynamically,
910
interopCommonJsModule,
@@ -513,6 +514,12 @@ export class CommonjsExecutor {
513514
this.builtinCache[normalized] = module
514515
return module.exports
515516
}
517+
if (normalized === 'v8' && this.codeCache) {
518+
const module = new this.Module('/v8.js')
519+
module.exports = createV8ModuleWithCacheReset(moduleExports, this.codeCache)
520+
this.builtinCache[normalized] = module
521+
return module.exports
522+
}
516523
this.builtinCache[normalized] = _require.cache[normalized]!
517524
// TODO: should we wrap module to rethrow context errors?
518525
return moduleExports

packages/vitest/src/runtime/vm/esm-executor.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type vm from 'node:vm'
22
import type { ExternalModulesExecutor, SyncModuleDisposition } from '../external-executor'
3-
import type { VMModule, VMSourceTextModule, VMSyntheticModule } from './types'
3+
import type { SourceTextModuleOptions, VMModule, VMSourceTextModule, VMSyntheticModule } from './types'
44
import { dirname } from 'node:path'
55
import { fileURLToPath } from 'node:url'
66
import { VITEST_VM_CONTEXT_SYMBOL } from '../moduleRunner/startVitestModuleRunner'
@@ -164,22 +164,38 @@ export class EsmExecutor {
164164
code: string,
165165
): VMSourceTextModule {
166166
const codeCache = this.executor.codeCache
167-
const cachedData = codeCache?.get(fileURL, code)
168-
const m = new SourceTextModule(code, {
167+
let cachedData = codeCache?.get(fileURL, code)
168+
const options: SourceTextModuleOptions = {
169169
identifier: fileURL,
170170
context: this.context,
171-
cachedData,
172171
// static callbacks: Node keeps them registered for as long as the
173172
// module's host-defined-options symbol is alive, so a closure here would
174173
// retain this executor (and the whole test file's world) beyond the
175174
// file's lifetime. The executor is recovered from the module's context
176175
// at call time instead.
177176
importModuleDynamically: staticImportModuleDynamically,
178177
initializeImportMeta: staticInitializeImportMeta,
179-
})
178+
}
179+
let m: VMSourceTextModule | undefined
180+
if (cachedData) {
181+
try {
182+
m = new SourceTextModule(code, { ...options, cachedData })
183+
}
184+
catch (error: any) {
185+
// unlike vm.Script, a module throws when V8 rejects the cache (e.g. the
186+
// V8 flags changed at runtime): compile from source instead
187+
if (error?.code !== 'ERR_VM_MODULE_CACHED_DATA_REJECTED') {
188+
throw error
189+
}
190+
codeCache!.delete(fileURL)
191+
cachedData = undefined
192+
}
193+
}
194+
m ??= new SourceTextModule(code, options)
180195
// the code cache of a SourceTextModule must be created before evaluation
181196
if (!cachedData) {
182-
codeCache?.store(fileURL, code, () => m.createCachedData())
197+
const created = m
198+
codeCache?.store(fileURL, code, () => created.createCachedData())
183199
}
184200
return m
185201
}

test/e2e/test/vm-threads.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,3 +529,47 @@ test.for(['vmThreads', 'vmForks'] as const)(
529529
expect(exitCode).toBe(0)
530530
},
531531
)
532+
533+
// changing V8 flags at runtime invalidates the module code cache shared across
534+
// files on a worker: via the context's `node:v8` the cache is cleared up front,
535+
// via anything else (here the worker realm's binding) the rejection is caught
536+
test.for([
537+
['vmThreads', 'context'],
538+
['vmForks', 'context'],
539+
['vmThreads', 'worker realm'],
540+
['vmForks', 'worker realm'],
541+
] as const)(
542+
'%s survives a runtime V8 flag change from the %s',
543+
async ([pool, from]) => {
544+
const setFlags = from === 'context'
545+
? `v8.setFlagsFromString('--expose-gc')`
546+
: `process.getBuiltinModule('node:v8').setFlagsFromString('--expose-gc')`
547+
const testFile = `
548+
import v8 from 'node:v8'
549+
import { expect, test } from 'vitest'
550+
import { answer } from 'esm-dep'
551+
552+
test('imports the external module', () => {
553+
expect(answer).toBe(42)
554+
${setFlags}
555+
})
556+
`
557+
const { stderr, exitCode } = await runInlineTests({
558+
'node_modules/esm-dep/package.json': JSON.stringify({
559+
name: 'esm-dep',
560+
type: 'module',
561+
main: './index.js',
562+
}),
563+
'node_modules/esm-dep/index.js': `export const answer = 42\n${'// padding so V8 emits a code cache\n'.repeat(200)}`,
564+
'a.test.js': testFile,
565+
'b.test.js': testFile,
566+
'c.test.js': testFile,
567+
}, {
568+
pool,
569+
maxWorkers: 1,
570+
})
571+
572+
expect(stderr).toBe('')
573+
expect(exitCode).toBe(0)
574+
},
575+
)

test/unit/test/vm-code-cache.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test, vi } from 'vitest'
2-
import { CodeCache } from '../../../packages/vitest/src/runtime/vm/code-cache.js'
2+
import { CodeCache, createV8ModuleWithCacheReset } from '../../../packages/vitest/src/runtime/vm/code-cache.js'
33

44
test('returns the stored data only for the exact same source', () => {
55
const cache = new CodeCache()
@@ -61,3 +61,24 @@ test('delete removes the entry', () => {
6161
cache.store('/mod.js', 'source', produce)
6262
expect(produce).toHaveBeenCalledTimes(2)
6363
})
64+
65+
test('the vm-context v8 module clears the cache when flags change', () => {
66+
const cache = new CodeCache()
67+
const flags: string[] = []
68+
const realV8 = {
69+
setFlagsFromString: (flag: string) => {
70+
flags.push(flag)
71+
},
72+
other: 1,
73+
}
74+
const patched = createV8ModuleWithCacheReset(realV8, cache)
75+
76+
cache.store('/mod.js', 'source', () => Buffer.from('cached'))
77+
patched.setFlagsFromString('--expose-gc')
78+
79+
expect(flags).toEqual(['--expose-gc'])
80+
expect(cache.get('/mod.js', 'source')).toBeUndefined()
81+
expect(patched.other).toBe(1)
82+
expect(patched).not.toBe(realV8)
83+
expect(realV8.setFlagsFromString).not.toBe(patched.setFlagsFromString)
84+
})

0 commit comments

Comments
 (0)