perf: build-time V8 code cache for the electron/js2c framework bundles#51697
Merged
Conversation
The hoisted get-intrinsic resolved to 1.2.1 (satisfying the older ^1.0.2..^1.2.0 consumers), so side-channel-weakmap, side-channel-map, call-bound and es-set-tostringtag — which need ^1.2.5/^1.3.0 — each pulled a nested get-intrinsic@1.3.0 copy. webpack bundled three of those nested copies into sandbox_bundle.js (~16.5 KB each). 1.3.0 satisfies every range in the tree (all ^1.x), so pin it via resolutions to collapse to a single copy. Drops sandbox_bundle.js from 364,762 to 323,116 bytes (-41,646, -11.4%) — that bundle is parsed/preparsed in every sandboxed renderer before the HTML parser, so this is a direct per-renderer startup win on top of the IPC and code-cache changes.
72c2b7d to
151ccd3
Compare
7e7aec5 to
6e005b4
Compare
2 tasks
VerteDinde
approved these changes
May 19, 2026
VerteDinde
left a comment
Member
There was a problem hiding this comment.
Excited to see this in action
00c0946 to
5e6ed2c
Compare
deepak1556
reviewed
May 20, 2026
deepak1556
left a comment
Member
There was a problem hiding this comment.
Looks great overall, thanks!
5e6ed2c to
61f3dac
Compare
fec0279 to
f24bca3
Compare
Generate a V8 code cache for Electron's embedded electron/js2c/* bundles at build time and embed it in the binary, so each process deserializes its framework bundle (kConsumeCodeCache) instead of parsing and compiling it from source at startup. V8's code-cache key includes FlagList::Hash() over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so one cache variant is generated per flavor and each process consumes the matching one: sandbox : sandboxed renderer, no Node env (+SAB) renderer : normal renderer, has Node env (+SAB +rehash +no-freeze) browser : main process (+rehash +no-freeze) utility : utility process (+rehash +no-freeze) worker : node/web worker (+rehash +no-freeze) All flavors create their isolate from the v8 context snapshot. The flag deltas are deterministic; --no-freeze-flags-after-init (present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every node-env process includes --rehash-snapshot. A sandboxed renderer has no Node env (flags stay frozen) so it is a distinct flavor, discriminated at runtime by node::Environment::GetCurrent(context). The generator compiles each bundle with kEagerCompile so the cache covers the inner functions, not just the top-level wrapper -- the framework bundles run in full at startup, so this front-loads the lazy compiles to build time. Cross-arch is supported (the generator is built in v8_snapshot_toolchain so it links V8 with V8_TARGET_ARCH set to the target and reads the target's snapshot blob); cross-OS is untested and gated off. - electron_natives_codecache_main.cc: per-flavor build-time generator compiling each bundle through the same BuiltinLoader path its runtime consumer uses. - shell/common/js2c_bundle_ids.h: shared bundle ids and wrapper-function param names used by the generator and the runtime CompileAndCall call sites, so the source/params hash matches by construction. - node_util.cc feeds the standalone loader; FeedEnvironmentCodeCache feeds the per-Environment loader before LoadEnvironment so the *_init bundles are consumed. - Test-only (DCHECK builds, compiled out of production): a per-process id->accepted status map exposed via v8_util.getJs2cCodeCacheStatus, a sandboxed-preload shim, and spec/api-js2c-code-cache-spec.ts which spawns a clean app and asserts every bundle is consumed in browser/sandbox/renderer/utility. DidClearWindowObject (sandboxed renderer, pre-HTML-parser): ~9.8 ms (cache rejected/absent) -> ~6.4 ms (eager cache consumed), a ~3.4 ms / ~35% cold-start win on every launch. No V8 patch.
f24bca3 to
8baf30e
Compare
jkleinsc
previously requested changes
May 20, 2026
jkleinsc
left a comment
Member
There was a problem hiding this comment.
Newly added patch needs a description
8baf30e to
126333a
Compare
done, also fixed lint (which was blocking on this)
|
Release Notes Persisted
|
Contributor
|
@MarshallOfSound has manually backported this PR to "43-x-y", please check out #51792 |
MarshallOfSound
added a commit
that referenced
this pull request
May 28, 2026
#51697) * build: dedupe get-intrinsic to shrink the sandboxed renderer bundle The hoisted get-intrinsic resolved to 1.2.1 (satisfying the older ^1.0.2..^1.2.0 consumers), so side-channel-weakmap, side-channel-map, call-bound and es-set-tostringtag — which need ^1.2.5/^1.3.0 — each pulled a nested get-intrinsic@1.3.0 copy. webpack bundled three of those nested copies into sandbox_bundle.js (~16.5 KB each). 1.3.0 satisfies every range in the tree (all ^1.x), so pin it via resolutions to collapse to a single copy. Drops sandbox_bundle.js from 364,762 to 323,116 bytes (-41,646, -11.4%) — that bundle is parsed/preparsed in every sandboxed renderer before the HTML parser, so this is a direct per-renderer startup win on top of the IPC and code-cache changes. * perf: build-time V8 code cache for the electron/js2c framework bundles Generate a V8 code cache for Electron's embedded electron/js2c/* bundles at build time and embed it in the binary, so each process deserializes its framework bundle (kConsumeCodeCache) instead of parsing and compiling it from source at startup. V8's code-cache key includes FlagList::Hash() over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so one cache variant is generated per flavor and each process consumes the matching one: sandbox : sandboxed renderer, no Node env (+SAB) renderer : normal renderer, has Node env (+SAB +rehash +no-freeze) browser : main process (+rehash +no-freeze) utility : utility process (+rehash +no-freeze) worker : node/web worker (+rehash +no-freeze) All flavors create their isolate from the v8 context snapshot. The flag deltas are deterministic; --no-freeze-flags-after-init (present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every node-env process includes --rehash-snapshot. A sandboxed renderer has no Node env (flags stay frozen) so it is a distinct flavor, discriminated at runtime by node::Environment::GetCurrent(context). The generator compiles each bundle with kEagerCompile so the cache covers the inner functions, not just the top-level wrapper -- the framework bundles run in full at startup, so this front-loads the lazy compiles to build time. Cross-arch is supported (the generator is built in v8_snapshot_toolchain so it links V8 with V8_TARGET_ARCH set to the target and reads the target's snapshot blob); cross-OS is untested and gated off. - electron_natives_codecache_main.cc: per-flavor build-time generator compiling each bundle through the same BuiltinLoader path its runtime consumer uses. - shell/common/js2c_bundle_ids.h: shared bundle ids and wrapper-function param names used by the generator and the runtime CompileAndCall call sites, so the source/params hash matches by construction. - node_util.cc feeds the standalone loader; FeedEnvironmentCodeCache feeds the per-Environment loader before LoadEnvironment so the *_init bundles are consumed. - Test-only (DCHECK builds, compiled out of production): a per-process id->accepted status map exposed via v8_util.getJs2cCodeCacheStatus, a sandboxed-preload shim, and spec/api-js2c-code-cache-spec.ts which spawns a clean app and asserts every bundle is consumed in browser/sandbox/renderer/utility. DidClearWindowObject (sandboxed renderer, pre-HTML-parser): ~9.8 ms (cache rejected/absent) -> ~6.4 ms (eager cache consumed), a ~3.4 ms / ~35% cold-start win on every launch. No V8 patch. * chore: export node patch for the js2c build-time code cache (cherry picked from commit d857117)
MarshallOfSound
added a commit
that referenced
this pull request
May 31, 2026
#51697) * build: dedupe get-intrinsic to shrink the sandboxed renderer bundle The hoisted get-intrinsic resolved to 1.2.1 (satisfying the older ^1.0.2..^1.2.0 consumers), so side-channel-weakmap, side-channel-map, call-bound and es-set-tostringtag — which need ^1.2.5/^1.3.0 — each pulled a nested get-intrinsic@1.3.0 copy. webpack bundled three of those nested copies into sandbox_bundle.js (~16.5 KB each). 1.3.0 satisfies every range in the tree (all ^1.x), so pin it via resolutions to collapse to a single copy. Drops sandbox_bundle.js from 364,762 to 323,116 bytes (-41,646, -11.4%) — that bundle is parsed/preparsed in every sandboxed renderer before the HTML parser, so this is a direct per-renderer startup win on top of the IPC and code-cache changes. * perf: build-time V8 code cache for the electron/js2c framework bundles Generate a V8 code cache for Electron's embedded electron/js2c/* bundles at build time and embed it in the binary, so each process deserializes its framework bundle (kConsumeCodeCache) instead of parsing and compiling it from source at startup. V8's code-cache key includes FlagList::Hash() over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so one cache variant is generated per flavor and each process consumes the matching one: sandbox : sandboxed renderer, no Node env (+SAB) renderer : normal renderer, has Node env (+SAB +rehash +no-freeze) browser : main process (+rehash +no-freeze) utility : utility process (+rehash +no-freeze) worker : node/web worker (+rehash +no-freeze) All flavors create their isolate from the v8 context snapshot. The flag deltas are deterministic; --no-freeze-flags-after-init (present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every node-env process includes --rehash-snapshot. A sandboxed renderer has no Node env (flags stay frozen) so it is a distinct flavor, discriminated at runtime by node::Environment::GetCurrent(context). The generator compiles each bundle with kEagerCompile so the cache covers the inner functions, not just the top-level wrapper -- the framework bundles run in full at startup, so this front-loads the lazy compiles to build time. Cross-arch is supported (the generator is built in v8_snapshot_toolchain so it links V8 with V8_TARGET_ARCH set to the target and reads the target's snapshot blob); cross-OS is untested and gated off. - electron_natives_codecache_main.cc: per-flavor build-time generator compiling each bundle through the same BuiltinLoader path its runtime consumer uses. - shell/common/js2c_bundle_ids.h: shared bundle ids and wrapper-function param names used by the generator and the runtime CompileAndCall call sites, so the source/params hash matches by construction. - node_util.cc feeds the standalone loader; FeedEnvironmentCodeCache feeds the per-Environment loader before LoadEnvironment so the *_init bundles are consumed. - Test-only (DCHECK builds, compiled out of production): a per-process id->accepted status map exposed via v8_util.getJs2cCodeCacheStatus, a sandboxed-preload shim, and spec/api-js2c-code-cache-spec.ts which spawns a clean app and asserts every bundle is consumed in browser/sandbox/renderer/utility. DidClearWindowObject (sandboxed renderer, pre-HTML-parser): ~9.8 ms (cache rejected/absent) -> ~6.4 ms (eager cache consumed), a ~3.4 ms / ~35% cold-start win on every launch. No V8 patch. * chore: export node patch for the js2c build-time code cache (cherry picked from commit d857117)
ckerr
pushed a commit
that referenced
this pull request
Jun 1, 2026
…1697, #51703) (#51792) * 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. (cherry picked from commit 07f25ea) * perf: build-time V8 code cache for the electron/js2c framework bundles (#51697) * build: dedupe get-intrinsic to shrink the sandboxed renderer bundle The hoisted get-intrinsic resolved to 1.2.1 (satisfying the older ^1.0.2..^1.2.0 consumers), so side-channel-weakmap, side-channel-map, call-bound and es-set-tostringtag — which need ^1.2.5/^1.3.0 — each pulled a nested get-intrinsic@1.3.0 copy. webpack bundled three of those nested copies into sandbox_bundle.js (~16.5 KB each). 1.3.0 satisfies every range in the tree (all ^1.x), so pin it via resolutions to collapse to a single copy. Drops sandbox_bundle.js from 364,762 to 323,116 bytes (-41,646, -11.4%) — that bundle is parsed/preparsed in every sandboxed renderer before the HTML parser, so this is a direct per-renderer startup win on top of the IPC and code-cache changes. * perf: build-time V8 code cache for the electron/js2c framework bundles Generate a V8 code cache for Electron's embedded electron/js2c/* bundles at build time and embed it in the binary, so each process deserializes its framework bundle (kConsumeCodeCache) instead of parsing and compiling it from source at startup. V8's code-cache key includes FlagList::Hash() over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so one cache variant is generated per flavor and each process consumes the matching one: sandbox : sandboxed renderer, no Node env (+SAB) renderer : normal renderer, has Node env (+SAB +rehash +no-freeze) browser : main process (+rehash +no-freeze) utility : utility process (+rehash +no-freeze) worker : node/web worker (+rehash +no-freeze) All flavors create their isolate from the v8 context snapshot. The flag deltas are deterministic; --no-freeze-flags-after-init (present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every node-env process includes --rehash-snapshot. A sandboxed renderer has no Node env (flags stay frozen) so it is a distinct flavor, discriminated at runtime by node::Environment::GetCurrent(context). The generator compiles each bundle with kEagerCompile so the cache covers the inner functions, not just the top-level wrapper -- the framework bundles run in full at startup, so this front-loads the lazy compiles to build time. Cross-arch is supported (the generator is built in v8_snapshot_toolchain so it links V8 with V8_TARGET_ARCH set to the target and reads the target's snapshot blob); cross-OS is untested and gated off. - electron_natives_codecache_main.cc: per-flavor build-time generator compiling each bundle through the same BuiltinLoader path its runtime consumer uses. - shell/common/js2c_bundle_ids.h: shared bundle ids and wrapper-function param names used by the generator and the runtime CompileAndCall call sites, so the source/params hash matches by construction. - node_util.cc feeds the standalone loader; FeedEnvironmentCodeCache feeds the per-Environment loader before LoadEnvironment so the *_init bundles are consumed. - Test-only (DCHECK builds, compiled out of production): a per-process id->accepted status map exposed via v8_util.getJs2cCodeCacheStatus, a sandboxed-preload shim, and spec/api-js2c-code-cache-spec.ts which spawns a clean app and asserts every bundle is consumed in browser/sandbox/renderer/utility. DidClearWindowObject (sandboxed renderer, pre-HTML-parser): ~9.8 ms (cache rejected/absent) -> ~6.4 ms (eager cache consumed), a ~3.4 ms / ~35% cold-start win on every launch. No V8 patch. * chore: export node patch for the js2c build-time code cache (cherry picked from commit d857117) * perf: boot the browser process from an embedded Node startup snapshot (#51703) * perf: boot the browser process from an embedded Node startup snapshot Build a Node startup snapshot at build time (node_mksnapshot, gated to native builds) and create the browser-process isolate from it so the Node bootstrap is deserialized instead of re-parsed and re-compiled at every app start. The snapshot is generated by extending the same V8 startup snapshot the rest of the process uses (snapshot_blob.bin), so the read-only heap is shared and V8's one-snapshot-per-process invariant holds. node_snapshot (a separate source_set in third_party/electron_node:unofficial.gni) provides the strong SnapshotBuilder::GetEmbeddedSnapshotData over a now- weak no-op stub; electron_lib deps it so the shipped framework links the real snapshot while node_mksnapshot (which only links libnode + the weak stub) does not -- avoiding the GN dependency cycle that v8_snapshot_ toolchain == default_toolchain would otherwise force on native builds. Browser process only: - javascript_environment.cc: when the embedded snapshot is present, CreateIsolateHolder feeds its blob + external references into the isolate CreateParams (gin passes nullptr external refs so there is no conflict; the browser process has no blink). The ctor skips node::NewContext -- the main context is materialized from the snapshot inside node::CreateEnvironment. - electron_browser_main_parts.cc: passes an empty context to Initialize/CreateEnvironment in the snapshot path; enters the deserialized context after. - node_bindings.cc: CreateEnvironment defers context-dependent setup until the snapshot's main context exists; CreateIsolateData receives the snapshot's per-isolate data; LoadEnvironment merges the build-time js2c cache for electron/js2c/* with the snapshot's per-builtin cache. Renderer / sandboxed renderer / utility / worker processes are unchanged. Measured impact (Testing build, macOS arm64): V8 compile/parse on the browser-process critical path drops from ~17 ms to ~12 ms top-level (V8.PreParse 1003 -> 24 events, V8.ParseProgram 8 -> 0.7 ms), but the 4.4 MB snapshot adds ~5 ms of deserialize cost (V8.DeserializeContext 2.6 ms + V8.DeserializeIsolate 2.0 ms + V8.CompileDeserialize 1.4 ms), so the net wall-clock change is within run-to-run noise. The Testing build keeps every DCHECK in V8's deserializer hot loop compiled in (dcheck_always_on=true); the snapshot-vs-recompile trade-off is expected to be more favorable in Release builds where those are compiled out and the deserialize is mostly memcpy + relocate. Needs a Release benchmark to determine whether this is a net win. The build-time js2c code cache for the browser process is generated against the embedded snapshot's isolate (a dedicated electron_natives_codecache_snapshot host tool links the real node_snapshot and compiles browser_init/node_init via GetEmbeddedSnapshotData + InitializeIsolateParams). A SnapshotCreator- produced snapshot owns a unique read-only-heap checksum that matches no standalone blob, so this is the only way the browser bundles' code-cache read-only checksum matches the runtime isolate; the other flavors keep using the v8 context snapshot. * fix: restore preventDefault on native events in the snapshot path The eager gin_helper Event-constructor warm-up in NodeBindings::Initialize was guarded out when booting from the embedded Node startup snapshot (the context is empty there), on the mistaken assumption the Event ObjectTemplate would be populated lazily. For gin Constructible classes the template -- which carries preventDefault()/defaultPrevented -- is only ever populated by GetConstructor(), so every native gin event emitted in the snapshot-booted browser process was missing those members (e.g. TypeError: event.preventDefault is not a function on -before-unload-fired, will-navigate, etc.). Run the warm-up in CreateEnvironment once the snapshot's main context has been materialized and entered. The template is cached per-isolate, so a single call covers the isolate lifetime as before. * fix: skip the embedded snapshot in the ELECTRON_RUN_AS_NODE path The ELECTRON_RUN_AS_NODE entry point (shell/app/node_main.cc, used by child_process.fork) runs without a --type switch, so IsBrowserProcess() is true for it and JavascriptEnvironment created its isolate from the embedded Node startup snapshot. But node_main.cc builds its IsolateData without snapshot_data and passes a fresh context, so node's CreateEnvironment tripped CHECK_NOT_NULL(isolate_data->snapshot_data()) and the forked process aborted. Gate the snapshot on a new IsRunningAsNode() helper so only the genuine browser process consumes it; the run-as-node path boots a fresh isolate as before. The run-as-node check is lifted out of crash_keys.cc into process_util so both callers share one definition. * fix: avoid double uncaughtException delivery in the snapshot path When the browser process boots from the embedded Node snapshot, node::CreateEnvironment registers the per-isolate message listener while deserializing the snapshot (SetIsolateErrorHandlers in node's src/api/environment.cc, reached only on the snapshot path). Electron's own SetIsolateUpForNode then registered the same listener again, and because V8 message listeners are additive, every uncaught error -- and the uncaughtException that follows -- was delivered twice. A throwing webContents.setWindowOpenHandler surfaced this (guest-window-manager spec). Skip Electron's duplicate registration when booting from the snapshot: the snapshot-registered PerIsolateMessageListener is identical, and the custom fatal/OOM handlers are setters that still take effect. * chore: address review feedback on the snapshot patch - node_mksnapshot: read the base V8 snapshot blob with node::ReadFileSync instead of ifstream/stringstream, reporting a clear error on a missing or unreadable path rather than silently producing an empty blob. - Fix a misleading comment: the Node startup snapshot's embedded code cache and Electron's build-time js2c cache are NOT disjoint -- both carry the electron/js2c/* bundles node_mksnapshot compiled -- so RefreshCodeCache's insert_or_assign (build-time entry wins) is intentional, not a latent replace-vs-emplace bug. Correct the same claim in LoadEnvironment. * fix: register fast-API CFunction objects as snapshot external references V8 CL 7828135 ("[fastapi] Store v8::CFunction pointer directly in FunctionTemplateInfo", already in our pinned V8) wraps the v8::CFunction in a Foreign instead of a Managed<>, which restores serialization of FunctionTemplateInfo. The snapshot serializer now encodes the CFunction object's address, so ExternalReferenceRegistry::Register(CFunction) must register &c_func alongside GetAddress()/GetTypeInfo(). With that, node_mksnapshot serializes fast-API FunctionTemplates with c_function intact, so the DropCFunctionForSnapshot workaround -- which forced the snapshot's templates onto their slow callbacks -- is removed. The browser process keeps the V8 fast-API paths. (cherry picked from commit 8b55291) * build: regenerate filenames.auto.gni for backported bundle change The renderer startup-data backport (#51602) drops the dependency on lib/renderer/ipc-renderer-internal-utils.ts from the sandboxed renderer bundle. Regenerate filenames.auto.gni so the gen-filenames --check CI step passes. * chore: update patches * chore: update patches --------- Co-authored-by: PatchUp <73610968+patchup[bot]@users.noreply.github.com>
Contributor
|
@MarshallOfSound has manually backported this PR to "42-x-y", please check out #51831 |
MarshallOfSound
added a commit
that referenced
this pull request
Jun 2, 2026
…1697, #51703) (#51792) * 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. (cherry picked from commit 07f25ea) * perf: build-time V8 code cache for the electron/js2c framework bundles (#51697) * build: dedupe get-intrinsic to shrink the sandboxed renderer bundle The hoisted get-intrinsic resolved to 1.2.1 (satisfying the older ^1.0.2..^1.2.0 consumers), so side-channel-weakmap, side-channel-map, call-bound and es-set-tostringtag — which need ^1.2.5/^1.3.0 — each pulled a nested get-intrinsic@1.3.0 copy. webpack bundled three of those nested copies into sandbox_bundle.js (~16.5 KB each). 1.3.0 satisfies every range in the tree (all ^1.x), so pin it via resolutions to collapse to a single copy. Drops sandbox_bundle.js from 364,762 to 323,116 bytes (-41,646, -11.4%) — that bundle is parsed/preparsed in every sandboxed renderer before the HTML parser, so this is a direct per-renderer startup win on top of the IPC and code-cache changes. * perf: build-time V8 code cache for the electron/js2c framework bundles Generate a V8 code cache for Electron's embedded electron/js2c/* bundles at build time and embed it in the binary, so each process deserializes its framework bundle (kConsumeCodeCache) instead of parsing and compiling it from source at startup. V8's code-cache key includes FlagList::Hash() over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so one cache variant is generated per flavor and each process consumes the matching one: sandbox : sandboxed renderer, no Node env (+SAB) renderer : normal renderer, has Node env (+SAB +rehash +no-freeze) browser : main process (+rehash +no-freeze) utility : utility process (+rehash +no-freeze) worker : node/web worker (+rehash +no-freeze) All flavors create their isolate from the v8 context snapshot. The flag deltas are deterministic; --no-freeze-flags-after-init (present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every node-env process includes --rehash-snapshot. A sandboxed renderer has no Node env (flags stay frozen) so it is a distinct flavor, discriminated at runtime by node::Environment::GetCurrent(context). The generator compiles each bundle with kEagerCompile so the cache covers the inner functions, not just the top-level wrapper -- the framework bundles run in full at startup, so this front-loads the lazy compiles to build time. Cross-arch is supported (the generator is built in v8_snapshot_toolchain so it links V8 with V8_TARGET_ARCH set to the target and reads the target's snapshot blob); cross-OS is untested and gated off. - electron_natives_codecache_main.cc: per-flavor build-time generator compiling each bundle through the same BuiltinLoader path its runtime consumer uses. - shell/common/js2c_bundle_ids.h: shared bundle ids and wrapper-function param names used by the generator and the runtime CompileAndCall call sites, so the source/params hash matches by construction. - node_util.cc feeds the standalone loader; FeedEnvironmentCodeCache feeds the per-Environment loader before LoadEnvironment so the *_init bundles are consumed. - Test-only (DCHECK builds, compiled out of production): a per-process id->accepted status map exposed via v8_util.getJs2cCodeCacheStatus, a sandboxed-preload shim, and spec/api-js2c-code-cache-spec.ts which spawns a clean app and asserts every bundle is consumed in browser/sandbox/renderer/utility. DidClearWindowObject (sandboxed renderer, pre-HTML-parser): ~9.8 ms (cache rejected/absent) -> ~6.4 ms (eager cache consumed), a ~3.4 ms / ~35% cold-start win on every launch. No V8 patch. * chore: export node patch for the js2c build-time code cache (cherry picked from commit d857117) * perf: boot the browser process from an embedded Node startup snapshot (#51703) * perf: boot the browser process from an embedded Node startup snapshot Build a Node startup snapshot at build time (node_mksnapshot, gated to native builds) and create the browser-process isolate from it so the Node bootstrap is deserialized instead of re-parsed and re-compiled at every app start. The snapshot is generated by extending the same V8 startup snapshot the rest of the process uses (snapshot_blob.bin), so the read-only heap is shared and V8's one-snapshot-per-process invariant holds. node_snapshot (a separate source_set in third_party/electron_node:unofficial.gni) provides the strong SnapshotBuilder::GetEmbeddedSnapshotData over a now- weak no-op stub; electron_lib deps it so the shipped framework links the real snapshot while node_mksnapshot (which only links libnode + the weak stub) does not -- avoiding the GN dependency cycle that v8_snapshot_ toolchain == default_toolchain would otherwise force on native builds. Browser process only: - javascript_environment.cc: when the embedded snapshot is present, CreateIsolateHolder feeds its blob + external references into the isolate CreateParams (gin passes nullptr external refs so there is no conflict; the browser process has no blink). The ctor skips node::NewContext -- the main context is materialized from the snapshot inside node::CreateEnvironment. - electron_browser_main_parts.cc: passes an empty context to Initialize/CreateEnvironment in the snapshot path; enters the deserialized context after. - node_bindings.cc: CreateEnvironment defers context-dependent setup until the snapshot's main context exists; CreateIsolateData receives the snapshot's per-isolate data; LoadEnvironment merges the build-time js2c cache for electron/js2c/* with the snapshot's per-builtin cache. Renderer / sandboxed renderer / utility / worker processes are unchanged. Measured impact (Testing build, macOS arm64): V8 compile/parse on the browser-process critical path drops from ~17 ms to ~12 ms top-level (V8.PreParse 1003 -> 24 events, V8.ParseProgram 8 -> 0.7 ms), but the 4.4 MB snapshot adds ~5 ms of deserialize cost (V8.DeserializeContext 2.6 ms + V8.DeserializeIsolate 2.0 ms + V8.CompileDeserialize 1.4 ms), so the net wall-clock change is within run-to-run noise. The Testing build keeps every DCHECK in V8's deserializer hot loop compiled in (dcheck_always_on=true); the snapshot-vs-recompile trade-off is expected to be more favorable in Release builds where those are compiled out and the deserialize is mostly memcpy + relocate. Needs a Release benchmark to determine whether this is a net win. The build-time js2c code cache for the browser process is generated against the embedded snapshot's isolate (a dedicated electron_natives_codecache_snapshot host tool links the real node_snapshot and compiles browser_init/node_init via GetEmbeddedSnapshotData + InitializeIsolateParams). A SnapshotCreator- produced snapshot owns a unique read-only-heap checksum that matches no standalone blob, so this is the only way the browser bundles' code-cache read-only checksum matches the runtime isolate; the other flavors keep using the v8 context snapshot. * fix: restore preventDefault on native events in the snapshot path The eager gin_helper Event-constructor warm-up in NodeBindings::Initialize was guarded out when booting from the embedded Node startup snapshot (the context is empty there), on the mistaken assumption the Event ObjectTemplate would be populated lazily. For gin Constructible classes the template -- which carries preventDefault()/defaultPrevented -- is only ever populated by GetConstructor(), so every native gin event emitted in the snapshot-booted browser process was missing those members (e.g. TypeError: event.preventDefault is not a function on -before-unload-fired, will-navigate, etc.). Run the warm-up in CreateEnvironment once the snapshot's main context has been materialized and entered. The template is cached per-isolate, so a single call covers the isolate lifetime as before. * fix: skip the embedded snapshot in the ELECTRON_RUN_AS_NODE path The ELECTRON_RUN_AS_NODE entry point (shell/app/node_main.cc, used by child_process.fork) runs without a --type switch, so IsBrowserProcess() is true for it and JavascriptEnvironment created its isolate from the embedded Node startup snapshot. But node_main.cc builds its IsolateData without snapshot_data and passes a fresh context, so node's CreateEnvironment tripped CHECK_NOT_NULL(isolate_data->snapshot_data()) and the forked process aborted. Gate the snapshot on a new IsRunningAsNode() helper so only the genuine browser process consumes it; the run-as-node path boots a fresh isolate as before. The run-as-node check is lifted out of crash_keys.cc into process_util so both callers share one definition. * fix: avoid double uncaughtException delivery in the snapshot path When the browser process boots from the embedded Node snapshot, node::CreateEnvironment registers the per-isolate message listener while deserializing the snapshot (SetIsolateErrorHandlers in node's src/api/environment.cc, reached only on the snapshot path). Electron's own SetIsolateUpForNode then registered the same listener again, and because V8 message listeners are additive, every uncaught error -- and the uncaughtException that follows -- was delivered twice. A throwing webContents.setWindowOpenHandler surfaced this (guest-window-manager spec). Skip Electron's duplicate registration when booting from the snapshot: the snapshot-registered PerIsolateMessageListener is identical, and the custom fatal/OOM handlers are setters that still take effect. * chore: address review feedback on the snapshot patch - node_mksnapshot: read the base V8 snapshot blob with node::ReadFileSync instead of ifstream/stringstream, reporting a clear error on a missing or unreadable path rather than silently producing an empty blob. - Fix a misleading comment: the Node startup snapshot's embedded code cache and Electron's build-time js2c cache are NOT disjoint -- both carry the electron/js2c/* bundles node_mksnapshot compiled -- so RefreshCodeCache's insert_or_assign (build-time entry wins) is intentional, not a latent replace-vs-emplace bug. Correct the same claim in LoadEnvironment. * fix: register fast-API CFunction objects as snapshot external references V8 CL 7828135 ("[fastapi] Store v8::CFunction pointer directly in FunctionTemplateInfo", already in our pinned V8) wraps the v8::CFunction in a Foreign instead of a Managed<>, which restores serialization of FunctionTemplateInfo. The snapshot serializer now encodes the CFunction object's address, so ExternalReferenceRegistry::Register(CFunction) must register &c_func alongside GetAddress()/GetTypeInfo(). With that, node_mksnapshot serializes fast-API FunctionTemplates with c_function intact, so the DropCFunctionForSnapshot workaround -- which forced the snapshot's templates onto their slow callbacks -- is removed. The browser process keeps the V8 fast-API paths. (cherry picked from commit 8b55291) * build: regenerate filenames.auto.gni for backported bundle change The renderer startup-data backport (#51602) drops the dependency on lib/renderer/ipc-renderer-internal-utils.ts from the sandboxed renderer bundle. Regenerate filenames.auto.gni so the gen-filenames --check CI step passes. * chore: update patches * chore: update patches --------- Co-authored-by: PatchUp <73610968+patchup[bot]@users.noreply.github.com> (cherry picked from commit ea8b0bb)
MarshallOfSound
added a commit
that referenced
this pull request
Jun 3, 2026
…1697, #51703) (#51792) * 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. (cherry picked from commit 07f25ea) * perf: build-time V8 code cache for the electron/js2c framework bundles (#51697) * build: dedupe get-intrinsic to shrink the sandboxed renderer bundle The hoisted get-intrinsic resolved to 1.2.1 (satisfying the older ^1.0.2..^1.2.0 consumers), so side-channel-weakmap, side-channel-map, call-bound and es-set-tostringtag — which need ^1.2.5/^1.3.0 — each pulled a nested get-intrinsic@1.3.0 copy. webpack bundled three of those nested copies into sandbox_bundle.js (~16.5 KB each). 1.3.0 satisfies every range in the tree (all ^1.x), so pin it via resolutions to collapse to a single copy. Drops sandbox_bundle.js from 364,762 to 323,116 bytes (-41,646, -11.4%) — that bundle is parsed/preparsed in every sandboxed renderer before the HTML parser, so this is a direct per-renderer startup win on top of the IPC and code-cache changes. * perf: build-time V8 code cache for the electron/js2c framework bundles Generate a V8 code cache for Electron's embedded electron/js2c/* bundles at build time and embed it in the binary, so each process deserializes its framework bundle (kConsumeCodeCache) instead of parsing and compiling it from source at startup. V8's code-cache key includes FlagList::Hash() over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so one cache variant is generated per flavor and each process consumes the matching one: sandbox : sandboxed renderer, no Node env (+SAB) renderer : normal renderer, has Node env (+SAB +rehash +no-freeze) browser : main process (+rehash +no-freeze) utility : utility process (+rehash +no-freeze) worker : node/web worker (+rehash +no-freeze) All flavors create their isolate from the v8 context snapshot. The flag deltas are deterministic; --no-freeze-flags-after-init (present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every node-env process includes --rehash-snapshot. A sandboxed renderer has no Node env (flags stay frozen) so it is a distinct flavor, discriminated at runtime by node::Environment::GetCurrent(context). The generator compiles each bundle with kEagerCompile so the cache covers the inner functions, not just the top-level wrapper -- the framework bundles run in full at startup, so this front-loads the lazy compiles to build time. Cross-arch is supported (the generator is built in v8_snapshot_toolchain so it links V8 with V8_TARGET_ARCH set to the target and reads the target's snapshot blob); cross-OS is untested and gated off. - electron_natives_codecache_main.cc: per-flavor build-time generator compiling each bundle through the same BuiltinLoader path its runtime consumer uses. - shell/common/js2c_bundle_ids.h: shared bundle ids and wrapper-function param names used by the generator and the runtime CompileAndCall call sites, so the source/params hash matches by construction. - node_util.cc feeds the standalone loader; FeedEnvironmentCodeCache feeds the per-Environment loader before LoadEnvironment so the *_init bundles are consumed. - Test-only (DCHECK builds, compiled out of production): a per-process id->accepted status map exposed via v8_util.getJs2cCodeCacheStatus, a sandboxed-preload shim, and spec/api-js2c-code-cache-spec.ts which spawns a clean app and asserts every bundle is consumed in browser/sandbox/renderer/utility. DidClearWindowObject (sandboxed renderer, pre-HTML-parser): ~9.8 ms (cache rejected/absent) -> ~6.4 ms (eager cache consumed), a ~3.4 ms / ~35% cold-start win on every launch. No V8 patch. * chore: export node patch for the js2c build-time code cache (cherry picked from commit d857117) * perf: boot the browser process from an embedded Node startup snapshot (#51703) * perf: boot the browser process from an embedded Node startup snapshot Build a Node startup snapshot at build time (node_mksnapshot, gated to native builds) and create the browser-process isolate from it so the Node bootstrap is deserialized instead of re-parsed and re-compiled at every app start. The snapshot is generated by extending the same V8 startup snapshot the rest of the process uses (snapshot_blob.bin), so the read-only heap is shared and V8's one-snapshot-per-process invariant holds. node_snapshot (a separate source_set in third_party/electron_node:unofficial.gni) provides the strong SnapshotBuilder::GetEmbeddedSnapshotData over a now- weak no-op stub; electron_lib deps it so the shipped framework links the real snapshot while node_mksnapshot (which only links libnode + the weak stub) does not -- avoiding the GN dependency cycle that v8_snapshot_ toolchain == default_toolchain would otherwise force on native builds. Browser process only: - javascript_environment.cc: when the embedded snapshot is present, CreateIsolateHolder feeds its blob + external references into the isolate CreateParams (gin passes nullptr external refs so there is no conflict; the browser process has no blink). The ctor skips node::NewContext -- the main context is materialized from the snapshot inside node::CreateEnvironment. - electron_browser_main_parts.cc: passes an empty context to Initialize/CreateEnvironment in the snapshot path; enters the deserialized context after. - node_bindings.cc: CreateEnvironment defers context-dependent setup until the snapshot's main context exists; CreateIsolateData receives the snapshot's per-isolate data; LoadEnvironment merges the build-time js2c cache for electron/js2c/* with the snapshot's per-builtin cache. Renderer / sandboxed renderer / utility / worker processes are unchanged. Measured impact (Testing build, macOS arm64): V8 compile/parse on the browser-process critical path drops from ~17 ms to ~12 ms top-level (V8.PreParse 1003 -> 24 events, V8.ParseProgram 8 -> 0.7 ms), but the 4.4 MB snapshot adds ~5 ms of deserialize cost (V8.DeserializeContext 2.6 ms + V8.DeserializeIsolate 2.0 ms + V8.CompileDeserialize 1.4 ms), so the net wall-clock change is within run-to-run noise. The Testing build keeps every DCHECK in V8's deserializer hot loop compiled in (dcheck_always_on=true); the snapshot-vs-recompile trade-off is expected to be more favorable in Release builds where those are compiled out and the deserialize is mostly memcpy + relocate. Needs a Release benchmark to determine whether this is a net win. The build-time js2c code cache for the browser process is generated against the embedded snapshot's isolate (a dedicated electron_natives_codecache_snapshot host tool links the real node_snapshot and compiles browser_init/node_init via GetEmbeddedSnapshotData + InitializeIsolateParams). A SnapshotCreator- produced snapshot owns a unique read-only-heap checksum that matches no standalone blob, so this is the only way the browser bundles' code-cache read-only checksum matches the runtime isolate; the other flavors keep using the v8 context snapshot. * fix: restore preventDefault on native events in the snapshot path The eager gin_helper Event-constructor warm-up in NodeBindings::Initialize was guarded out when booting from the embedded Node startup snapshot (the context is empty there), on the mistaken assumption the Event ObjectTemplate would be populated lazily. For gin Constructible classes the template -- which carries preventDefault()/defaultPrevented -- is only ever populated by GetConstructor(), so every native gin event emitted in the snapshot-booted browser process was missing those members (e.g. TypeError: event.preventDefault is not a function on -before-unload-fired, will-navigate, etc.). Run the warm-up in CreateEnvironment once the snapshot's main context has been materialized and entered. The template is cached per-isolate, so a single call covers the isolate lifetime as before. * fix: skip the embedded snapshot in the ELECTRON_RUN_AS_NODE path The ELECTRON_RUN_AS_NODE entry point (shell/app/node_main.cc, used by child_process.fork) runs without a --type switch, so IsBrowserProcess() is true for it and JavascriptEnvironment created its isolate from the embedded Node startup snapshot. But node_main.cc builds its IsolateData without snapshot_data and passes a fresh context, so node's CreateEnvironment tripped CHECK_NOT_NULL(isolate_data->snapshot_data()) and the forked process aborted. Gate the snapshot on a new IsRunningAsNode() helper so only the genuine browser process consumes it; the run-as-node path boots a fresh isolate as before. The run-as-node check is lifted out of crash_keys.cc into process_util so both callers share one definition. * fix: avoid double uncaughtException delivery in the snapshot path When the browser process boots from the embedded Node snapshot, node::CreateEnvironment registers the per-isolate message listener while deserializing the snapshot (SetIsolateErrorHandlers in node's src/api/environment.cc, reached only on the snapshot path). Electron's own SetIsolateUpForNode then registered the same listener again, and because V8 message listeners are additive, every uncaught error -- and the uncaughtException that follows -- was delivered twice. A throwing webContents.setWindowOpenHandler surfaced this (guest-window-manager spec). Skip Electron's duplicate registration when booting from the snapshot: the snapshot-registered PerIsolateMessageListener is identical, and the custom fatal/OOM handlers are setters that still take effect. * chore: address review feedback on the snapshot patch - node_mksnapshot: read the base V8 snapshot blob with node::ReadFileSync instead of ifstream/stringstream, reporting a clear error on a missing or unreadable path rather than silently producing an empty blob. - Fix a misleading comment: the Node startup snapshot's embedded code cache and Electron's build-time js2c cache are NOT disjoint -- both carry the electron/js2c/* bundles node_mksnapshot compiled -- so RefreshCodeCache's insert_or_assign (build-time entry wins) is intentional, not a latent replace-vs-emplace bug. Correct the same claim in LoadEnvironment. * fix: register fast-API CFunction objects as snapshot external references V8 CL 7828135 ("[fastapi] Store v8::CFunction pointer directly in FunctionTemplateInfo", already in our pinned V8) wraps the v8::CFunction in a Foreign instead of a Managed<>, which restores serialization of FunctionTemplateInfo. The snapshot serializer now encodes the CFunction object's address, so ExternalReferenceRegistry::Register(CFunction) must register &c_func alongside GetAddress()/GetTypeInfo(). With that, node_mksnapshot serializes fast-API FunctionTemplates with c_function intact, so the DropCFunctionForSnapshot workaround -- which forced the snapshot's templates onto their slow callbacks -- is removed. The browser process keeps the V8 fast-API paths. (cherry picked from commit 8b55291) * build: regenerate filenames.auto.gni for backported bundle change The renderer startup-data backport (#51602) drops the dependency on lib/renderer/ipc-renderer-internal-utils.ts from the sandboxed renderer bundle. Regenerate filenames.auto.gni so the gen-filenames --check CI step passes. * chore: update patches * chore: update patches --------- Co-authored-by: PatchUp <73610968+patchup[bot]@users.noreply.github.com> (cherry picked from commit ea8b0bb)
MarshallOfSound
added a commit
that referenced
this pull request
Jun 3, 2026
…1697, #51703) (#51831) * perf: backport startup performance improvements to 43-x-y (#51602, #51697, #51703) (#51792) * 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. (cherry picked from commit 07f25ea) * perf: build-time V8 code cache for the electron/js2c framework bundles (#51697) * build: dedupe get-intrinsic to shrink the sandboxed renderer bundle The hoisted get-intrinsic resolved to 1.2.1 (satisfying the older ^1.0.2..^1.2.0 consumers), so side-channel-weakmap, side-channel-map, call-bound and es-set-tostringtag — which need ^1.2.5/^1.3.0 — each pulled a nested get-intrinsic@1.3.0 copy. webpack bundled three of those nested copies into sandbox_bundle.js (~16.5 KB each). 1.3.0 satisfies every range in the tree (all ^1.x), so pin it via resolutions to collapse to a single copy. Drops sandbox_bundle.js from 364,762 to 323,116 bytes (-41,646, -11.4%) — that bundle is parsed/preparsed in every sandboxed renderer before the HTML parser, so this is a direct per-renderer startup win on top of the IPC and code-cache changes. * perf: build-time V8 code cache for the electron/js2c framework bundles Generate a V8 code cache for Electron's embedded electron/js2c/* bundles at build time and embed it in the binary, so each process deserializes its framework bundle (kConsumeCodeCache) instead of parsing and compiling it from source at startup. V8's code-cache key includes FlagList::Hash() over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so one cache variant is generated per flavor and each process consumes the matching one: sandbox : sandboxed renderer, no Node env (+SAB) renderer : normal renderer, has Node env (+SAB +rehash +no-freeze) browser : main process (+rehash +no-freeze) utility : utility process (+rehash +no-freeze) worker : node/web worker (+rehash +no-freeze) All flavors create their isolate from the v8 context snapshot. The flag deltas are deterministic; --no-freeze-flags-after-init (present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every node-env process includes --rehash-snapshot. A sandboxed renderer has no Node env (flags stay frozen) so it is a distinct flavor, discriminated at runtime by node::Environment::GetCurrent(context). The generator compiles each bundle with kEagerCompile so the cache covers the inner functions, not just the top-level wrapper -- the framework bundles run in full at startup, so this front-loads the lazy compiles to build time. Cross-arch is supported (the generator is built in v8_snapshot_toolchain so it links V8 with V8_TARGET_ARCH set to the target and reads the target's snapshot blob); cross-OS is untested and gated off. - electron_natives_codecache_main.cc: per-flavor build-time generator compiling each bundle through the same BuiltinLoader path its runtime consumer uses. - shell/common/js2c_bundle_ids.h: shared bundle ids and wrapper-function param names used by the generator and the runtime CompileAndCall call sites, so the source/params hash matches by construction. - node_util.cc feeds the standalone loader; FeedEnvironmentCodeCache feeds the per-Environment loader before LoadEnvironment so the *_init bundles are consumed. - Test-only (DCHECK builds, compiled out of production): a per-process id->accepted status map exposed via v8_util.getJs2cCodeCacheStatus, a sandboxed-preload shim, and spec/api-js2c-code-cache-spec.ts which spawns a clean app and asserts every bundle is consumed in browser/sandbox/renderer/utility. DidClearWindowObject (sandboxed renderer, pre-HTML-parser): ~9.8 ms (cache rejected/absent) -> ~6.4 ms (eager cache consumed), a ~3.4 ms / ~35% cold-start win on every launch. No V8 patch. * chore: export node patch for the js2c build-time code cache (cherry picked from commit d857117) * perf: boot the browser process from an embedded Node startup snapshot (#51703) * perf: boot the browser process from an embedded Node startup snapshot Build a Node startup snapshot at build time (node_mksnapshot, gated to native builds) and create the browser-process isolate from it so the Node bootstrap is deserialized instead of re-parsed and re-compiled at every app start. The snapshot is generated by extending the same V8 startup snapshot the rest of the process uses (snapshot_blob.bin), so the read-only heap is shared and V8's one-snapshot-per-process invariant holds. node_snapshot (a separate source_set in third_party/electron_node:unofficial.gni) provides the strong SnapshotBuilder::GetEmbeddedSnapshotData over a now- weak no-op stub; electron_lib deps it so the shipped framework links the real snapshot while node_mksnapshot (which only links libnode + the weak stub) does not -- avoiding the GN dependency cycle that v8_snapshot_ toolchain == default_toolchain would otherwise force on native builds. Browser process only: - javascript_environment.cc: when the embedded snapshot is present, CreateIsolateHolder feeds its blob + external references into the isolate CreateParams (gin passes nullptr external refs so there is no conflict; the browser process has no blink). The ctor skips node::NewContext -- the main context is materialized from the snapshot inside node::CreateEnvironment. - electron_browser_main_parts.cc: passes an empty context to Initialize/CreateEnvironment in the snapshot path; enters the deserialized context after. - node_bindings.cc: CreateEnvironment defers context-dependent setup until the snapshot's main context exists; CreateIsolateData receives the snapshot's per-isolate data; LoadEnvironment merges the build-time js2c cache for electron/js2c/* with the snapshot's per-builtin cache. Renderer / sandboxed renderer / utility / worker processes are unchanged. Measured impact (Testing build, macOS arm64): V8 compile/parse on the browser-process critical path drops from ~17 ms to ~12 ms top-level (V8.PreParse 1003 -> 24 events, V8.ParseProgram 8 -> 0.7 ms), but the 4.4 MB snapshot adds ~5 ms of deserialize cost (V8.DeserializeContext 2.6 ms + V8.DeserializeIsolate 2.0 ms + V8.CompileDeserialize 1.4 ms), so the net wall-clock change is within run-to-run noise. The Testing build keeps every DCHECK in V8's deserializer hot loop compiled in (dcheck_always_on=true); the snapshot-vs-recompile trade-off is expected to be more favorable in Release builds where those are compiled out and the deserialize is mostly memcpy + relocate. Needs a Release benchmark to determine whether this is a net win. The build-time js2c code cache for the browser process is generated against the embedded snapshot's isolate (a dedicated electron_natives_codecache_snapshot host tool links the real node_snapshot and compiles browser_init/node_init via GetEmbeddedSnapshotData + InitializeIsolateParams). A SnapshotCreator- produced snapshot owns a unique read-only-heap checksum that matches no standalone blob, so this is the only way the browser bundles' code-cache read-only checksum matches the runtime isolate; the other flavors keep using the v8 context snapshot. * fix: restore preventDefault on native events in the snapshot path The eager gin_helper Event-constructor warm-up in NodeBindings::Initialize was guarded out when booting from the embedded Node startup snapshot (the context is empty there), on the mistaken assumption the Event ObjectTemplate would be populated lazily. For gin Constructible classes the template -- which carries preventDefault()/defaultPrevented -- is only ever populated by GetConstructor(), so every native gin event emitted in the snapshot-booted browser process was missing those members (e.g. TypeError: event.preventDefault is not a function on -before-unload-fired, will-navigate, etc.). Run the warm-up in CreateEnvironment once the snapshot's main context has been materialized and entered. The template is cached per-isolate, so a single call covers the isolate lifetime as before. * fix: skip the embedded snapshot in the ELECTRON_RUN_AS_NODE path The ELECTRON_RUN_AS_NODE entry point (shell/app/node_main.cc, used by child_process.fork) runs without a --type switch, so IsBrowserProcess() is true for it and JavascriptEnvironment created its isolate from the embedded Node startup snapshot. But node_main.cc builds its IsolateData without snapshot_data and passes a fresh context, so node's CreateEnvironment tripped CHECK_NOT_NULL(isolate_data->snapshot_data()) and the forked process aborted. Gate the snapshot on a new IsRunningAsNode() helper so only the genuine browser process consumes it; the run-as-node path boots a fresh isolate as before. The run-as-node check is lifted out of crash_keys.cc into process_util so both callers share one definition. * fix: avoid double uncaughtException delivery in the snapshot path When the browser process boots from the embedded Node snapshot, node::CreateEnvironment registers the per-isolate message listener while deserializing the snapshot (SetIsolateErrorHandlers in node's src/api/environment.cc, reached only on the snapshot path). Electron's own SetIsolateUpForNode then registered the same listener again, and because V8 message listeners are additive, every uncaught error -- and the uncaughtException that follows -- was delivered twice. A throwing webContents.setWindowOpenHandler surfaced this (guest-window-manager spec). Skip Electron's duplicate registration when booting from the snapshot: the snapshot-registered PerIsolateMessageListener is identical, and the custom fatal/OOM handlers are setters that still take effect. * chore: address review feedback on the snapshot patch - node_mksnapshot: read the base V8 snapshot blob with node::ReadFileSync instead of ifstream/stringstream, reporting a clear error on a missing or unreadable path rather than silently producing an empty blob. - Fix a misleading comment: the Node startup snapshot's embedded code cache and Electron's build-time js2c cache are NOT disjoint -- both carry the electron/js2c/* bundles node_mksnapshot compiled -- so RefreshCodeCache's insert_or_assign (build-time entry wins) is intentional, not a latent replace-vs-emplace bug. Correct the same claim in LoadEnvironment. * fix: register fast-API CFunction objects as snapshot external references V8 CL 7828135 ("[fastapi] Store v8::CFunction pointer directly in FunctionTemplateInfo", already in our pinned V8) wraps the v8::CFunction in a Foreign instead of a Managed<>, which restores serialization of FunctionTemplateInfo. The snapshot serializer now encodes the CFunction object's address, so ExternalReferenceRegistry::Register(CFunction) must register &c_func alongside GetAddress()/GetTypeInfo(). With that, node_mksnapshot serializes fast-API FunctionTemplates with c_function intact, so the DropCFunctionForSnapshot workaround -- which forced the snapshot's templates onto their slow callbacks -- is removed. The browser process keeps the V8 fast-API paths. (cherry picked from commit 8b55291) * build: regenerate filenames.auto.gni for backported bundle change The renderer startup-data backport (#51602) drops the dependency on lib/renderer/ipc-renderer-internal-utils.ts from the sandboxed renderer bundle. Regenerate filenames.auto.gni so the gen-filenames --check CI step passes. * chore: update patches * chore: update patches --------- Co-authored-by: PatchUp <73610968+patchup[bot]@users.noreply.github.com> (cherry picked from commit ea8b0bb) * build: backport V8 fastapi serialization fix for the Node startup snapshot 42-x-y pins Chromium 148 / V8 14.8, which stores fast-API CFunction overloads in FunctionTemplateInfo wrapped in Managed<> objects. Managed<> objects cannot be serialized, so node_mksnapshot (added by the startup snapshot backport) fails with "CheckGlobalAndEternalHandles failed" when it serializes fast-API FunctionTemplates. Backport upstream V8 CL 7828135 ("[fastapi] Store v8::CFunction pointer directly in FunctionTemplateInfo", first shipped in V8 15.0.163) as a V8 patch. It wraps the embedder's v8::CFunction in a Foreign instead, which restores serialization; the snapshot encodes the CFunction's address via the external references that node's ExternalReferenceRegistry registers. The only adaptation is keeping 14.8's GetCFunction(Isolate*, int) signature so existing wasm/logging callers are untouched. * build: prune stale hasown descriptor from yarn.lock The hasown@^2.0.0 descriptor was only requested by the get-intrinsic@1.2.4 lockfile entry, which the get-intrinsic resolutions pin removed. Yarn's hardened-mode validation in CI rejects the stale descriptor in the entry key. * chore: update patches --------- Co-authored-by: PatchUp <73610968+patchup[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description of Change
Builds a V8 code cache for Electron's embedded
electron/js2c/*framework bundles at compile time and embeds it in the binary, so they're deserialized (kConsumeCodeCache) instead of parsed and compiled from source on every process start. The bundles are eagerly compiled at build time (kEagerCompile), so the cache covers every inner function — at runtime the consuming process never lazy-compiles any of the framework JS.The win is in the sandboxed renderer: with no Node Environment, its pre-HTML-parser blocking window (
DidClearWindowObject) is almost entirely thesandbox_bundlecompile, and caching it cuts that by ~35% on every launch (~9.8 ms → ~6.4 ms cold, embedded in the binary, no warm-up needed). The bundles in the Node-env processes are also consumed — spec-verified — but the Node bootstrap dominates there so the perf delta is in the noise.DidClearWindowObject(sandboxed renderer, cold)Also dedupes
get-intrinsic(3× viapackage.jsonresolutions), shrinking the sandboxed renderer bundle 365 KB → 323 KB.Stacks with #51602's preload bytecode cache — the bundle and the preload are independently cached.
How it works
V8's code-cache key includes
FlagList::Hash()over non-default flags and the snapshot's read-only checksum, both of which differ per process type, so a single cache only works for one process flavor. We generate one variant per flavor and feed each process the matching one:sandbox+SABrenderer+SAB +rehash-snapshot +no-freezebrowser+rehash-snapshot +no-freezeutility+rehash-snapshot +no-freezeworker+rehash-snapshot +no-freezeAll flavors create their isolate from the v8 context snapshot (gin loads it process-wide). The flag deltas are deterministic; the key insight is that
--no-freeze-flags-after-init(present iff the process has a Node Environment) lets V8 recompute the flag-hash after init, so the consume-time hash of every Node-env process includes--rehash-snapshot. A sandboxed renderer has no Node env, so it's a distinct flavor — discriminated at runtime bynode::Environment::GetCurrent(context) != nullptr.electron_natives_codecacheis a build-time host tool (built inv8_snapshot_toolchain, likenode_mksnapshot) that compiles each bundle through the exactnode::builtins::BuiltinLoaderpath its runtime consumer uses — the caller-parameterized 4-argLookupAndCompileFunctionforutil::CompileAndCallbundles, the 3-argparameter_mappath for the*_initbundles run bynode::LoadEnvironment— so source/params hash matches by construction. The bundle ids and wrapper-function param names live inshell/common/js2c_bundle_ids.h, shared between the generator and the runtime call sites. A node patch makes the 4-argLookupAndCompileFunctioncache-aware (kConsumeCodeCache, graceful compile-from-source fallback).FeedEnvironmentCodeCachefeeds the per-Environment loader beforeLoadEnvironmentso the*_initbundles are consumed.Cross-arch is supported: the generator is built in
v8_snapshot_toolchain(V8_TARGET_ARCH set to the target) and reads the target's snapshot blob, so the cache it emits is keyed to the target's read-only checksum — the same mechanism that makes V8'smksnapshotcross-arch correct. Cross-OS is untested and gated off (electron_generate_js2c_code_cache = host_os == target_os).Embedded cache size: ~3.5 MB across all 5 flavors (sandbox 1.7 MB, browser 1.1 MB, renderer 343 KB, utility 241 KB, worker 169 KB).
Testing
Test-only instrumentation (DCHECK / Electron Testing builds, compiled out of production): a per-process
id → acceptedstatus map exposed viav8_util.getJs2cCodeCacheStatus(), plus a sandboxed-preload shim.spec/api-js2c-code-cache-spec.tsspawns a clean Electron app — the spec runner injects--js-flags=--expose_gcsuite-wide, which would change the flag-hash and (correctly) reject the production-flavor cache — and asserts every bundle is consumed in browser / sandboxed renderer / renderer / utility.Checklist
npm testpassesRelease Notes
Notes: Reduced sandboxed renderer cold-start time (
DidClearWindowObject) by ~35% by deserializing a build-time V8 code cache for Electron's framework bundles instead of parsing and compiling them from source on every launch.