diff --git a/docs/validation/iframe-beforeunload-lifecycle-267.md b/docs/validation/iframe-beforeunload-lifecycle-267.md new file mode 100644 index 00000000..4c06b4ea --- /dev/null +++ b/docs/validation/iframe-beforeunload-lifecycle-267.md @@ -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. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc index 2a2862ed..a53f966f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc @@ -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 diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc index 04f2a5be..b38781a0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc @@ -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); @@ -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(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 5e30a3a8..fe7c8f7f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -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); @@ -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); @@ -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 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 result; + v8::Local arguments[] = {event}; + const auto dispatched = dispatcher.As()->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); @@ -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 { + const auto source = frame.attributes.find("src"); + return source == frame.attributes.end() + ? std::nullopt + : std::optional{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(); @@ -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) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc index 7cde6a43..a73c7b8e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc @@ -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; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc index 852b3590..c091210e 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc @@ -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") { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc index bc55241a..81f8332c 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc @@ -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(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index 22f1d808..8f101359 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -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(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index 27fc2dd7..388f8d29 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -29,6 +29,7 @@ std::unordered_map pending_frame_preparations; std::unordered_map frame_navigation_generations; + std::unordered_set frame_beforeunload_dispatches; std::unordered_set frame_pagehide_dispatched; std::vector>> retired_frame_preparations; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc index 393762d0..8a7cdfbf 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_resource_tests.inc @@ -128,6 +128,15 @@ void test_same_origin_iframe_navigation_document_replacement() {std::string{address}, "frame navigation"}, {"https://iframe-navigation.test/fake.html?id=markdown-preview", "Fake"}, + {"https://iframe-navigation.test/fake.html?id=beforeunload", + "Before unload" + "
Original
"}, + {"https://iframe-navigation.test/fake.html?id=allowed", + "Allowed" + "
Allowed
"}, {"https://cross-origin.test/fake.html", "Cross origin" ""} @@ -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, diff --git a/tests/WebPlatformSubset/contracts/iframe-beforeunload-navigation.html b/tests/WebPlatformSubset/contracts/iframe-beforeunload-navigation.html new file mode 100644 index 00000000..073bd49c --- /dev/null +++ b/tests/WebPlatformSubset/contracts/iframe-beforeunload-navigation.html @@ -0,0 +1,90 @@ + + +Nested beforeunload navigation contract + + + diff --git a/tests/WebPlatformSubset/contracts/resources/iframe-beforeunload-source.html b/tests/WebPlatformSubset/contracts/resources/iframe-beforeunload-source.html new file mode 100644 index 00000000..a5f4a9f9 --- /dev/null +++ b/tests/WebPlatformSubset/contracts/resources/iframe-beforeunload-source.html @@ -0,0 +1,15 @@ + + +Before unload source +
retained
+ diff --git a/tests/WebPlatformSubset/webscene-iframe-beforeunload-navigation-profile.json b/tests/WebPlatformSubset/webscene-iframe-beforeunload-navigation-profile.json new file mode 100644 index 00000000..c1132fe9 --- /dev/null +++ b/tests/WebPlatformSubset/webscene-iframe-beforeunload-navigation-profile.json @@ -0,0 +1,40 @@ +{ + "profile": "webscene-iframe-beforeunload-navigation-1", + "wptRevision": "2c705104a295c48053eeddf7fe0170d790a4e853", + "runtime": "v8", + "viewport": { + "width": 800, + "height": 600, + "deviceScaleFactor": 1 + }, + "required": [ + { + "path": "contracts/iframe-beforeunload-navigation.html", + "type": "contract", + "capabilities": [ + "nested-beforeunload-synchronous-dispatch", + "nested-beforeunload-cancellation", + "nested-navigation-reentrancy-guard", + "nested-navigation-generation-veto" + ], + "evidence": [ + "wpt-beforeunload-synchronous-2c705104", + "wpt-beforeunload-event-order-2c705104", + "wpt-beforeunload-canceling-2c705104", + "vscode-oss-webview-confirm-before-close" + ], + "reason": "Pinned-WPT-derived contract for synchronous nested beforeunload ordering and cancellation signaling. The native companion gate additionally proves that 100 canceled iframe src/document.open attempts preserve the active realm and generation, reject same-frame reentrant navigation, allocate no replacement resource work, and allow the next uncanceled navigation before pagehide." + } + ], + "harnessBlocked": [], + "excluded": [ + { + "path": "html/browsers/browsing-the-web/unloading-documents/prompt/manual-001.html", + "reason": "WebScene has no browser prompt or WebView dependency; native cancellation is returned directly to the nested navigation initiator." + }, + { + "path": "html/browsers/browsing-the-web/unloading-documents/beforeunload-sticky-activation-manual.html", + "reason": "Sticky-activation prompt UI is outside this reusable native lifecycle slice." + } + ] +}