From 6582e29041e59d959e10f02e34cc08a6dbc1231e Mon Sep 17 00:00:00 2001 From: Joaquin Terrasa Date: Tue, 8 Sep 2026 22:35:55 +0200 Subject: [PATCH 1/3] perf(startup): enable V8 compile cache for the main process and children --- src/main/startup/main-process-preflight.ts | 2 + .../startup/main-process-ready-foundation.ts | 2 + src/main/startup/native-code-cache.test.ts | 55 ++++++++++++++++ src/main/startup/native-code-cache.ts | 63 +++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 src/main/startup/native-code-cache.test.ts create mode 100644 src/main/startup/native-code-cache.ts diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 891f6576a289..9cd9b1aede7c 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -36,6 +36,7 @@ import { import { getDevInstanceIdentity, shouldApplyPreReadyAppName } from './dev-instance-identity' import { enableRendererHeapHeadroom } from './renderer-heap-headroom' import { configureLinuxDevShmUsage } from './linux-dev-shm-policy' +import { enableMainProcessCompileCache } from './native-code-cache' import { isStartupDiagnosticsEnabled, logStartupDiagnostic } from './startup-diagnostics' import { startEventLoopStallProbe } from './event-loop-stall-probe' import { @@ -365,6 +366,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b startCrashpadCapture() state.crashReports = CrashReportStore.fromUserData() state.gpuCrashDiagnostics = createGpuCrashDiagnosticsRecorder() + enableMainProcessCompileCache() recordCrashBreadcrumb('app_started', { packaged: app.isPackaged, platform: process.platform, diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 4403659a2b92..07e29c2d3fba 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -44,6 +44,7 @@ import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' import { browserSessionRegistry } from '../browser/browser-session-registry' import { logStartupMilestone } from './startup-diagnostics' +import { configureSessionCodeCache } from './native-code-cache' import { writeHttp1CompatibilityMarker } from './http1-compatibility-marker' import { mainProcessState as state } from './main-process-state' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' @@ -57,6 +58,7 @@ import { reportProfileStateWriteFailure } from './profile-state-write-failure' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') + configureSessionCodeCache(session.defaultSession) // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. applyBackgroundActivationPolicy({ warn: console.warn }) installElectronProxyRequestGuard(session.defaultSession) diff --git a/src/main/startup/native-code-cache.test.ts b/src/main/startup/native-code-cache.test.ts new file mode 100644 index 000000000000..ef1ca4d1123a --- /dev/null +++ b/src/main/startup/native-code-cache.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Session } from 'electron' +import { configureSessionCodeCache, enableMainProcessCompileCache } from './native-code-cache' + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn((name: string) => `/mock/user/data/${name}`) + } +})) + +describe('enableMainProcessCompileCache', () => { + const originalEnv = process.env.NODE_COMPILE_CACHE + + beforeEach(() => { + delete process.env.NODE_COMPILE_CACHE + }) + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.NODE_COMPILE_CACHE = originalEnv + } else { + delete process.env.NODE_COMPILE_CACHE + } + vi.restoreAllMocks() + }) + + it('enables compile cache and sets process.env.NODE_COMPILE_CACHE', () => { + const result = enableMainProcessCompileCache('/tmp/mock-cache') + expect(result.enabled).toBe(true) + expect(result.directory).toBeTruthy() + expect(process.env.NODE_COMPILE_CACHE).toBe(result.directory) + }) + + it('safely handles missing cache dir fallback', () => { + const result = enableMainProcessCompileCache() + expect(result.enabled).toBe(true) + expect(result.directory).toBeTruthy() + }) +}) + +describe('configureSessionCodeCache', () => { + it('calls setCodeCachePath on the target session with app userData Code Cache path', () => { + const mockSession: Pick = { + setCodeCachePath: vi.fn() + } + + const configured = configureSessionCodeCache(mockSession) + expect(configured).toBe(true) + expect(mockSession.setCodeCachePath).toHaveBeenCalledWith('/mock/user/data/userData/Code Cache') + }) + + it('returns false gracefully if setCodeCachePath throws or is missing', () => { + expect(configureSessionCodeCache({} as Pick)).toBe(false) + }) +}) diff --git a/src/main/startup/native-code-cache.ts b/src/main/startup/native-code-cache.ts new file mode 100644 index 000000000000..28121ba26a1e --- /dev/null +++ b/src/main/startup/native-code-cache.ts @@ -0,0 +1,63 @@ +import { join } from 'node:path' +import type { Session } from 'electron' +import { app } from 'electron' + +export type NativeCodeCacheResult = { + enabled: boolean + directory: string | null + error?: string +} + +// Why: persist compiled V8 bytecode to disk so warm launches bypass JS parsing and AST allocation spikes. +export function enableMainProcessCompileCache(customCacheDir?: string): NativeCodeCacheResult { + try { + const nodeModule = require('node:module') as { + enableCompileCache?: (dir?: string) => { status: number; directory: string } + } + + if (typeof nodeModule.enableCompileCache !== 'function') { + return { enabled: false, directory: null } + } + + const cacheDir = + customCacheDir ?? + process.env.NODE_COMPILE_CACHE ?? + (typeof app?.getPath === 'function' + ? join(app.getPath('userData'), 'compile-cache') + : undefined) + + const result = nodeModule.enableCompileCache(cacheDir) + if (result && result.directory) { + // Why: child processes (daemon, plugin-host, watcher) inherit env and automatically use compile cache. + process.env.NODE_COMPILE_CACHE = result.directory + return { enabled: true, directory: result.directory } + } + + return { enabled: false, directory: null } + } catch (error) { + return { + enabled: false, + directory: null, + error: error instanceof Error ? error.message : String(error) + } + } +} + +// Why: ensure Chromium session stores compiled web script bytecode persistently in user profile. +export function configureSessionCodeCache( + targetSession: Pick +): boolean { + try { + if ( + typeof targetSession?.setCodeCachePath === 'function' && + typeof app?.getPath === 'function' + ) { + const codeCachePath = join(app.getPath('userData'), 'Code Cache') + targetSession.setCodeCachePath(codeCachePath) + return true + } + return false + } catch { + return false + } +} From 402262f7f8a755dbc1baed89de26fc1cf8ce7eae Mon Sep 17 00:00:00 2001 From: Joaquin Terrasa Date: Wed, 9 Sep 2026 10:22:45 +0200 Subject: [PATCH 2/3] perf(startup): enable compile cache before the main module graph evaluates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit blocker: module.enableCompileCache() is not retroactive — the call at the tail of runMainProcessPreflight only saw modules compiled after it, so the main process's own startup graph was never cached. Move the enable to a build-time banner prepended to out/main/index.js (same generateBundle mechanism as the bootstrap-fatal-exit and startup- diagnostics banners). It runs before Rollup's chunk requires, so the entire main graph is cached from the first launch. The no-arg call uses Node's default cache dir outside userData, which keeps it correct across the dev/E2E userData redirect that happens later in preflight; chunk cache entries are keyed by content hash and shared across profiles. The runtime enableMainProcessCompileCache() stays as an idempotent fallback that pins NODE_COMPILE_CACHE for forked children (daemon, plugin-host, sidecars); the banner sets the same env var on the normal path. Drop configureSessionCodeCache — setCodeCachePath to the default 'Code Cache' location was a runtime no-op. --- electron.vite.config.ts | 27 ++++++++++++ src/main/startup/main-process-preflight.ts | 2 + .../startup/main-process-ready-foundation.ts | 2 - src/main/startup/native-code-cache.test.ts | 43 ++++++++----------- src/main/startup/native-code-cache.ts | 25 ++--------- 5 files changed, 50 insertions(+), 49 deletions(-) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 263268833ba1..e73d84cdd524 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -181,6 +181,32 @@ function createStartupDiagnosticsBanner(chunkName: string): string { ` } +function createCompileCacheBanner(): string { + // Why here and not in main-process-preflight: module.enableCompileCache() is not + // retroactive — by preflight time the entire static import graph of index.ts has + // already been evaluated, so nothing of the main process's own startup would be + // cached. Prepending to the bundle entry caches the real startup graph. + // No-arg call uses Node's default cache dir (per-user, outside userData), so the + // later dev/E2E userData redirect cannot misplace it; entries are keyed by chunk + // content hash, which is identical across profiles. + return ` +;(() => { + try { + const nodeModule = require('node:module') + if (typeof nodeModule.enableCompileCache === 'function') { + const result = nodeModule.enableCompileCache() + if (result && result.directory && typeof process !== 'undefined' && process.env) { + // Why: child processes (daemon, plugin-host, sidecars) inherit env and reuse the cache. + process.env.NODE_COMPILE_CACHE = result.directory + } + } + } catch { + // A cache-dir failure must never block startup. + } +})(); +` +} + function createMainBootstrapPlugin() { return { name: 'orca-main-bootstrap', @@ -194,6 +220,7 @@ function createMainBootstrapPlugin() { // prelude, too late to handle a missing bootstrap dependency. mainChunk.code = createBootstrapFatalExitBanner() + + createCompileCacheBanner() + createStartupDiagnosticsBanner(mainChunk.fileName) + mainChunk.code } diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 9cd9b1aede7c..bd3c69d66db7 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -366,6 +366,8 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b startCrashpadCapture() state.crashReports = CrashReportStore.fromUserData() state.gpuCrashDiagnostics = createGpuCrashDiagnosticsRecorder() + // Why: the main graph is already cached by the build-time banner; this only pins + // NODE_COMPILE_CACHE for forked children and covers banner-less entry paths. enableMainProcessCompileCache() recordCrashBreadcrumb('app_started', { packaged: app.isPackaged, diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 07e29c2d3fba..4403659a2b92 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -44,7 +44,6 @@ import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' import { browserSessionRegistry } from '../browser/browser-session-registry' import { logStartupMilestone } from './startup-diagnostics' -import { configureSessionCodeCache } from './native-code-cache' import { writeHttp1CompatibilityMarker } from './http1-compatibility-marker' import { mainProcessState as state } from './main-process-state' import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' @@ -58,7 +57,6 @@ import { reportProfileStateWriteFailure } from './profile-state-write-failure' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') - configureSessionCodeCache(session.defaultSession) // Why: a headless automated run must not claim a macOS Dock tile or the menu bar. applyBackgroundActivationPolicy({ warn: console.warn }) installElectronProxyRequestGuard(session.defaultSession) diff --git a/src/main/startup/native-code-cache.test.ts b/src/main/startup/native-code-cache.test.ts index ef1ca4d1123a..66ef7be804e0 100644 --- a/src/main/startup/native-code-cache.test.ts +++ b/src/main/startup/native-code-cache.test.ts @@ -1,12 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { Session } from 'electron' -import { configureSessionCodeCache, enableMainProcessCompileCache } from './native-code-cache' - -vi.mock('electron', () => ({ - app: { - getPath: vi.fn((name: string) => `/mock/user/data/${name}`) - } -})) +import { enableMainProcessCompileCache } from './native-code-cache' describe('enableMainProcessCompileCache', () => { const originalEnv = process.env.NODE_COMPILE_CACHE @@ -31,25 +24,23 @@ describe('enableMainProcessCompileCache', () => { expect(process.env.NODE_COMPILE_CACHE).toBe(result.directory) }) - it('safely handles missing cache dir fallback', () => { - const result = enableMainProcessCompileCache() - expect(result.enabled).toBe(true) - expect(result.directory).toBeTruthy() + it('is idempotent: repeat calls keep reporting the enabled state without throwing', () => { + const first = enableMainProcessCompileCache('/tmp/mock-cache') + const second = enableMainProcessCompileCache('/tmp/mock-cache') + expect(first.enabled).toBe(true) + // Node returns ALREADY_ENABLED with the original directory; the wrapper must + // still report enabled so callers cannot mistake the state for a failure. + expect(second.enabled).toBe(true) + expect(second.directory).toBeTruthy() }) -}) -describe('configureSessionCodeCache', () => { - it('calls setCodeCachePath on the target session with app userData Code Cache path', () => { - const mockSession: Pick = { - setCodeCachePath: vi.fn() - } - - const configured = configureSessionCodeCache(mockSession) - expect(configured).toBe(true) - expect(mockSession.setCodeCachePath).toHaveBeenCalledWith('/mock/user/data/userData/Code Cache') - }) - - it('returns false gracefully if setCodeCachePath throws or is missing', () => { - expect(configureSessionCodeCache({} as Pick)).toBe(false) + it('exposes the runtime enable result shape for the build-banner fallback path', () => { + // The build-time banner (electron.vite.config.ts) enables the cache before the + // main graph evaluates; this module is the in-process fallback. Both must agree + // on the contract: enabled + directory, never throw on a missing cache dir. + const result = enableMainProcessCompileCache() + expect(result.enabled).toBe(true) + expect(typeof result.directory).toBe('string') + expect(result.error).toBeUndefined() }) }) diff --git a/src/main/startup/native-code-cache.ts b/src/main/startup/native-code-cache.ts index 28121ba26a1e..330a1c48f0a7 100644 --- a/src/main/startup/native-code-cache.ts +++ b/src/main/startup/native-code-cache.ts @@ -1,5 +1,4 @@ import { join } from 'node:path' -import type { Session } from 'electron' import { app } from 'electron' export type NativeCodeCacheResult = { @@ -8,7 +7,10 @@ export type NativeCodeCacheResult = { error?: string } -// Why: persist compiled V8 bytecode to disk so warm launches bypass JS parsing and AST allocation spikes. +// Why: the main-process graph itself is cached by the build-time compile-cache banner +// prepended to out/main/index.js (see electron.vite.config.ts) — this runtime call is +// only the idempotent fallback for entry paths that skip the banner. It pins the +// resolved cache dir into NODE_COMPILE_CACHE so forked children reuse the same cache. export function enableMainProcessCompileCache(customCacheDir?: string): NativeCodeCacheResult { try { const nodeModule = require('node:module') as { @@ -42,22 +44,3 @@ export function enableMainProcessCompileCache(customCacheDir?: string): NativeCo } } } - -// Why: ensure Chromium session stores compiled web script bytecode persistently in user profile. -export function configureSessionCodeCache( - targetSession: Pick -): boolean { - try { - if ( - typeof targetSession?.setCodeCachePath === 'function' && - typeof app?.getPath === 'function' - ) { - const codeCachePath = join(app.getPath('userData'), 'Code Cache') - targetSession.setCodeCachePath(codeCachePath) - return true - } - return false - } catch { - return false - } -} From 8e6efd46e6ff2bc141fa8e22e0ad9f4677e11b2d Mon Sep 17 00:00:00 2001 From: Joaquin Terrasa Date: Wed, 9 Sep 2026 12:19:07 +0200 Subject: [PATCH 3/3] test(startup): use portable tmpdir paths in compile cache tests Hardcoded /tmp/mock-cache resolves to a drive root on Windows where mkdirSync can fail; derive from os.tmpdir() instead. --- src/main/startup/native-code-cache.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/startup/native-code-cache.test.ts b/src/main/startup/native-code-cache.test.ts index 66ef7be804e0..22589cbd71e8 100644 --- a/src/main/startup/native-code-cache.test.ts +++ b/src/main/startup/native-code-cache.test.ts @@ -1,3 +1,5 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { enableMainProcessCompileCache } from './native-code-cache' @@ -18,15 +20,16 @@ describe('enableMainProcessCompileCache', () => { }) it('enables compile cache and sets process.env.NODE_COMPILE_CACHE', () => { - const result = enableMainProcessCompileCache('/tmp/mock-cache') + const result = enableMainProcessCompileCache(join(tmpdir(), 'orca-compile-cache-test')) expect(result.enabled).toBe(true) expect(result.directory).toBeTruthy() expect(process.env.NODE_COMPILE_CACHE).toBe(result.directory) }) it('is idempotent: repeat calls keep reporting the enabled state without throwing', () => { - const first = enableMainProcessCompileCache('/tmp/mock-cache') - const second = enableMainProcessCompileCache('/tmp/mock-cache') + const cacheDir = join(tmpdir(), 'orca-compile-cache-test') + const first = enableMainProcessCompileCache(cacheDir) + const second = enableMainProcessCompileCache(cacheDir) expect(first.enabled).toBe(true) // Node returns ALREADY_ENABLED with the original directory; the wrapper must // still report enabled so callers cannot mistake the state for a failure.