diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 2cdbdbaa77..a628947594 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2960,11 +2960,7 @@ html/syntax/charset/xhr.html html/syntax/parsing/cdata-in-integration-point-fragment.html html/webappapis/dynamic-markup-insertion/document-write/module-delayed.html html/webappapis/dynamic-markup-insertion/document-write/module-static-import-delayed.html -html/webappapis/dynamic-markup-insertion/document-write/module-static-import.html html/webappapis/dynamic-markup-insertion/document-write/module-tla-delayed.html -html/webappapis/dynamic-markup-insertion/document-write/module-tla-immediate-promise.html -html/webappapis/dynamic-markup-insertion/document-write/module-tla-promise.html -html/webappapis/dynamic-markup-insertion/document-write/module.html html/webappapis/dynamic-markup-insertion/document-write/mutation-observer.html html/webappapis/dynamic-markup-insertion/document-write/write-active-document.html html/webappapis/scripting/event-loops/microtask_before_prepare_the_script_element-01.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 3fb895e804..6911a216ec 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -6940,7 +6940,11 @@ html/webappapis/dynamic-markup-insertion/document-write/iframe_007.html html/webappapis/dynamic-markup-insertion/document-write/iframe_008.html html/webappapis/dynamic-markup-insertion/document-write/iframe_009.html html/webappapis/dynamic-markup-insertion/document-write/module-dynamic-import.html +html/webappapis/dynamic-markup-insertion/document-write/module-static-import.html +html/webappapis/dynamic-markup-insertion/document-write/module-tla-immediate-promise.html html/webappapis/dynamic-markup-insertion/document-write/module-tla-import.html +html/webappapis/dynamic-markup-insertion/document-write/module-tla-promise.html +html/webappapis/dynamic-markup-insertion/document-write/module.html html/webappapis/dynamic-markup-insertion/document-write/script_001.html html/webappapis/dynamic-markup-insertion/document-write/script_002.html html/webappapis/dynamic-markup-insertion/document-write/script_003.html diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index f6f6d53661..6d42025f14 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -31,6 +31,9 @@ mod child_markup_insertion; #[path = "scripts/child_script_text.rs"] mod child_script_text; +#[path = "scripts/module_document_write.rs"] +mod module_document_write; + fn diagnostic_global<'a>( page: &'a moli_core::page::Page, name: &str, diff --git a/moli-core/tests/scripts/module_document_write.rs b/moli-core/tests/scripts/module_document_write.rs new file mode 100644 index 0000000000..2660432ee7 --- /dev/null +++ b/moli-core/tests/scripts/module_document_write.rs @@ -0,0 +1,275 @@ +use super::*; + +fn markup_url(server: &FixtureServer, markup: &str) -> String { + let mut url = url::Url::parse(&server.url("/compat/child-dynamic-markup-document")).unwrap(); + url.query_pairs_mut().append_pair("markup", markup); + url.into() +} + +fn module_url(source: &str) -> String { + format!( + "data:text/javascript,{}", + url::form_urlencoded::byte_serialize(source.as_bytes()) + .collect::() + .replace('+', "%20") + ) +} + +const WRITE_ATTEMPTS: &str = r#" + for (const method of ['write', 'writeln']) { + let converted = false; + const result = document[method]({toString() { + converted = true; + return '

written

'; + }}); + writeLog.push({method, converted, undefinedResult: result === undefined, + original: !!document.getElementById('original'), + written: !!document.getElementById('written'), + currentScriptNull: document.currentScript === null}); + } + document.close(); +"#; + +async fn module_document_write_matrix(child: bool) -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let imported = serde_json::to_string(&module_url(WRITE_ATTEMPTS))?; + let variants = [ + ("sync", WRITE_ATTEMPTS.to_owned()), + ( + "microtask", + format!("Promise.resolve().then(() => {{ {WRITE_ATTEMPTS} }});"), + ), + ( + "tla-immediate", + format!("await Promise.resolve(); {WRITE_ATTEMPTS}"), + ), + ("static-import", format!("import {imported};")), + ("external", WRITE_ATTEMPTS.to_owned()), + ( + "throw", + format!("{WRITE_ATTEMPTS} throw new Error('module write probe');"), + ), + ]; + let mut results = Vec::new(); + for (name, source) in variants { + let script = if name == "external" { + format!( + "", + module_url(&source) + ) + } else { + format!("") + }; + let markup = format!( + "

original

