Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions docs/validation/iframe-beforeunload-lifecycle-267.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Nested beforeunload lifecycle contract

## Scope and baseline

The #253 and #265-#268 audit started from exact WebScene `main`
`2b64b08965aeba5ecbd33fcc0bd1beaaceaf6243`. Before commit, the slice was
rebased onto exact current `main`
`eaf54e5f51a8377c7b260c2001dfe67a59c28c24`. The audit found the sandbox token
surface, Service Worker control/resource planes,
same-origin WindowProxy and `document.open/write/close`, clipboard Permissions
Policy, parser script CSP, per-frame navigation generations, and one-shot
`pagehide` already merged. The first remaining #267 lifecycle gap was a
cancelable `beforeunload` decision before an authored nested navigation.

The implementation dispatches one synchronous, non-bubbling, cancelable
`beforeunload` in the outgoing frame realm for iframe `src` mutation and
`document.open()`. `preventDefault()` or `event.returnValue = ''` vetoes the
navigation before `pagehide`, generation advance, pending hydration retirement,
drag/download retirement, or replacement resource preparation. This is the
unchanged Code OSS `browser/pre/index.html` confirm-before-close shape.

Navigation of the same frame from its own `beforeunload` handler is rejected.
The initiating source wins over a reentrant source write; sibling frame work is
not globally locked. An allowed navigation retains the stable owner-realm
WindowProxy and proceeds through the existing generation and teardown path.
Iframe detach keeps its existing non-cancelable discard behavior.

## Bounds and authority

The reentrancy set contains at most one entry per synchronously unloading live
frame and is cleared after dispatch, frame detach, replacement activation,
top-level navigation, and runtime teardown. A veto creates no navigation queue
or future, resource request, host request, replacement realm, or new generation.
No
browser process, WebView, modal prompt, filesystem authority, network authority,
or AppScene ABI is added. Existing sandbox/origin access, CSP, Permissions
Policy, resource admission, download leases, and generation ownership are
unchanged.

## Authored contracts

- `tests/WebPlatformSubset/contracts/iframe-beforeunload-navigation.html`
derives synchronous dispatch and event ordering from pinned WPT
`beforeunload-synchronous.html`, `prompt/001.html`, and
`beforeunload-canceling.html` at
`2c705104a295c48053eeddf7fe0170d790a4e853`.
- The `iframe-navigation-lifecycle` native gate covers 50 canceled
`document.open()` replacements, 50 canceled reflected `src` mutations,
same-frame reentrancy, unchanged Document/WindowProxy identity, zero
pre-decision `pagehide`, the following allowed navigation, and the existing
heap, DOM-node, RSS, 100-cycle, one-MiB replacement, origin, and teardown
bounds.

Manual beforeunload prompt and sticky-activation WPTs are excluded because this
native runtime has no browser prompt surface. `onbeforeunload` return-string
conversion, visibility transitions, `unload`, `location.reload()`, history
traversal, cross-origin WindowProxy capabilities, and broad event-order product
qualification remain outside this slice. The next #267 implementation gap is
the outgoing document visibility transition and `unload` ordering after an
allowed navigation or discard.

## Validation status

