Skip to content

perf: push sandboxed renderer startup data via mojo and cache preload bytecode#51602

Merged
MarshallOfSound merged 7 commits into
mainfrom
sam/preload-push-startup-data
May 19, 2026
Merged

perf: push sandboxed renderer startup data via mojo and cache preload bytecode#51602
MarshallOfSound merged 7 commits into
mainfrom
sam/preload-push-startup-data

Conversation

@MarshallOfSound

@MarshallOfSound MarshallOfSound commented May 13, 2026

Copy link
Copy Markdown
Member

Description of Change

Replaces the BROWSER_SANDBOX_LOAD sync IPC with browser→renderer mojo pushes and adds a persistent V8 code cache for preload scripts. Removes the largest renderer-side FCP cost that isn't present in stock Chromium.

The sync IPC fired from DidCreateScriptContext — before the HTML parser starts — and the browser-side handler awaited reading each preload file, yielding the UI thread back to whatever else the main process was doing at startup. Under cold-launch contention the renderer parked for ~5–8× the UI-thread busy-chunk size waiting on the reply. With two ~150 KB preloads and 20 ms busy chunks, FCP went from ~244 ms to ~140 ms.

UI busy chunk sync IPC pull mojo push
idle ~24 ms ~22 ms
10 ms ~84 ms ~22 ms
20 ms ~134 ms ~22 ms
40 ms ~230 ms ~25 ms

Three pushes replace it, one per place a preload can run:

  • Framesmojom::ElectronFrameStartup.SetStartupData() from ReadyToCommitNavigation(), on the same associated channel as CommitNavigation, so it's ordered ahead of the document needing it.
  • window.open() popups — the synchronous about:blank document is created before any async push can land, so the data rides the CreateNewWindowReply (new Chromium patch + ContentBrowserClient/ContentRendererClient hooks). Verified against stock Electron 42 that setWindowOpenHandler-overridden preloads behave identically.
  • Service-worker preload realmsmojom::ElectronWorkerStartup per-process push from RenderProcessReady(), behind a WaitableEvent for the worker thread.

Then it caches the preload's compiled V8 bytecode (userData/Code Cache/electron-preload/<sha256(id)>.cache), produced after the first compile and shipped with the push thereafter. Compilation moved to ScriptCompiler::CompileFunction() with a ScriptOrigin, which keeps the contents and cache bytes in native buffers (no V8 string churn) and fixes preload stack traces to show the real file and line number instead of <anonymous> and off-by-one. V8's CachedData validation self-heals stale blobs across upgrades.

The cache is two-tiered (in-memory per launch + disk per userData), so the win lands on every navigation after the first and on every launch after the first:

navigation pre-parse blocking vs no cache
baseline (no cache feature) ~22.0 ms
first-ever (cold compile + produce + ship) 27.2 ms +5.2 ms once
same launch, navs 1+ (in-memory hit) 17.6 ms −4.4 ms
next launch, nav 0 (disk hit) 18.0 ms −4.0 ms
next launch, navs 1+ 17.2 ms −4.8 ms

Behavior notes:

  • process.env is captured fresh per push (uv_os_environ()), same timing as the legacy spread.
  • process.versions/arch/platform/execPath are now filled renderer-side and are read-only (the legacy structured clone left them writable; mutating them now no-ops).
  • The disk cache has the same threat model as Chromium's userData/Code Cache/js. Could gate it on embeddedAsarIntegrityValidation if reviewers prefer.
  • PreloadScriptData.contents is array<uint8> not BigBuffer so it flat-serializes into the popup reply blob — one extra ~150 KB memcpy/nav.

Checklist

Release Notes

Notes: Improved sandboxed renderer startup performance — preload scripts and process info are now pushed ahead of navigation instead of fetched via blocking IPC, and preload compilation results are cached on disk. Preload stack traces now show the correct file path and line number.

@MarshallOfSound MarshallOfSound requested review from a team as code owners May 13, 2026 19:53
@MarshallOfSound MarshallOfSound added the semver/minor backwards-compatible functionality label May 13, 2026
@MarshallOfSound MarshallOfSound force-pushed the sam/preload-push-startup-data branch from adcf392 to 225522c Compare May 13, 2026 20:19
@MarshallOfSound MarshallOfSound added semver/patch backwards-compatible bug fixes no-backport and removed semver/minor backwards-compatible functionality api-review/requested 🗳 labels May 13, 2026
@MarshallOfSound MarshallOfSound force-pushed the sam/preload-push-startup-data branch from 225522c to a1c0c8b Compare May 13, 2026 21:43
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.
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.
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.
- 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.
@MarshallOfSound MarshallOfSound force-pushed the sam/preload-push-startup-data branch from a1c0c8b to 08e37ad Compare May 14, 2026 07:52
@MarshallOfSound MarshallOfSound requested a review from a team as a code owner May 14, 2026 07:52
@electron-cation electron-cation Bot removed the new-pr 🌱 PR opened recently label May 14, 2026
@ckerr

ckerr commented May 14, 2026

Copy link
Copy Markdown
Member

I'm reviewing this, it's 1600 lines of nontrivial code so it's not going to happen immediately 🐱

Comment thread shell/browser/api/electron_api_web_contents.cc
Comment thread shell/browser/electron_browser_client.cc Outdated
Comment thread shell/browser/renderer_startup_data.cc Outdated
Comment thread shell/browser/renderer_startup_data.cc
Comment thread shell/renderer/preload_utils.cc Outdated

} // namespace

