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 {
+ "inserted
"
+ };
+ 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!(
+ "",
+ url::form_urlencoded::byte_serialize(source.as_bytes())
+ .collect::()
+ .replace('+', "%20")
+ )
+ } else {
+ format!("")
+ };
+ if !character_chunks {
+ written.push_str("tail");
+ }
+ let parent = format!(
+ r#""#,
+ serde_json::to_string(&written)?.replace("", "<\\/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)
{