{script}" + ); + let source_url = markup_url(&server, &markup); + let url = if child { + markup_url( + &server, + &format!( + "", + source_url.replace('&', "&") + ), + ) + } else { + source_url + }; + let mut page = browser.fetch(&url).await?; + let observed = page + .evaluate_runtime_expression_with_await_async( + r#"(() => { + const target = document.getElementById('target')?.contentWindow || window; + const duringModule = target.writeLog; + // The initial module evaluation, its cleanup checkpoint and any + // exception handling have ended. This later write must work. + target.document.write('

late

'); + target.document.close(); + return JSON.stringify({duringModule, + laterWrite: target.document.getElementById('late')?.textContent}); + })()"#, + true, + ) + .await?; + let observed: serde_json::Value = + serde_json::from_str(observed["value"].as_str().expect("module write probe"))?; + results.push(serde_json::json!({"name": name, "observed": observed})); + } + server.shutdown().await; + let expected_log = ["write", "writeln"].map(|method| { + serde_json::json!({"method": method, "converted": true, "undefinedResult": true, + "original": true, "written": false, "currentScriptNull": true}) + }); + let expected: Vec<_> = [ + "sync", + "microtask", + "tla-immediate", + "static-import", + "external", + "throw", + ] + .map(|name| { + serde_json::json!({"name": name, "observed": { + "duringModule": expected_log, "laterWrite": "late" + }}) + }) + .into(); + assert_eq!(results, expected, "child={child}"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn root_module_scripts_ignore_destructive_writes_through_cleanup() -> Result<()> { + module_document_write_matrix(false).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn child_module_scripts_ignore_destructive_writes_through_cleanup() -> Result<()> { + module_document_write_matrix(true).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn modules_can_explicitly_open_a_document_stream() -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + for child in [false, true] { + let source_url = markup_url( + &server, + &format!( + "

original

" + ), + ); + let url = if child { + markup_url( + &server, + &format!( + "", + source_url.replace('&', "&") + ), + ) + } else { + source_url + }; + let mut page = browser.fetch(&url).await?; + let observed = page + .evaluate_runtime_expression_with_await_async( + r#"(() => { + const target = document.getElementById('target')?.contentWindow || window; + return JSON.stringify({log: target.writeLog, + body: target.document.body.textContent}); + })()"#, + true, + ) + .await?; + let observed: serde_json::Value = + serde_json::from_str(observed["value"].as_str().expect("explicit stream"))?; + let expected_log = ["write", "writeln"].map(|method| { + serde_json::json!({"method": method, "converted": true, "undefinedResult": true, + "original": false, "written": true, "currentScriptNull": true}) + }); + assert_eq!( + observed, + serde_json::json!({"log": expected_log, + "body": "writtenwritten\n"}), + "child={child}" + ); + } + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn module_write_guard_is_released_before_pending_tla_continues() -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + for child in [false, true] { + let source_url = markup_url( + &server, + r#"

original

+ "#, + ); + let url = if child { + markup_url( + &server, + &format!( + "", + source_url.replace('&', "&") + ), + ) + } else { + source_url + }; + let mut page = browser + .fetch_with_wait_until(&url, RenderedDomWaitUntil::Load, Duration::from_secs(5)) + .await?; + let observed = page + .evaluate_runtime_expression_with_await_async( + r#"(async () => { + const target = document.getElementById('target')?.contentWindow || window; + const original = !!target.document.getElementById('original'); + const finished = target.moduleFinished; + target.resumeModule(); + await finished; + return JSON.stringify({original, + later: target.document.getElementById('later')?.textContent}); + })()"#, + true, + ) + .await?; + let observed: serde_json::Value = + serde_json::from_str(observed["value"].as_str().expect("pending TLA write"))?; + assert_eq!( + observed, + serde_json::json!({"original": true, "later": "later"}), + "child={child}" + ); + } + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn module_write_guard_follows_the_document_across_callback_realms() -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let child_url = markup_url( + &server, + r#"

original

+ "#, + ); + let parent = format!( + r#" + "#, + child_url.replace('&', "&") + ); + let mut page = browser.fetch(&markup_url(&server, &parent)).await?; + let observed = page + .evaluate_runtime_expression_with_await_async("JSON.stringify(writeResult)", true) + .await?; + server.shutdown().await; + let observed: serde_json::Value = + serde_json::from_str(observed["value"].as_str().expect("cross-realm writes"))?; + assert_eq!( + observed, + serde_json::json!({"protected": true, "blocked": false, "other": "allowed"}) + ); + Ok(()) +} diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index 74dcf0920e..9f650ffa7c 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -7,6 +7,7 @@ use std::{ use url::Url; +mod destructive_writes; mod devtools_mutations; mod document_write; mod dom_facade; @@ -774,6 +775,7 @@ pub(super) struct DocumentRuntime { document_character_set: String, resource_loader_binding: Option, script_context_stack: Vec, + destructive_write_counters: destructive_writes::DocumentWriteCounters, root_document_parser: Option, post_parse_schedule_invalidated: bool, stylesheet_lifecycle: StylesheetLifecycleState, diff --git a/moli-renderer-v8/src/document_runtime/destructive_writes.rs b/moli-renderer-v8/src/document_runtime/destructive_writes.rs new file mode 100644 index 0000000000..f1f657c3bf --- /dev/null +++ b/moli-renderer-v8/src/document_runtime/destructive_writes.rs @@ -0,0 +1,90 @@ +use std::{cell::RefCell, collections::HashMap, rc::Rc}; + +use super::{DocumentRuntime, DomHandle}; + +#[derive(Debug, Default)] +pub(super) struct DocumentWriteCounters(Rc>>); + +/// Owns an execute-script-element counter through script cleanup, including +/// its microtask checkpoint, but not subsequent tasks or pending TLA work. +/// The shared state avoids borrowing the runtime across JavaScript reentry. +pub(crate) struct IgnoreDestructiveWritesGuard { + counters: Rc>>, + document: DomHandle, +} + +impl DocumentWriteCounters { + fn enter(&self, document: DomHandle) -> IgnoreDestructiveWritesGuard { + let mut counters = self.0.borrow_mut(); + let counter = counters.entry(document).or_default(); + *counter = counter + .checked_add(1) + .expect("document write counter overflow"); + IgnoreDestructiveWritesGuard { + counters: Rc::clone(&self.0), + document, + } + } + + fn is_active(&self, document: DomHandle) -> bool { + self.0.borrow().contains_key(&document) + } +} + +impl Drop for IgnoreDestructiveWritesGuard { + fn drop(&mut self) { + let mut counters = self.counters.borrow_mut(); + let counter = counters + .get_mut(&self.document) + .expect("document write guard requires a matching counter"); + *counter -= 1; + if *counter == 0 { + counters.remove(&self.document); + } + } +} + +impl DocumentRuntime { + pub(crate) fn enter_ignore_destructive_writes( + &self, + document: DomHandle, + ) -> IgnoreDestructiveWritesGuard { + self.destructive_write_counters.enter(document) + } + + pub(crate) fn has_ignore_destructive_writes_counter(&self, document: DomHandle) -> bool { + self.destructive_write_counters.is_active(document) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn destructive_write_counters_are_nested_and_document_scoped() { + let counters = DocumentWriteCounters::default(); + let document = DomHandle::new(0); + let other = DomHandle::new(1); + let outer = counters.enter(document); + assert!(counters.is_active(document)); + assert!(!counters.is_active(other)); + let inner = counters.enter(document); + let other_guard = counters.enter(other); + drop(outer); + assert!(counters.is_active(document)); + drop(inner); + assert!(!counters.is_active(document)); + assert!(counters.is_active(other)); + drop(other_guard); + assert!(counters.0.borrow().is_empty()); + } + + #[test] + fn destructive_write_guard_can_outlive_retired_runtime_state() { + let counters = DocumentWriteCounters::default(); + let guard = counters.enter(DomHandle::new(0)); + drop(counters); + drop(guard); + } +} diff --git a/moli-renderer-v8/src/document_runtime/runtime_core.rs b/moli-renderer-v8/src/document_runtime/runtime_core.rs index 52ec420ccd..6175d414d3 100644 --- a/moli-renderer-v8/src/document_runtime/runtime_core.rs +++ b/moli-renderer-v8/src/document_runtime/runtime_core.rs @@ -82,6 +82,7 @@ impl DocumentRuntime { document_character_set: "UTF-8".to_owned(), resource_loader_binding: None, script_context_stack: Vec::new(), + destructive_write_counters: Default::default(), root_document_parser: None, post_parse_schedule_invalidated: false, stylesheet_lifecycle, @@ -215,6 +216,7 @@ impl DocumentRuntime { document_character_set: _, resource_loader_binding: _, script_context_stack: _, + destructive_write_counters: _, root_document_parser: _, post_parse_schedule_invalidated: _, stylesheet_lifecycle: _, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/parser_store.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/parser_store.rs index 8e15266f78..065a98fee4 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/parser_store.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/parser_store.rs @@ -1,4 +1,3 @@ -#[cfg(test)] use crate::live_document_parser::DocumentParserLifetime; use crate::{ frame_owner_model::FrameDocumentOwner, @@ -37,7 +36,6 @@ impl ChildDocumentParserStore { self.sessions.remove(&owner) } - #[cfg(test)] pub(in crate::native_bridge::context_host) fn has_open_stream( &self, owner: FrameDocumentOwner, diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs index f2c09e7e07..59dc577327 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs @@ -346,6 +346,22 @@ fn child_document_write_or_writeln_callback<'s>( rv.set_undefined(); return; } + let Some(document_handle) = child_document_native_handle_for_runtime(scope, host_ptr, document) + else { + rv.set_undefined(); + return; + }; + let has_open_stream = host + .frame_owner_store + .current_child_document_owner(handle) + .is_some_and(|owner| host.child_document_parsers.has_open_stream(owner)); + if host.has_ignore_destructive_writes_counter(document_handle) + && !has_open_stream + && !host.child_document_is_executing_parser_script(document_handle) + { + rv.set_undefined(); + return; + } let script_context = if host.child_document_parser_is_active(handle) { match unsafe { &mut *host_ptr }.ensure_prebootstrapped_child_default_context(scope, handle) { @@ -367,11 +383,6 @@ fn child_document_write_or_writeln_callback<'s>( }; context }; - let Some(document_handle) = child_document_native_handle_for_runtime(scope, host_ptr, document) - else { - rv.set_undefined(); - return; - }; let _ = unsafe { &mut *host_ptr }.pump_child_document_write_parser( scope, script_context, diff --git a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs index 26596071eb..a93d005321 100644 --- a/moli-renderer-v8/src/native_bridge/document/lifecycle.rs +++ b/moli-renderer-v8/src/native_bridge/document/lifecycle.rs @@ -141,7 +141,8 @@ fn node_document_write_or_writeln_callback<'s>( let implicit_replacement_session = !runtime.has_active_parser_write_insertion_point() && !runtime.host_document().replace_on_close(); if implicit_replacement_session - && current_script_ignores_document_write_without_parser_insertion_point(runtime) + && (runtime.has_ignore_destructive_writes_counter(handle) + || current_script_ignores_document_write_without_parser_insertion_point(runtime)) { rv.set_undefined(); return; diff --git a/moli-renderer-v8/src/script_vm/native_module.rs b/moli-renderer-v8/src/script_vm/native_module.rs index 00925ebe50..fe86db2fbc 100644 --- a/moli-renderer-v8/src/script_vm/native_module.rs +++ b/moli-renderer-v8/src/script_vm/native_module.rs @@ -3734,6 +3734,14 @@ impl ScriptVm { })?; self.document_runtime .mark_native_module_evaluating(root_entry); + // currentScript is null for modules. The execute-script-element guard + // belongs to its Document and lasts through the cleanup checkpoint, + // not the lifetime of the module's evaluation promise. + let _ignore_destructive_writes = + (owner == NativeModuleEvaluationOwner::Script).then(|| { + self.document_runtime + .enter_ignore_destructive_writes(self.document_runtime.document_handle()) + }); let promise = self .renderer_document_isolate .with_entered_renderer_document_isolate(|isolate| { diff --git a/moli-renderer-v8/src/script_vm/native_module/child_dynamic_import.rs b/moli-renderer-v8/src/script_vm/native_module/child_dynamic_import.rs index 22cad14812..4960a5a3a8 100644 --- a/moli-renderer-v8/src/script_vm/native_module/child_dynamic_import.rs +++ b/moli-renderer-v8/src/script_vm/native_module/child_dynamic_import.rs @@ -1042,6 +1042,25 @@ impl ScriptVm { ) })?; document_modulator.mark_evaluating(root_entry); + let _ignore_destructive_writes = if owner == NativeModuleEvaluationOwner::Script { + let host = self._context_host.borrow(); + let document = host + .frame_owner_current_child_snapshot_for_realm(realm_id) + .filter(|snapshot| { + snapshot.local_window_id == document_owner.local_window_id + && snapshot.document_id == document_owner.document_id + }) + .ok_or_else(|| { + ModuleLoadError::new( + ModuleLoadStage::Evaluate, + "module script has no current child Document for its execution guard", + ) + })?; + Some(host.enter_ignore_destructive_writes(document.document_handle)) + } else { + // import() is not execution of a script element. + None + }; let promise = self .renderer_document_isolate .with_entered_renderer_document_isolate(|isolate| {