Per the task constraint, no build, browser/WPT run, native test, benchmark,
product check, package gate, or CI job was run. Only `git diff --check` is used
for this commit. The authored profile and native assertions remain unexecuted.
Original file line number Diff line number Diff line change
Expand Up @@ -1234,6 +1234,7 @@
actual_frame_windows[frame.id].Reset(isolate, global);
actual_frame_contexts[frame.id].Reset(isolate, local_context);
actual_frame_documents[frame.id].Reset(isolate, frame_document_value);
frame_beforeunload_dispatches.erase(frame.id);
frame_pagehide_dispatched.erase(frame.id);
if (same_origin_access) {
// Every browsing context has a WindowProxy even when script has
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -787,8 +787,9 @@
return;
}
node->attributes[name] = value;
auto iframe_navigation_allowed = true;
if (name == "src" && node->tag == "iframe") {
self->begin_iframe_navigation(*node);
iframe_navigation_allowed = self->begin_iframe_navigation(*node);
}
if (node->tag == "base" && name == "href") {
self->invalidate_document_base(*node);
Expand All @@ -811,7 +812,9 @@
node->style.inline_property_mask |= inline_property_mask;
}
else if (name == "src" && node->tag == "iframe") {
self->enqueue_iframe_hydration_if_needed(*node);
if (iframe_navigation_allowed) {
self->enqueue_iframe_hydration_if_needed(*node);
}
}
else if (name == "src" && node->tag == "img") {
node->clear_replaced_image();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1386,6 +1386,7 @@
actual_frame_contexts.erase(id);
frame_hydration_states.erase(id);
retire_iframe_navigation(id);
frame_beforeunload_dispatches.erase(id);
frame_pagehide_dispatched.erase(id);
actual_frame_windows.erase(id);
actual_frame_documents.erase(id);
Expand Down Expand Up @@ -1946,6 +1947,7 @@
actual_frame_contexts.erase(id);
frame_hydration_states.erase(id);
retire_iframe_navigation(id);
frame_beforeunload_dispatches.erase(id);
frame_pagehide_dispatched.erase(id);
actual_frame_documents.erase(id);
frame_session_storage.erase(id);
Expand Down Expand Up @@ -2517,6 +2519,52 @@
.first->second;
}

bool dispatch_iframe_beforeunload(dom_node& frame)
{
const auto known = actual_frame_contexts.find(frame.id);
if (known == actual_frame_contexts.end() || known->second.IsEmpty()) {
return true;
}
if (frame_pagehide_dispatched.contains(frame.id)) return true;
// HTML blocks a navigation targeted at the browsing context which is
// already prompting to unload. A sibling frame remains independent.
if (!frame_beforeunload_dispatches.insert(frame.id).second) return false;
auto local_context = known->second.Get(isolate);
v8::Context::Scope context_scope(local_context);
auto target = local_context->Global();
v8::Local<v8::Value> dispatcher;
if (!target->Get(local_context, js_string(isolate, "dispatchEvent"))
.ToLocal(&dispatcher)
|| !dispatcher->IsFunction()) {
frame_beforeunload_dispatches.erase(frame.id);
return true;
}
auto event = create_event_instance(local_context);
event->Set(local_context, js_string(isolate, "type"),
js_string(isolate, "beforeunload")).Check();
event->Set(local_context, js_string(isolate, "bubbles"),
v8::False(isolate)).Check();
event->Set(local_context, js_string(isolate, "cancelable"),
v8::True(isolate)).Check();
event->Set(local_context, js_string(isolate, "defaultPrevented"),
v8::False(isolate)).Check();
v8::TryCatch try_catch(isolate);
v8::Local<v8::Value> result;
v8::Local<v8::Value> arguments[] = {event};
const auto dispatched = dispatcher.As<v8::Function>()->Call(
local_context, target, 1, arguments).ToLocal(&result);
perform_microtask_checkpoint();
frame_beforeunload_dispatches.erase(frame.id);
if (!dispatched) {
frame_last_error_value = "Nested beforeunload dispatch failed: "
+ describe_reported_exception(try_catch, local_context);
++frame_script_error_count;
report_document_script_error(frame_last_error_value);
return true;
}
return !result->IsFalse();
}

void dispatch_iframe_pagehide(dom_node& frame)
{
const auto known = actual_frame_contexts.find(frame.id);
Expand Down Expand Up @@ -2546,8 +2594,28 @@
perform_microtask_checkpoint();
}

