From cc591ff7cb288d9bf64655885d45f8e08d4b1d25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9onard=20C=2E?= Date: Sat, 6 Jun 2026 05:03:38 +0200 Subject: [PATCH] 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). --- bin/electron-bytecode-preload.cjs | 21 +++++ bin/electron-bytecode.cjs | 64 ++++++++------ src/plugins/bytecode.ts | 133 ++++++++++++++---------------- 3 files changed, 125 insertions(+), 93 deletions(-) create mode 100644 bin/electron-bytecode-preload.cjs diff --git a/bin/electron-bytecode-preload.cjs b/bin/electron-bytecode-preload.cjs new file mode 100644 index 0000000..f925bce --- /dev/null +++ b/bin/electron-bytecode-preload.cjs @@ -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') diff --git a/bin/electron-bytecode.cjs b/bin/electron-bytecode.cjs index 9af3153..3a5fd76 100644 --- a/bin/electron-bytecode.cjs +++ b/bin/electron-bytecode.cjs @@ -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,') + }) +} 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() + }) +} diff --git a/src/plugins/bytecode.ts b/src/plugins/bytecode.ts index 31d864b..9a72dc9 100644 --- a/src/plugins/bytecode.ts +++ b/src/plugins/bytecode.ts @@ -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 { - return new Promise((resolve, reject) => { - let data = Buffer.from([]) +let bytecodeId = 0 +function compileToBytecode(code: string, renderer: boolean): Promise { + 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',