diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index 80e4e7344b..4a857cecbc 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -31,6 +31,9 @@ mod child_markup_insertion; #[path = "scripts/module_document_write.rs"] mod module_document_write; +#[path = "scripts/document_close.rs"] +mod document_close; + #[path = "scripts/parser_cleanup.rs"] mod parser_cleanup; diff --git a/moli-core/tests/scripts/document_close.rs b/moli-core/tests/scripts/document_close.rs new file mode 100644 index 0000000000..1fa5cdac8a --- /dev/null +++ b/moli-core/tests/scripts/document_close.rs @@ -0,0 +1,124 @@ +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() +} + +async fn written_child_script_close_finishes_load( + character_chunks: bool, + plaintext: bool, + external: bool, +) -> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let inserted = if plaintext { + "Filler " + } else { + "<p id=inserted>inserted</p>" + }; + let source = format!( + r#" + document.addEventListener('DOMContentLoaded', () => parent.events.push('child-DCL')); + addEventListener('load', () => parent.events.push('child-load:' + document.readyState)); + document.write({}); + document.close(); + parent.events.push('close-return:' + document.readyState); + "#, + serde_json::to_string(inserted)? + ); + let mut written = if external { + format!( + "<script src=\"data:text/javascript,{}\"></script>", + url::form_urlencoded::byte_serialize(source.as_bytes()) + .collect::<String>() + .replace('+', "%20") + ) + } else { + format!("<script>{source}</script>") + }; + if !character_chunks { + written.push_str("<main id=tail>tail</main>"); + } + let parent = format!( + r#"<!doctype html><body><script> + window.events = []; + addEventListener('load', () => events.push('parent-load:' + document.readyState)); + const frame = document.createElement('iframe'); + frame.id = 'target'; + document.body.append(frame); + const written = {}; + if ({character_chunks}) {{ + for (const character of written) frame.contentDocument.write(character); + }} else {{ + frame.contentDocument.write(written); + }} + </script>"#, + serde_json::to_string(&written)?.replace("</script>", "<\\/script>") + ); + // The written script is the only caller of close(). Fetch must finish both + // the child and its load-blocked parent without another close from outside. + let mut page = tokio::time::timeout( + Duration::from_secs(10), + browser.fetch(&markup_url(&server, &parent)), + ) + .await??; + let result = page + .evaluate_runtime_expression_with_await_async( + r#"(() => { + const child = document.getElementById('target').contentDocument; + return JSON.stringify({events, readyState: child.readyState, + inserted: child.getElementById('inserted')?.textContent ?? null, + tail: child.getElementById('tail')?.textContent ?? null, + children: Array.from(child.body.children, node => [node.localName, node.textContent])}); + })()"#, + true, + ) + .await?; + let result: serde_json::Value = + serde_json::from_str(result["value"].as_str().expect("child close observation"))?; + assert_eq!(result["readyState"], "complete"); + assert_eq!( + result["events"], + serde_json::json!([ + "close-return:loading", + "child-DCL", + "child-load:complete", + "parent-load:complete" + ]) + ); + if plaintext { + assert_eq!( + result["children"], + serde_json::json!([["plaintext", "Filler "], ["table", ""]]) + ); + } else { + assert_eq!(result["inserted"], "inserted"); + } + if !character_chunks { + assert_eq!(result["tail"], "tail"); + } + server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn child_document_close_in_character_chunked_script_finishes_load() -> Result<()> { + written_child_script_close_finishes_load(true, false, false).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn child_document_close_in_plaintext_writer_finishes_load() -> Result<()> { + written_child_script_close_finishes_load(true, true, false).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn child_document_close_in_written_script_drains_tail_and_finishes_load() -> Result<()> { + written_child_script_close_finishes_load(false, false, false).await +} + +#[tokio::test(flavor = "multi_thread")] +async fn child_document_close_in_external_writer_drains_tail_and_finishes_load() -> Result<()> { + written_child_script_close_finishes_load(false, false, true).await +} diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs index cb7f5ac8e6..d92d654802 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_documents/live_parser.rs @@ -1319,7 +1319,7 @@ impl JsContextHost { return true; } let executing_parser_script = - chunk.is_some() && self.child_document_is_executing_parser_script(document_handle); + self.child_document_is_executing_parser_script(document_handle); let nested_insertion = matches!( insertion.run_state(), DocumentParserRunState::Pumping { .. } @@ -1330,7 +1330,9 @@ impl JsContextHost { || executing_parser_script || nested_insertion); let ready = if close_requested { + // Record EOF during parser-script execution and let its continuation finish. insertion.request_close() == DocumentParserCloseDisposition::DrainNow + && !executing_parser_script } else { matches!( insertion.run_state(), 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 0315102646..a0943b75cd 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 @@ -448,6 +448,15 @@ fn child_document_close_callback<'s>( rv.set_undefined(); return; } + // Only script-created streams accept EOF from document.close(). + if !host + .frame_owner_store + .current_child_document_owner(handle) + .is_some_and(|owner| host.child_document_parsers.has_open_stream(owner)) + { + rv.set_undefined(); + return; + } let script_context = match unsafe { &mut *host_ptr } .ensure_prebootstrapped_child_default_context(scope, handle) {