void begin_iframe_navigation(dom_node& frame)
bool begin_iframe_navigation(dom_node& frame)
{
const auto requested_source = [&]() -> std::optional<std::string> {
const auto source = frame.attributes.find("src");
return source == frame.attributes.end()
? std::nullopt
: std::optional<std::string>{source->second};
}();
const auto restore_requested_source = [&] {
if (requested_source.has_value()) {
frame.attributes["src"] = *requested_source;
} else {
frame.attributes.erase("src");
}
};
if (!dispatch_iframe_beforeunload(frame)) {
restore_requested_source();
return false;
}
// A beforeunload handler cannot replace the navigation which caused
// its own dispatch. Preserve the initiator's reflected source.
restore_requested_source();
if (active_drag_session.has_value()
&& active_drag_session->frame_owner_id == frame.id) {
active_drag_session.reset();
Expand Down Expand Up @@ -2580,6 +2648,7 @@
pending_frame_preparations.erase(preparation);
}
std::erase(pending_frame_hydrations, &frame);
return true;
}

void retire_iframe_navigation(uint32_t frame_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3769,8 +3769,9 @@
if (node == nullptr) return;
node->attributes["src"] = to_utf8(info.GetIsolate(), value);
if (node->tag == "iframe") {
self->begin_iframe_navigation(*node);
self->enqueue_iframe_hydration_if_needed(*node);
if (self->begin_iframe_navigation(*node)) {
self->enqueue_iframe_hydration_if_needed(*node);
}
return;
}
if (node->tag != "img") return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@
auto* self = current(info.GetIsolate());
auto* node = provisional_frame_owner(info.This());
if (node == nullptr) return;
self->begin_iframe_navigation(*node);
if (!self->begin_iframe_navigation(*node)) return;
node->attributes["frame-html"].clear();
auto* target = unwrap_node(info.This());
if (target != nullptr && target->tag == "html") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,7 @@
frame_window_scroll_offsets.clear();
frame_load_listeners.clear();
frame_dom_content_loaded_listeners.clear();
frame_beforeunload_dispatches.clear();
frame_pagehide_dispatched.clear();
frame_event_listeners.clear();
window_selections.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@
resize_observers_pending = false;
frame_load_listeners.clear();
frame_dom_content_loaded_listeners.clear();
frame_beforeunload_dispatches.clear();
frame_pagehide_dispatched.clear();
frame_event_listeners.clear();
window_selections.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
std::unordered_map<uint32_t, pending_frame_preparation>
pending_frame_preparations;
std::unordered_map<uint32_t, uint64_t> frame_navigation_generations;
std::unordered_set<uint32_t> frame_beforeunload_dispatches;
std::unordered_set<uint32_t> frame_pagehide_dispatched;
std::vector<std::shared_future<std::shared_ptr<const prepared_frame>>>
retired_frame_preparations;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ void test_same_origin_iframe_navigation_document_replacement()
{std::string{address}, "<!doctype html><title>frame navigation</title><body></body>"},
{"https://iframe-navigation.test/fake.html?id=markdown-preview",
"<!doctype html><title>Fake</title><body></body>"},
{"https://iframe-navigation.test/fake.html?id=beforeunload",
"<!doctype html><title>Before unload</title><body>"
"<main id='original'>Original</main><script>"
"addEventListener('beforeunload',event=>parent.__iframeBeforeUnloadHandler(event));"
"addEventListener('pagehide',event=>parent.__iframePageHideHandler(event));"
"</script></body>"},
{"https://iframe-navigation.test/fake.html?id=allowed",
"<!doctype html><title>Allowed</title><body>"
"<main id='allowed'>Allowed</main></body>"},
{"https://cross-origin.test/fake.html",
"<!doctype html><title>Cross origin</title><body>"
"<script>parent.__crossOriginEscape=true;</script></body>"}
Expand Down Expand Up @@ -222,6 +231,109 @@ void test_same_origin_iframe_navigation_document_replacement()
require(webscene_engine_get_memory_metrics(engine, &memory_before) != 0,
"iframe navigation memory baseline was unavailable");
const auto rss_before = service_worker_test_current_rss_bytes();
execute(engine, R"JS(
globalThis.__iframeBeforeUnload = {
complete:false, error:null, beforeunload:0, pagehide:0,
synchronous:true, eventShape:true, vetoPreserved:false,
reentrantBlocked:false, allowedLoaded:false, stableProxy:false
};
(() => {
const state = __iframeBeforeUnload;
try {
const frame = document.createElement('iframe');
frame.setAttribute('sandbox', 'allow-same-origin allow-scripts');
let vetoEnabled = true;
globalThis.__iframeBeforeUnloadHandler = unloadEvent => {
++state.beforeunload;
state.eventShape = state.eventShape
&& unloadEvent.type === 'beforeunload'
&& unloadEvent.bubbles === false
&& unloadEvent.cancelable === true;
if (!vetoEnabled) return;
frame.src = './fake.html?id=reentrant';
unloadEvent.preventDefault();
unloadEvent.returnValue = '';
};
globalThis.__iframePageHideHandler = pagehideEvent => {
++state.pagehide;
state.eventShape = state.eventShape
&& pagehideEvent.bubbles === false
&& pagehideEvent.cancelable === false
&& pagehideEvent.persisted === false;
};
frame.src = './fake.html?id=beforeunload';
document.body.appendChild(frame);
const proxy = frame.contentWindow;
let initialLoaded = false;
proxy.addEventListener('DOMContentLoaded', event => {
try {
if (!initialLoaded) {
initialLoaded = true;
const initialDocument = event.target;
for (let attempt = 0; attempt < 50; ++attempt) {
const before = state.beforeunload;
initialDocument.open();
state.synchronous = state.synchronous
&& state.beforeunload === before + 1;
}
for (let attempt = 0; attempt < 50; ++attempt) {
const before = state.beforeunload;
if ((attempt & 1) === 0) {
frame.src = `./blocked.html?attempt=${attempt}`;
} else {
frame.setAttribute('src', `./blocked.html?attempt=${attempt}`);
}
state.synchronous = state.synchronous
&& state.beforeunload === before + 1;
}
state.vetoPreserved = state.pagehide === 0
&& initialDocument.querySelector('#original')?.textContent === 'Original'
&& frame.contentDocument === initialDocument
&& frame.contentWindow === proxy;
state.reentrantBlocked = !initialDocument.location.href.includes('reentrant');

vetoEnabled = false;
const before = state.beforeunload;
frame.src = './fake.html?id=allowed';
state.synchronous = state.synchronous
&& state.beforeunload === before + 1
&& state.pagehide === 1;
return;
}

state.allowedLoaded = event.target.querySelector('#allowed')?.textContent
=== 'Allowed';
state.stableProxy = frame.contentWindow === proxy;
frame.remove();
delete globalThis.__iframeBeforeUnloadHandler;
delete globalThis.__iframePageHideHandler;
state.complete = true;
} catch (error) {
state.error = String(error && error.stack || error);
frame.remove();
state.complete = true;
}
});
} catch (error) {
state.error = String(error && error.stack || error);
state.complete = true;
}
})();
)JS", "iframe-beforeunload-lifecycle.js");
require(evaluate_until_equals(engine, "__iframeBeforeUnload.complete",
"iframe-beforeunload-complete.js", "true", 3'000) == "true",
"nested beforeunload gate timed out: "
+ evaluate(engine, "JSON.stringify(__iframeBeforeUnload)",
"iframe-beforeunload-timeout-detail.js"));
require(evaluate(engine, R"JS((() => {
const s=__iframeBeforeUnload;
return s.error===null && s.beforeunload===101 && s.pagehide===1
&& s.synchronous && s.eventShape && s.vetoPreserved
&& s.reentrantBlocked && s.allowedLoaded && s.stableProxy;
})())JS", "iframe-beforeunload-result.js") == "true",
"nested beforeunload cancellation diverged: "
+ evaluate(engine, "JSON.stringify(__iframeBeforeUnload)",
"iframe-beforeunload-result-detail.js"));
execute(engine, R"JS(
globalThis.__iframeLifecycleGate = {
complete:false, error:null, cycles:0, maximumLiveFrames:0,
Expand Down
Loading
Loading