std::vector<uint8_t> Get(const std::string& id) {

@deepak1556 deepak1556 May 15, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High level question, could we reuse the code cache support from the browser https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/public/mojom/loader/code_cache.mojom with a fake origin and resource url for the preload scripts. For a couple of reasons,

  1. Reuse the system other renderer scripts relies on
  2. The upstream version is per-browsercontext based so gives us nice partition and in necessary cases session.clearCodeCaches works
  3. Upstream version has a LRU eviction backend, current implementation seems unbounded on disk
  4. Can support service worker context which is currently unsupported due to the dependency on frame level interface

The part I am unsure is the origin and process_lock checks in the codecachehost side might pose an issue so is the trade-off to have a custom system for preload the best path or not ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dug into this. Going through the CodeCacheHost mojom from the renderer isn't viable — CheckSecurityForAccessingCodeCacheData rejects any non-http/https/chrome/registered-codecache URL and mojo::ReportBadMessages (kills the renderer) on write, GetOriginLock disables file: locks, and a preload runs in the page's renderer so the lock is the page's scheme not ours. But driving GeneratedCodeCache::WriteEntry/FetchEntry directly browser-side (via StoragePartition::GetGeneratedCodeCacheContext(), which we already // nogncheck-include in electron_api_session.cc) is viable with no Chromium patch and gets per-BrowserContext partitioning, disk LRU, and session.clearCodeCaches() actually purging preload caches (it doesn't today). FetchEntry is async-only though while the pre-nav push needs it sync, so it's a warm-ahead rework + a new internal scheme, not a swap — pulling it into a focused follow-up rather than growing this PR. Good call, that's the right end state.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me for a followup, thanks!

Comment thread shell/renderer/electron_render_thread_observer.h Outdated

@ckerr ckerr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea LGTM. I had a bunch of review comments, nothing unexpected for a PR this size 😸

Agree with Deepak's review comments, esp the revision to SendToProcess() and the deuplication

Comment thread shell/renderer/preload_utils.cc Outdated
Comment thread shell/browser/preload_code_cache.cc
Comment thread shell/browser/preload_code_cache.cc
Comment thread shell/renderer/electron_api_service_impl.cc Outdated
Comment thread patches/chromium/feat_carry_embedder_startup_data_in_createnewwindowreply.patch Outdated
Comment thread shell/browser/renderer_startup_data.cc Outdated
Comment thread shell/browser/api/electron_api_web_contents.cc Outdated
Comment thread shell/renderer/electron_api_service_impl.h Outdated
Comment on lines +80 to +81
if (receiver_.is_bound())
receiver_.reset();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (receiver_.is_bound())
receiver_.reset();
receiver_.reset();

Comment thread shell/browser/preload_code_cache.cc Outdated
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).
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.
@MarshallOfSound

Copy link
Copy Markdown
Member Author

Pushed two commits for this round — fa01b37fc5 (cleanups: DCHECKs, ShouldUseSandbox dedup, dead-code + type removal, return types, SKIP_ON_SHUTDOWN, the CreateNewWindowReply target_url gate) and 14f8d31715 (Deepak's embedded_worker.mojom suggestion — the SW cross-thread global is gone, data rides EmbeddedWorkerStartParams). Both fast-forward so the existing threads stay anchored.

Reusing Chromium's GeneratedCodeCache is the right end state but it's an async-read rework not a swap (the pre-nav push needs the cache synchronously), so pulling it into a focused follow-up rather than growing this further. Didn't take the first-writer-wins guard — breaks corrupt-cache self-heal, reasoning in that thread.

@MarshallOfSound MarshallOfSound force-pushed the sam/preload-push-startup-data branch from f21b75d to 14f8d31 Compare May 18, 2026 16:40

@deepak1556 deepak1556 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM from my end 🚀

@MarshallOfSound MarshallOfSound merged commit 07f25ea into main May 19, 2026
260 of 276 checks passed
@MarshallOfSound MarshallOfSound deleted the sam/preload-push-startup-data branch May 19, 2026 15:38
@release-clerk

release-clerk Bot commented May 19, 2026

Copy link
Copy Markdown

Release Notes Persisted

Improved sandboxed renderer startup performance — preload scripts and process info are now pushed ahead of navigation instead of fetched via blocking IPC, and preload compilation results are cached on disk. Preload stack traces now show the correct file path and line number.

@trop

trop Bot commented May 28, 2026

Copy link
Copy Markdown
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
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 on 43-x-y.
MarshallOfSound added a commit that referenced this pull request May 28, 2026
… 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)
MarshallOfSound added a commit that referenced this pull request May 28, 2026
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.
MarshallOfSound added a commit that referenced this pull request May 31, 2026
… 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)
MarshallOfSound added a commit that referenced this pull request May 31, 2026
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.
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>
@trop trop Bot added merged/43-x-y PR was merged to the "43-x-y" branch. and removed in-flight/43-x-y labels Jun 1, 2026
@trop

trop Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

@MarshallOfSound has manually backported this PR to "42-x-y", please check out #51831

@trop trop Bot added the in-flight/42-x-y label Jun 1, 2026
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>
@trop trop Bot added merged/42-x-y PR was merged to the "42-x-y" branch. and removed in-flight/42-x-y labels Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merged/42-x-y PR was merged to the "42-x-y" branch. merged/43-x-y PR was merged to the "43-x-y" branch. no-backport semver/patch backwards-compatible bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants