fix(bytecodePlugin): support Electron 42 / V8 14.8 code cache

With build.bytecode enabled, apps crash at launch on Electron 42 (V8 14.8)
with "Invalid or incompatible cached data (cachedDataRejected)". Electron 41
(V8 14.6) is unaffected, on the same machine and arch.

On V8 14.8 the code cache is bound to a per-process-type snapshot/isolate
checksum AND flag hash (electron/electron#51831 gives each process type its
own Node startup snapshot), and vm.Script no longer runs a cache when the
loader supplies a placeholder source.

Fix: compile each chunk in the Electron process type that will consume it,
via vm.compileFunction:
- main chunks    -> the browser (main) process
- preload chunks -> a renderer process (a hidden window whose sandbox:false
                    preload compiles the chunk)
Load via vm.compileFunction under --no-lazy, which runs the cache and ignores
the placeholder body (made unique per file to avoid V8 compilation-cache
collisions). No header bytes are patched, so an incompatible cache fails
loudly instead of corrupting execution.

Note: bytecode compilation now spawns Electron processes (a main process, and
a hidden window for the preload). On Windows this works without an interactive
desktop (e.g. over SSH); on Linux a display may be required (e.g. xvfb).
This commit is contained in:
Léonard C. 2026-06-06 05:03:38 +02:00
parent 31965d2972
commit cc591ff7cb
No known key found for this signature in database
GPG Key ID: 4951625F97086BBD
3 changed files with 125 additions and 93 deletions

View File

@ -0,0 +1,21 @@
// Compiler preload: runs in a renderer process (sandbox:false) so the produced code
// cache matches the V8 isolate (snapshot + flag hash) of the app's runtime preload,
// which the browser/main process cache does not on Electron 42+ / V8 14.8.
const fs = require('fs')
const vm = require('vm')
const v8 = require('v8')
const { ipcRenderer } = require('electron')
v8.setFlagsFromString('--no-lazy')
v8.setFlagsFromString('--no-flush-bytecode')
const params = ['exports', 'require', 'module', '__filename', '__dirname']
try {
const code = fs.readFileSync(process.env.ELECTRON_VITE_BYTECODE_IN, 'utf-8')
const fn = vm.compileFunction(code, params, { produceCachedData: true })
fs.writeFileSync(process.env.ELECTRON_VITE_BYTECODE_OUT, fn.cachedData)
} catch (error) {
console.error(error)
}
ipcRenderer.send('electron-vite:bytecode-done')

View File

