Skip to content

Commit 07f25ea

Browse files
perf: push sandboxed renderer startup data via mojo and cache preload bytecode (#51602)
* perf: push sandboxed renderer startup data via mojo instead of sync IPC Replaces the BROWSER_SANDBOX_LOAD sync IPC pull with a mojo push (ElectronFrameStartup.SetStartupData) sent from the browser at ReadyToCommitNavigation, ahead of CommitNavigation. The push carries preload script contents (as BigBuffer, shared-memory backed for large bundles), the browser's environment snapshot, and process.helperExecPath. The sync IPC parked the renderer main thread on a browser UI-thread roundtrip during every navigation. Its handler was async with await fs.promises.readFile() per preload, so under UI-thread contention each await yielded a turn to other startup work and the stall amplified ~5-8x with two preloads — measured at ~97ms with 20ms busy chunks. The push eliminates the roundtrip entirely; the renderer reads the data from a per-frame cache that is guaranteed to be populated before DidCreateScriptContext fires, because the message is associated with the navigation channel and ordered before CommitNavigation. The legacy sync IPC remains as a fallback for the cases the push does not yet cover (devtools extension subframes, service worker preload realms). process.arch/platform/version/versions are no longer shipped over the wire — they are identical in the renderer (same Electron binary) and are populated from node::per_process::metadata locally. * perf: remove BROWSER_SANDBOX_LOAD sync IPC entirely The previous commit kept the sync IPC as a fallback for paths that don't get a per-frame ElectronFrameStartup push: service worker preload realms (no RenderFrame, worker thread) and window.open() child windows whose synchronous about:blank document is created before the browser knows the window exists. Both now have a dedicated push and the IPC is removed: Service workers — a per-process associated mojom::ElectronWorkerStartup interface is registered via a RenderThreadObserver and the browser pushes from RenderProcessHostObserver::RenderProcessReady(), strictly before any StartWorker can spawn a worker thread. Cached behind a lock + WaitableEvent so the worker thread can block on it. Re-pushed when SW preload scripts are registered or unregistered while a renderer is alive. window.open() child windows — the popup's RenderFrame, its synchronous about:blank document, and its preload all happen inside the renderer's window.open() call stack, after the [Sync] CreateNewWindow IPC returns and before control returns to page script. No async browser-to-renderer mojo message can land in time. The browser does, however, know everything about the popup during CreateNewWindow (it just ran setWindowOpenHandler and may have overridden the preload, partition, etc.), so the only correct zero-extra-IPC transport is to attach the data to the CreateNewWindowReply itself. A new Chromium patch adds an opaque mojo_base.BigBuffer? field to CreateNewWindowReply, plus ContentBrowserClient::GetExtraCreateNewWindowReplyData() and ContentRendererClient::SetPendingCreateNewWindowStartupData() hooks; the renderer stashes the deserialized data process-globally for the next ElectronApiServiceImpl constructor on the same call stack. The browser-side data is built by a new renderer_startup_data::Build() helper shared by the navigation push, the per-process SW push, and the CreateNewWindowReply path. process.env is captured fresh per push via uv_os_environ() — same per-navigation timing as the legacy { ...process.env } spread. process.arch/platform/version/versions are filled from node::per_process::metadata in the renderer (same Electron binary) instead of being shipped over the wire; cldr/tz are runtime ICU state and computed directly from the renderer's ICU. BROWSER_NONSANDBOX_LOAD remains for non-sandboxed renderers — it returns preload paths only (no contents, no env spread), so the contention amplification doesn't apply. Verified against stock Electron 42.0.1 that window.open() popups with setWindowOpenHandler-overridden preloads behave identically. * perf: V8 code cache for sandboxed preload scripts Adds an in-memory + on-disk V8 code cache for preload scripts so the parse + top-level codegen cost is paid once per preload per Electron version instead of per navigation, and moves preload script compilation fully into native code so the contents and cache never bounce through the V8 heap. createPreloadScript() now takes (scriptId, paramNames) instead of a wrapped source string. It looks up the preload contents and code cache directly from the per-frame ElectronApiServiceImpl's mojo-cached startup data — the contents stay in the BigBuffer until a single NewFromUtf8() copy for the compile (rather than BigBuffer → V8 string → template-literal concat → flatten, ~150 KB of allocation and 2-3 copies per preload eliminated), and the cache bytes never enter V8 at all (BufferNotOwned for consume, direct BigBuffer construction from the produced CachedData for ship-back over ElectronWebContentsUtility.SetPreloadCodeCache). BuildStartupData() exposes hasContents instead of contents to JS. The compile uses ScriptCompiler::CompileFunction() with the parameter names directly instead of a string-templated (function(p0, p1, ...){…}) wrapper — no concat, no flatten, and preload stack traces get correct line numbers (the wrapper offsets every line by 1 with no ScriptOrigin::lineOffset compensation). The browser keys the cache by sha256(id) and persists it to userData/Code Cache/electron-preload/. V8's CachedData validation handles invalidation: a blob from a different V8 version, flag set, or source is rejected at consume time, the renderer compiles from source and ships a fresh blob, and the stale one is overwritten. Producing the cache during the cold compile adds ~25% to the first navigation per launch; warm navigations skip the parse (~5 ms saved per nav in a debug build, ~22% reduction in pre-parse blocking time). Service-worker preload realms look up contents from a clone of the worker startup data captured on ServiceWorkerData when the preload realm is created. They have no per-frame ship-back channel so they consume but never produce caches; the experimental --service-worker-preload flow is otherwise unchanged. * test: add spec coverage for preload startup data push and code cache - preload code cache: produces and persists a blob to userData/Code Cache/electron-preload/, runs the preload identically when consuming the cache, rejects and re-produces a corrupt disk cache (V8 CachedData validation rejects garbage and version-mismatched blobs and the renderer self-heals). - preload script stack traces: errors report correct line numbers and the preload's real file path instead of <anonymous>, sandboxed and not. CompileFunction() with an explicit ScriptOrigin gets both right; the legacy template-string wrapper had neither. - service worker preload realm: process.env, process.execPath, process.arch/platform/version, and process.versions.electron/chrome/node are populated from the per-process ElectronWorkerStartup push. - window.open() popup preload: the popup's synchronous about:blank document runs the preload from setWindowOpenHandler's overrideBrowserWindowOptions when set (delivered via CreateNewWindowReply), no preload when only security prefs are inherited (matching legacy Electron), and the opener's preload when explicitly carried through the override. * Delete .claude/scheduled_tasks.lock * refactor: address PR review feedback Review fixups independent of the service-worker delivery change: - preload_code_cache: DCHECK Get/Set are UI-thread only; SKIP_ON_SHUTDOWN for the disk write so an in-flight write isn't abandoned mid-write. Did NOT take the suggested first-writer-wins guard — Get() memoizes a corrupt on-disk blob (only V8 can validate it), so a guard keying on 'entry exists' would pin a bad cache and break the self-heal path; the duplicate-ship case is rare, identical bytes, and Set() is UI-thread serialized so it isn't a data race. - WebContentsPreferences::ShouldUseSandbox(): extract the duplicated 'null prefs => default sandboxed, --enable-sandbox forces sandbox' check used by the per-frame push. - preload_utils: LookupPreloadScript returns const PreloadScriptData* not const unique_ptr<>*; param name V8 conversion moved below the null-check so a missing script bails without paying for it. - electron_api_service_impl: drop unused TakeStartupData(); DCHECK the window.open pending-data slot is touched only on the renderer main thread; tighten the stale startup_data() comment. - Stop assigning the unused PreloadScriptData.type field (the field itself is removed in the following commit with its last setters). * perf: deliver service worker preload data via EmbeddedWorkerStartParams Replaces the per-process ElectronWorkerStartup push + the renderer-side cross-thread global (ElectronRenderThreadObserver: a process-global WaitableEvent + lock the SW worker thread could block on) with delivery through the service worker's own EmbeddedWorkerStartParams. A new ContentBrowserClient::GetServiceWorkerStartupData() hook populates an opaque BigBuffer on the start params; it rides the marshalling StartWorker already performs (GlobalScopeCreationParams moved onto the worker thread by WorkerThread::Start), so the data arrives on the worker thread ordered with worker creation — no global, no lock, no blocking wait, and no 'worker raced the push' edge case to handle. The renderer reads it off WebServiceWorkerContextProxy::ElectronPreloadData() (backed by WorkerGlobalScope) when it builds the preload realm. GetServiceWorkerStartupData() early-returns when the session has no service-worker preloads, so the asar reads + serialization no longer run on every renderer at RenderProcessReady() (addresses the SendToProcess cost raised in review). Registry changes are picked up automatically — the data is rebuilt from SessionPreferences on every StartWorker — so Session::BroadcastWorkerStartupData and its re-push on register/ unregister are removed. Also folds, since they share these files: removal of the now-unused PreloadScriptData.type field and its last setters, the CreateNewWindowReply target_url gate so the embedder blob isn't built for popups that navigate (review feedback), and the corrected CreateNewWindowReply patch commit message.
1 parent dcb4bef commit 07f25ea

42 files changed

Lines changed: 1717 additions & 101 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

filenames.gni

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,12 +492,16 @@ filenames = {
492492
"shell/browser/osr/osr_web_contents_view.h",
493493
"shell/browser/plugins/plugin_utils.cc",
494494
"shell/browser/plugins/plugin_utils.h",
495+
"shell/browser/preload_code_cache.cc",
496+
"shell/browser/preload_code_cache.h",
495497
"shell/browser/preload_script.cc",
496498
"shell/browser/preload_script.h",
497499
"shell/browser/protocol_registry.cc",
498500
"shell/browser/protocol_registry.h",
499501
"shell/browser/relauncher.cc",
500502
"shell/browser/relauncher.h",
503+
"shell/browser/renderer_startup_data.cc",
504+
"shell/browser/renderer_startup_data.h",
501505
"shell/browser/serial/electron_serial_delegate.cc",
502506
"shell/browser/serial/electron_serial_delegate.h",
503507
"shell/browser/serial/serial_chooser_context.cc",

lib/browser/rpc-server.ts

Lines changed: 14 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
55
import { clipboard } from 'electron/common';
66
import { webFrameMain } from 'electron/main';
77

8-
import * as fs from 'fs';
98
import * as path from 'path';
109

1110
// Implements window.close()
@@ -49,60 +48,23 @@ ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_CLIPBOARD_SYNC, function (event, me
4948
return (clipboard as any)[method](...args);
5049
});
5150

52-
const getPreloadScriptsFromEvent = (event: ElectronInternal.IpcMainInternalEvent) => {
53-
const session: Electron.Session = event.type === 'service-worker' ? event.session : event.sender.session;
54-
let preloadScripts = session.getPreloadScripts();
55-
56-
if (event.type === 'frame') {
57-
preloadScripts = preloadScripts.filter((script) => script.type === 'frame');
58-
59-
const webPrefPreload = event.sender._getPreloadScript();
60-
if (webPrefPreload) preloadScripts.push(webPrefPreload);
61-
} else if (event.type === 'service-worker') {
62-
preloadScripts = preloadScripts.filter((script) => script.type === 'service-worker');
63-
} else {
64-
throw new Error(`getPreloadScriptsFromEvent: event.type is invalid (${(event as any).type})`);
51+
// Sandboxed renderers receive their preload scripts and process info via the
52+
// browser-pushed ElectronFrameStartup mojo interface for frames, or
53+
// EmbeddedWorkerStartParams for service workers (see
54+
// electron_api_web_contents.cc and electron_browser_client.cc), not over
55+
// sync IPC. This handler is only used by non-sandboxed renderers, which read
56+
// their own preload files from disk and only need the path list.
57+
ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_NONSANDBOX_LOAD, function (event) {
58+
if (event.type !== 'frame') {
59+
throw new Error(`BROWSER_NONSANDBOX_LOAD: invalid event.type (${(event as any).type})`);
6560
}
66-
61+
const session: Electron.Session = event.sender.session;
62+
let preloadScripts = session.getPreloadScripts().filter((script) => script.type === 'frame');
63+
const webPrefPreload = event.sender._getPreloadScript();
64+
if (webPrefPreload) preloadScripts.push(webPrefPreload);
6765
// TODO(samuelmaddock): Remove filter after Session.setPreloads is fully
6866
// deprecated. The new API will prevent relative paths from being registered.
69-
return preloadScripts.filter((script) => path.isAbsolute(script.filePath));
70-
};
71-
72-
const readPreloadScript = async function (script: Electron.PreloadScript): Promise<ElectronInternal.PreloadScript> {
73-
let contents;
74-
let error;
75-
try {
76-
contents = await fs.promises.readFile(script.filePath, 'utf8');
77-
} catch (err) {
78-
if (err instanceof Error) {
79-
error = err;
80-
}
81-
}
82-
return {
83-
...script,
84-
contents,
85-
error
86-
};
87-
};
88-
89-
ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_SANDBOX_LOAD, async function (event) {
90-
const preloadScripts = getPreloadScriptsFromEvent(event);
91-
return {
92-
preloadScripts: await Promise.all(preloadScripts.map(readPreloadScript)),
93-
process: {
94-
arch: process.arch,
95-
platform: process.platform,
96-
env: { ...process.env },
97-
version: process.version,
98-
versions: process.versions,
99-
execPath: process.helperExecPath
100-
}
101-
};
102-
});
103-
104-
ipcMainUtils.handleSync(IPC_MESSAGES.BROWSER_NONSANDBOX_LOAD, function (event) {
105-
const preloadScripts = getPreloadScriptsFromEvent(event);
67+
preloadScripts = preloadScripts.filter((script) => path.isAbsolute(script.filePath));
10668
return { preloadPaths: preloadScripts.map((script) => script.filePath) };
10769
});
10870

lib/common/ipc-messages.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ export const enum IPC_MESSAGES {
22
BROWSER_CLIPBOARD_SYNC = 'BROWSER_CLIPBOARD_SYNC',
33
BROWSER_GET_LAST_WEB_PREFERENCES = 'BROWSER_GET_LAST_WEB_PREFERENCES',
44
BROWSER_PRELOAD_ERROR = 'BROWSER_PRELOAD_ERROR',
5-
BROWSER_SANDBOX_LOAD = 'BROWSER_SANDBOX_LOAD',
65
BROWSER_NONSANDBOX_LOAD = 'BROWSER_NONSANDBOX_LOAD',
76
BROWSER_WINDOW_CLOSE = 'BROWSER_WINDOW_CLOSE',
87
BROWSER_GET_PROCESS_MEMORY_INFO = 'BROWSER_GET_PROCESS_MEMORY_INFO',

lib/preload_realm/init.ts

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import '@electron/internal/sandboxed_renderer/pre-init';
2-
import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
3-
import type * as ipcRendererUtilsModule from '@electron/internal/renderer/ipc-renderer-internal-utils';
42
import {
53
createPreloadProcessObject,
64
executeSandboxedPreloadScripts
@@ -11,16 +9,18 @@ import * as events from 'events';
119
declare const binding: {
1210
get: (name: string) => any;
1311
process: NodeJS.Process;
14-
createPreloadScript: (src: string) => Function;
12+
createPreloadScript: (scriptId: string, paramNames: string[]) => Function | null;
13+
// Delivered by the browser via the service worker's EmbeddedWorkerStartParams
14+
// (ContentBrowserClient::GetServiceWorkerStartupData), marshalled onto the
15+
// worker thread with the rest of the start params — always present when this
16+
// bundle runs.
17+
startupData: {
18+
preloadScripts: ElectronInternal.PreloadScript[];
19+
process: NodeJS.Process;
20+
};
1521
};
1622

17-
const ipcRendererUtils =
18-
require('@electron/internal/renderer/ipc-renderer-internal-utils') as typeof ipcRendererUtilsModule;
19-
20-
const { preloadScripts, process: processProps } = ipcRendererUtils.invokeSync<{
21-
preloadScripts: ElectronInternal.PreloadScript[];
22-
process: NodeJS.Process;
23-
}>(IPC_MESSAGES.BROWSER_SANDBOX_LOAD);
23+
const { preloadScripts, process: processProps } = binding.startupData;
2424

2525
const electron = require('electron');
2626

lib/sandboxed_renderer/init.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import '@electron/internal/sandboxed_renderer/pre-init';
2-
import { IPC_MESSAGES } from '@electron/internal/common/ipc-messages';
3-
import type * as ipcRendererUtilsModule from '@electron/internal/renderer/ipc-renderer-internal-utils';
42
import {
53
createPreloadProcessObject,
64
executeSandboxedPreloadScripts
@@ -11,16 +9,17 @@ import { setImmediate, clearImmediate } from 'timers';
119

1210
declare const binding: {
1311
process: NodeJS.Process;
14-
createPreloadScript: (src: string) => Function;
12+
createPreloadScript: (scriptId: string, paramNames: string[]) => Function | null;
13+
// Pushed by the browser via mojom.ElectronFrameStartup, ordered ahead of
14+
// the CommitNavigation that triggered DidCreateScriptContext — always
15+
// present for documents that reach this bundle.
16+
startupData: {
17+
preloadScripts: ElectronInternal.PreloadScript[];
18+
process: NodeJS.Process;
19+
};
1520
};
1621

17-
const ipcRendererUtils =
18-
require('@electron/internal/renderer/ipc-renderer-internal-utils') as typeof ipcRendererUtilsModule;
19-
20-
const { preloadScripts, process: processProps } = ipcRendererUtils.invokeSync<{
21-
preloadScripts: ElectronInternal.PreloadScript[];
22-
process: NodeJS.Process;
23-
}>(IPC_MESSAGES.BROWSER_SANDBOX_LOAD);
22+
const { preloadScripts, process: processProps } = binding.startupData;
2423

2524
const electron = require('electron');
2625

lib/sandboxed_renderer/preload.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ interface PreloadContext {
1010
/** Process object to pass into preloads. */
1111
process: NodeJS.Process;
1212

13-
createPreloadScript: (src: string) => Function;
13+
/**
14+
* Compiles a preload script's body and returns the resulting function. The
15+
* preload contents and V8 code cache are looked up by id from the mojo
16+
* startup data on the C++ side — they never become V8 strings until the
17+
* single copy for the compile, and the cache bytes never enter JS at all.
18+
* `paramNames` is the function's parameter list (CompileFunction(), not a
19+
* string-templated wrapper, so preload stack traces have correct line
20+
* numbers). Returns null if the script id is unknown.
21+
*/
22+
createPreloadScript: (scriptId: string, paramNames: string[]) => Function | null;
1423

1524
/** Globals to be exposed to preload context. */
1625
exposeGlobals: any;
@@ -56,26 +65,28 @@ function preloadRequire(context: PreloadContext, module: string) {
5665
throw new Error(`module not found: ${module}`);
5766
}
5867

59-
// Wrap the script into a function executed in global scope. It won't have
60-
// access to the current scope, so we'll expose a few objects as arguments:
68+
// Compile and run a preload script as a function with these parameters in
69+
// scope, plus whatever's in `context.exposeGlobals`:
6170
//
6271
// - `require`: The `preloadRequire` function
6372
// - `process`: The `preloadProcess` object
6473
// - `Buffer`: Shim of `Buffer` implementation
6574
// - `global`: The window object, which is aliased to `global` by webpack.
66-
function runPreloadScript(context: PreloadContext, preloadSrc: string) {
75+
function runPreloadScript(context: PreloadContext, script: ElectronInternal.PreloadScript) {
6776
const globalVariables = [];
6877
const fnParameters = [];
6978
for (const [key, value] of Object.entries(context.exposeGlobals)) {
7079
globalVariables.push(key);
7180
fnParameters.push(value);
7281
}
73-
const preloadWrapperSrc = `(function(require, process, exports, module, ${globalVariables.join(', ')}) {
74-
${preloadSrc}
75-
})`;
76-
77-
// eval in window scope
78-
const preloadFn = context.createPreloadScript(preloadWrapperSrc);
82+
// The body and code cache are looked up from the mojo-cached startup data on
83+
// the C++ side keyed by script.id — neither crosses the V8 boundary. The
84+
// (paramNames, contents) pair is a deterministic function of (preload file,
85+
// Electron version), which is what makes the persisted code cache valid
86+
// across navigations and launches.
87+
const paramNames = ['require', 'process', 'exports', 'module', ...globalVariables];
88+
const preloadFn = context.createPreloadScript(script.id, paramNames);
89+
if (!preloadFn) return;
7990
const exports = {};
8091

8192
preloadFn(preloadRequire.bind(null, context), context.process, exports, { exports }, ...fnParameters);
@@ -88,10 +99,11 @@ export function executeSandboxedPreloadScripts(
8899
context: PreloadContext,
89100
preloadScripts: ElectronInternal.PreloadScript[]
90101
) {
91-
for (const { filePath, contents, error } of preloadScripts) {
102+
for (const script of preloadScripts) {
103+
const { filePath, hasContents, error } = script;
92104
try {
93-
if (contents) {
94-
runPreloadScript(context, contents);
105+
if (hasContents) {
106+
runPreloadScript(context, script);
95107
} else if (error) {
96108
// eslint-disable-next-line no-throw-literal
97109
throw error;

patches/chromium/.patches

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,5 @@ build_gn_arg_to_support_linker_wrapper_script_on_windows.patch
153153
fix_use_bundled_devtools_frontend_url_for_remote_debugging.patch
154154
fix_constrain_allowuniversalaccessfromfileurls_to_file_origins_in.patch
155155
fix_add_reinitializegeneratedcodecachecontext_to.patch
156+
feat_carry_embedder_startup_data_in_createnewwindowreply.patch
157+
feat_deliver_service_worker_preload_data_via_embeddedworkerstartparams.patch

0 commit comments

Comments
 (0)