fix(html): retain child parser EOF during script execution

This commit is contained in:
ldm0
2026-09-23 00:11:56 +08:00
parent 12475ed6fa
commit b09660c5e3
4 changed files with 139 additions and 1 deletions
+3
View File
@@ -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;
+124
View File
@@ -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 {
"<table><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
}
@@ -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(),
@@ -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)
{