@ -1,32 +1,50 @@
const { app } = require('electron')
const vm = require('vm')
const v8 = require('v8')
const wrap = require('module').wrap
const fs = require('fs')
const path = require('path')
v8.setFlagsFromString('--no-lazy')
v8.setFlagsFromString('--no-flush-bytecode')
let code = ''
// Compile a chunk to a V8 code cache in the SAME process type that will consume it.
// On Electron 42+/V8 14.8 the cache is bound to a per-process-type snapshot AND flag
// hash, so a cache built in the wrong process type is rejected (and forcing acceptance
// corrupts execution). Spawned WITHOUT ELECTRON_RUN_AS_NODE (whose isolate matches
// neither). Code in / cache out go through ELECTRON_VITE_BYTECODE_IN / _OUT temp files.
// - main chunks -> compiled here, in the browser (main) process.
// - preload chunks -> ELECTRON_VITE_BYTECODE_RENDERER=1: a hidden window whose
// sandbox:false preload compiles it in a renderer isolate.
app.disableHardwareAcceleration()
process.stdin.setEncoding('utf-8')
const params = ['exports', 'require', 'module', '__filename', '__dirname']
const inFile = process.env.ELECTRON_VITE_BYTECODE_IN
const outFile = process.env.ELECTRON_VITE_BYTECODE_OUT
process.stdin.on('readable', () => {
const data = process.stdin.read()
if (data !== null) {
code += data
}
})
process.stdin.on('end', () => {
try {
if (typeof code !== 'string') {
throw new Error(`javascript code must be string. ${typeof code} was given.`)
if (process.env.ELECTRON_VITE_BYTECODE_RENDERER) {
const { BrowserWindow, ipcMain } = require('electron')
app.whenReady().then(() => {
ipcMain.once('electron-vite:bytecode-done', () => app.quit())
const win = new BrowserWindow({
show: false,
webPreferences: {
preload: path.join(__dirname, 'electron-bytecode-preload.cjs'),
sandbox: false,
contextIsolation: true
}
})
win.loadURL('data:text/html,<!doctype html><html></html>')
})
} else {
app.whenReady().then(() => {
try {
const code = fs.readFileSync(inFile, 'utf-8')
const fn = vm.compileFunction(code, params, { produceCachedData: true })
fs.writeFileSync(outFile, fn.cachedData)
} catch (error) {
console.error(error)
process.exitCode = 1
}
const script = new vm.Script(wrap(code), { produceCachedData: true })
const bytecodeBuffer = script.createCachedData()
process.stdout.write(bytecodeBuffer)
} catch (error) {
console.error(error)
}
})
app.quit()
})
}

View File

@ -1,3 +1,5 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
@ -16,50 +18,54 @@ function getBytecodeCompilerPath(): string {
return path.join(path.dirname(_require.resolve('electron-vite/package.json')), 'bin', 'electron-bytecode.cjs')
}
function compileToBytecode(code: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
let data = Buffer.from([])
let bytecodeId = 0
function compileToBytecode(code: string, renderer: boolean): Promise<Buffer> {
return new Promise((resolve, reject) => {
const electronPath = getElectronPath()
const bytecodePath = getBytecodeCompilerPath()
const id = `${process.pid}-${bytecodeId++}`
const inFile = path.join(os.tmpdir(), `electron-vite-bytecode-${id}.in.js`)
const outFile = path.join(os.tmpdir(), `electron-vite-bytecode-${id}.jsc`)
fs.writeFileSync(inFile, code)
// Compile in a real Electron process whose V8 isolate matches the one that will
// consume the cache. On V8 14.8+ (Electron 42+) the code cache is bound to a
// snapshot/isolate checksum AND a flag hash that differ per process type, so a cache
// built in the wrong process type is rejected (and forcing acceptance corrupts):
// - main chunks -> the Electron browser (main) process
// - preload chunks -> a renderer process (a hidden window whose sandbox:false
// preload does the compile)
// Never ELECTRON_RUN_AS_NODE (its isolate matches neither on Electron 42+). Code in
// / cache out go through temp files (a GUI-subsystem process doesn't pipe stdio).
const env = { ...process.env, ELECTRON_VITE_BYTECODE_IN: inFile, ELECTRON_VITE_BYTECODE_OUT: outFile }
delete env.ELECTRON_RUN_AS_NODE
if (renderer) {
env.ELECTRON_VITE_BYTECODE_RENDERER = '1'
}
const proc = spawn(electronPath, [bytecodePath], {
env: { ELECTRON_RUN_AS_NODE: '1' },
stdio: ['pipe', 'pipe', 'pipe', 'ipc']
env,
stdio: ['ignore', 'ignore', 'pipe']
})
if (proc.stdin) {
proc.stdin.write(code)
proc.stdin.end()
}
if (proc.stdout) {
proc.stdout.on('data', chunk => {
data = Buffer.concat([data, chunk])
})
proc.stdout.on('error', err => {
console.error(err)
})
proc.stdout.on('end', () => {
resolve(data)
})
}
let stderr = ''
if (proc.stderr) {
proc.stderr.on('data', chunk => {
console.error('Error: ', chunk.toString())
})
proc.stderr.on('error', err => {
console.error('Error: ', err)
stderr += chunk.toString()
})
}
proc.addListener('message', message => console.log(message))
proc.addListener('error', err => console.error(err))
proc.on('error', err => reject(err))
proc.on('exit', () => {
resolve(data)
proc.on('exit', exitCode => {
fs.rmSync(inFile, { force: true })
try {
const data = fs.readFileSync(outFile)
fs.rmSync(outFile, { force: true })
resolve(data)
} catch {
reject(new Error(`bytecode compilation failed (exit code ${exitCode})${stderr ? `:\n${stderr}` : ''}`))
}
})
})
}
@ -73,47 +79,37 @@ const bytecodeModuleLoaderCode = [
`const Module = require("module");`,
`v8.setFlagsFromString("--no-lazy");`,
`v8.setFlagsFromString("--no-flush-bytecode");`,
`const FLAG_HASH_OFFSET = 12;`,
`const COMPILE_PARAMS = ["exports", "require", "module", "__filename", "__dirname"];`,
`const SOURCE_HASH_OFFSET = 8;`,
`let dummyBytecode;`,
`function setFlagHashHeader(bytecodeBuffer) {`,
` if (!dummyBytecode) {`,
` const script = new vm.Script("", {`,
` produceCachedData: true`,
` });`,
` dummyBytecode = script.createCachedData();`,
`function sourceLength(bytecodeBuffer) {`,
` // The low 28 bits of the source hash hold the source length; the high bits are`,
` // V8 source-hash flags (e.g. the "wrapped" bit set by vm.compileFunction).`,
` return bytecodeBuffer.readUInt32LE(SOURCE_HASH_OFFSET) & 0x0fffffff;`,
`};`,
`function placeholderBody(len, filename) {`,
` // A same-length body so the source hash matches. Its CONTENT is ignored (V8 runs`,
` // the cached bytecode) but it must be UNIQUE per file, otherwise V8's in-isolate`,
` // compilation cache returns a previously-compiled function for the same source.`,
` const tag = "/*" + filename + " ";`,
` if (tag.length + 2 <= len) {`,
` return tag + " ".repeat(len - tag.length - 2) + "*/";`,
` }`,
` dummyBytecode.slice(FLAG_HASH_OFFSET, FLAG_HASH_OFFSET + 4).copy(bytecodeBuffer, FLAG_HASH_OFFSET);`,
`};`,
`function getSourceHashHeader(bytecodeBuffer) {`,
` return bytecodeBuffer.slice(SOURCE_HASH_OFFSET, SOURCE_HASH_OFFSET + 4);`,
`};`,
`function buffer2Number(buffer) {`,
` let ret = 0;`,
` ret |= buffer[3] << 24;`,
` ret |= buffer[2] << 16;`,
` ret |= buffer[1] << 8;`,
` ret |= buffer[0];`,
` return ret;`,
` if (len >= 4) {`,
` return "/*" + (filename + " ").slice(0, len - 4).padEnd(len - 4, " ") + "*/";`,
` }`,
` return " ".repeat(len);`,
`};`,
`Module._extensions[".jsc"] = Module._extensions[".cjsc"] = function (module, filename) {`,
` const bytecodeBuffer = fs.readFileSync(filename);`,
` if (!Buffer.isBuffer(bytecodeBuffer)) {`,
` throw new Error("BytecodeBuffer must be a buffer object.");`,
` }`,
` setFlagHashHeader(bytecodeBuffer);`,
` const length = buffer2Number(getSourceHashHeader(bytecodeBuffer));`,
` let dummyCode = "";`,
` if (length > 1) {`,
` dummyCode = "\\"" + "\\u200b".repeat(length - 2) + "\\"";`,
` }`,
` const script = new vm.Script(dummyCode, {`,
` const placeholder = placeholderBody(sourceLength(bytecodeBuffer), filename);`,
` const compiledWrapper = vm.compileFunction(placeholder, COMPILE_PARAMS, {`,
` filename: filename,`,
` lineOffset: 0,`,
` displayErrors: true,`,
` cachedData: bytecodeBuffer`,
` });`,
` if (script.cachedDataRejected) {`,
` if (compiledWrapper.cachedDataRejected) {`,
` throw new Error("Invalid or incompatible cached data (cachedDataRejected)");`,
` }`,
` const require = function (id) {`,
@ -127,15 +123,8 @@ const bytecodeModuleLoaderCode = [
` }`,
` require.extensions = Module._extensions;`,
` require.cache = Module._cache;`,
` const compiledWrapper = script.runInThisContext({`,
` filename: filename,`,
` lineOffset: 0,`,
` columnOffset: 0,`,
` displayErrors: true`,
` });`,
` const dirname = path.dirname(filename);`,
` const args = [module.exports, require, module, filename, dirname, process, global];`,
` return compiledWrapper.apply(module.exports, args);`,
` return compiledWrapper.call(module.exports, module.exports, require, module, filename, dirname);`,
`};`
]
@ -190,6 +179,9 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
const bytecodeModuleLoader = 'bytecode-loader.cjs'
let supported = false
// Preload runs in a renderer-type V8 isolate (different snapshot/flags than the
// browser/main process), so its bytecode must be compiled in a renderer process.
let isPreload = false
return {
name: 'vite:bytecode',
@ -199,6 +191,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
if (supported) {
return
}
isPreload = config.plugins.some(p => p.name === 'vite:electron-preload-config-preset')
const useInRenderer = config.plugins.some(p => p.name === 'vite:electron-renderer-preset-config')
if (useInRenderer) {
config.logger.warn(colors.yellow('bytecodePlugin does not support renderer.'))
@ -274,7 +267,7 @@ export function bytecodePlugin(options: BytecodeOptions = {}): Plugin | null {
}
}
if (bytecodeChunks.includes(name)) {
const bytecodeBuffer = await compileToBytecode(_code)
const bytecodeBuffer = await compileToBytecode(_code, isPreload)
this.emitFile({
type: 'asset',
fileName: name + 'c',