← All reports

[Site Isolation] Add multi-process BFCache restoration with Site Isolation

Component: WebKit | fceeb85

BFCache preserves a full page snapshot in memory so back/forward navigation is instant. Under Site Isolation, cross-origin iframes run in separate WebContent processes, so a single cached page can span N processes. SuspendedPageProxy is the UI-process object holding suspended state, and BrowsingContextGroup tracks which WebPageProxy belongs to which process group and routes RemotePageProxy references out to subframe processes.

Source/WebKit/UIProcess/SuspendedPageProxy.cpp

-void SuspendedPageProxy::unsuspend()
+void SuspendedPageProxy::unsuspend(WebCore::BackForwardFrameItemIdentifier mainFrameItemID)
{
ASSERT(m_suspensionState == SuspensionState::Suspended);
 
sendWithAsyncReply(Messages::WebPage::SetIsSuspended(false), [](std::optional<bool> didSuspend) {
ASSERT(!didSuspend.has_value());
});
+
+ RefPtr page = m_page.get();
+ if (!page)
+ return;
+
+ auto aggregator = MainRunLoopSuccessCallbackAggregator::create([weakPage = m_page](bool success) {
+ if (success)
+ return;
+ RefPtr page = weakPage.get();
+ if (!page)
+ return;
+ RELEASE_LOG_ERROR(ProcessSwapping, "SuspendedPageProxy::unsuspend: subframe restoration failed, reloading page");
+ page->reload(WebCore::ReloadOption::ExpiredOnly);
+ });
+
+ m_browsingContextGroup->forEachRemotePage(*page, [suspendedPage = Ref { *this }, &aggregator, mainFrameItemID](auto& remotePage) {
// sends RestoreWithFrameItem to each subframe process
});

Source/WebKit/UIProcess/BrowsingContextGroup.cpp

void BrowsingContextGroup::addPage(WebPageProxy& page)
{
- ASSERT(!m_pages.contains(page));
+ if (m_pages.contains(page)) {
+ // This only happens when restoring a page from a suspended BCG, which holds exactly this one page.
+ ASSERT(!hasMultiplePages());
+ return;
+ }
m_pages.add(page);
...

The single SetIsSuspended IPC is split into separate SuspendWithFrameItem and RestoreWithFrameItem messages. unsuspend() now fans RestoreWithFrameItem out to every subframe process and collects results through a MainRunLoopSuccessCallbackAggregator, triggering a full reload(ExpiredOnly) if any process fails to restore. Process selection during navigation also changes: the suspended page's browsing context group is reused for the new navigation so existing RemotePageProxy handles stay valid, which is what motivates addPage() tolerating a page already present.

Before (single-process BFCache):
  SuspendedPageProxy
    ├─► SetIsSuspended(true)  ──────► WebPage [main frame process]
    └─► SetIsSuspended(false) ──────► WebPage [main frame process]

After (multi-process BFCache, Site Isolation):
  SuspendedPageProxy
    ├─ startSuspension():
    │    └─► SuspendWithFrameItem(id) ─────► WebPage [main frame process]
    │         └─◄ didSuspend: bool ◄────────┘
    │
    └─ unsuspend(mainFrameItemID):
         ├─► SetIsSuspended(false) ──────────► WebPage [main frame process]
         │
         ├── create MainRunLoopSuccessCallbackAggregator
         │
         └─► forEachRemotePage:
               └─► RestoreWithFrameItem(id) ─► WebPage [subframe process 1]
               └─► RestoreWithFrameItem(id) ─► WebPage [subframe process 2]
                    └─◄ success/fail ◄─────────┘
                         └─ [if any fail → page.reload(ExpiredOnly)]

BFCache restoration now coordinates suspend/restore across N WebContent processes simultaneously, making cached Site-Isolated pages survive back/forward navigation. The new IPC paths, the aggregated failure handling, and the process-routing changes all land on multi-process boundaries that carry cross-origin isolation guarantees.

Narrow: the partial-restoration window. SetIsSuspended(false) reaches the main frame process before the aggregator exists and before any RestoreWithFrameItem is sent. If a subframe subsequently fails, the aggregator fires reload(ExpiredOnly) on an already-live main frame — a window in which the main frame is restored and subframes are in an unknown state. Any navigation or script executing in that gap runs against a mixed-state page. The forward-facing version of this pattern: audit other multi-process fan-out restorations in WebKit/UIProcess/ where one participant is committed before the aggregator that decides whether the operation succeeded is constructed. The tell is a send/sendWithAsyncReply textually preceding the create of the aggregator meant to gate it.

Wider: invariants demoted to ASSERT on a multi-process routing path. addPage() now returns silently in release builds when the page is already present, with only ASSERT(!hasMultiplePages()) guarding the "suspended BCG holds exactly one page" claim. If a re-entrant navigation or a race between suspension and a new page attach violates it, process routing is corrupted with no observable error. This is a portable pattern: sweep BrowsingContextGroup, WebProcessProxy, and WebPageProxy for early-returns whose only correctness argument is a debug-only assertion on a security-relevant routing decision. The review tell is an if (...) { ASSERT(...); return; } where the assertion, not the condition, carries the invariant.

Widest: BFCache entry lifetime versus process reuse. removeEntriesForPageAndProcess is narrowed to the main frame only, so cross-site iframe process swaps no longer evict main-frame BFCache entries from the destination process — stale entries can accumulate in a process that is later recycled for a different origin. Combined with the browsing-context-group reuse during restoration, where RemotePageProxy references established before suspension remain bound during restore, the question generalizes: for any cache keyed on (page, process), audit whether process recycling across origins is paired with eviction. Start with a navigation that begins as a BFCache hit and transitions to a network load mid-flight, which is the shape most likely to route subframe processes into the wrong context group.