fix(parser): defer document close while blocked

This commit is contained in:
ldm0
2026-08-12 13:56:02 +08:00
parent d1e300a3e6
commit 375c48544b
4 changed files with 191 additions and 16 deletions
@@ -354,15 +354,10 @@ impl DocumentRuntime {
closed,
"a live root parser must own an open document stream"
);
let close_transitioned = self
.root_document_parser
self.root_document_parser
.as_mut()
.expect("root document parser existence was checked")
.request_close();
debug_assert!(
close_transitioned,
"an open root document parser must accept its first close request"
);
}
let _ = self.finish_root_document_parser_stream_if_ready(scope, host_ptr);
true
+48 -5
View File
@@ -177,6 +177,15 @@ pub(crate) enum DocumentParserLifetime {
Closing,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum DocumentParserCloseDisposition {
/// The parser is ready to consume any queued input and reach EOF now.
DrainNow,
/// An active parser operation or blocker owns progress; closing resumes
/// through that operation's existing completion path.
DeferredUntilReady,
}
static NEXT_DOCUMENT_PARSER_SESSION_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -664,12 +673,13 @@ impl DocumentParserSession {
self.lifetime
}
pub(crate) fn request_close(&mut self) -> bool {
if self.lifetime == DocumentParserLifetime::Closing {
return false;
}
pub(crate) fn request_close(&mut self) -> DocumentParserCloseDisposition {
self.lifetime = DocumentParserLifetime::Closing;
true
if self.run_state() == DocumentParserRunState::Ready {
DocumentParserCloseDisposition::DrainNow
} else {
DocumentParserCloseDisposition::DeferredUntilReady
}
}
pub(crate) fn finishes_when_drained(&self) -> bool {
@@ -1098,6 +1108,39 @@ mod session_state_tests {
assert_eq!(parser.resume(second), ParserResumeApplication::Resumed);
}
#[test]
fn close_defers_without_consuming_the_active_parser_suspension() {
let mut parser = DocumentParserSession::start_open_live_document(
Url::parse("https://parser-session.test/").expect("test URL"),
NativeNodeId::new(1),
);
let parser_owner = owner(1, 2, 3);
parser.bind_owner(parser_owner, 11);
let permit = parser.suspend(ParserSuspensionCause::ParserClassicSource {
script: NativeNodeId::new(8),
});
let suspended_state = parser.run_state();
assert_eq!(
parser.request_close(),
DocumentParserCloseDisposition::DeferredUntilReady
);
assert_eq!(parser.lifetime(), DocumentParserLifetime::Closing);
assert_eq!(
parser.run_state(),
suspended_state,
"document.close() must not bypass the active parser blocker"
);
assert_eq!(parser.current_resume_permit(), Some(permit));
assert_eq!(parser.resume(permit), ParserResumeApplication::Resumed);
assert_eq!(
parser.request_close(),
DocumentParserCloseDisposition::DrainNow,
"the delayed close can drain after the exact blocker releases"
);
}
#[test]
fn parser_pump_and_drop_transitions_are_observable_by_derived_capabilities() {
let parser = session();
@@ -13,9 +13,9 @@ use crate::{
LocalWindowId,
},
live_document_parser::{
DocumentParserLifetime, DocumentParserRunState, DocumentParserSession,
LiveDocumentParserOwner, LiveDocumentParserStepOutcome, ParserResumeApplication,
ParserSuspensionCause,
DocumentParserCloseDisposition, DocumentParserLifetime, DocumentParserRunState,
DocumentParserSession, LiveDocumentParserOwner, LiveDocumentParserStepOutcome,
ParserResumeApplication, ParserSuspensionCause,
},
modulepreload::{
invalid_modulepreload_as_value, invalid_modulepreload_as_warning,
@@ -1044,8 +1044,14 @@ impl JsContextHost {
.script_input_session()
.enqueue_script_input_html(chunk);
}
if close_requested {
entry.request_close();
let parser_ready_to_advance = if close_requested {
entry.request_close() == DocumentParserCloseDisposition::DrainNow
} else {
entry.run_state() == DocumentParserRunState::Ready
};
if !parser_ready_to_advance {
self.child_document_parsers.replace(owner, entry);
return true;
}
loop {
@@ -11067,6 +11067,137 @@ async fn child_document_write_nested_external_classic_blocks_domcontentloaded()
.await;
}
#[tokio::test]
async fn child_document_close_defers_while_written_external_classic_blocks_parser() {
run_page_vm_async_test(async move {
let (base_url, server) = spawn_path_response_http_server(vec![(
"/child-close-blocker.js",
"HTTP/1.1 200 OK",
"parent.__childCloseEvents.push('external:' + Boolean(document.getElementById('after-blocker')));"
.to_owned(),
Duration::ZERO,
)])
.await;
let loader =
crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader");
let document_url = Url::parse(&format!("{base_url}/page")).expect("page url");
let page_vm = test_page_vm_with_loader_and_document_url(&loader, Vec::new(), document_url);
let local_executor = page_vm.local_executor.clone();
let (
events_after_close,
source_load_source,
events_after_external,
final_events,
tail_exists,
) = local_executor
.run(async move {
let mut page_vm = page_vm;
page_vm.vm_mut().eval(
r#"
(() => {
globalThis.__childCloseEvents = [];
const frame = document.createElement("iframe");
frame.id = "child-close-frame";
document.body.appendChild(frame);
})()
"#,
)?;
materialize_child_realm_through_page_turn_for_test(
&mut page_vm,
"child-close-frame",
)?;
let script_url = format!("{base_url}/child-close-blocker.js");
page_vm.vm_mut().eval(&format!(
r#"
(() => {{
const frame = document.getElementById("child-close-frame");
frame.onload = () => __childCloseEvents.push("load");
const childDocument = frame.contentDocument;
childDocument.open();
childDocument.addEventListener("DOMContentLoaded", () => __childCloseEvents.push("dcl"));
childDocument.write(`<script src="{script_url}"><\/script><main id="after-blocker">tail</main>`);
childDocument.close();
__childCloseEvents.push("after-close");
}})()
"#,
))?;
let events_after_close = page_vm.vm_mut().eval("__childCloseEvents.join('|')")?;
let source_load_source =
run_expected_child_frame_task_source_after_realm_prerequisite_for_wait(
&mut page_vm,
ChildFrameSemanticTurnKind::ClassicScriptSourceLoad,
"document.close delayed external classic source load",
)
.await;
if !page_vm
.page_resource_completion_queue()
.has_ready_completion()
{
let arrived = tokio::time::timeout(
Duration::from_secs(2),
wait_for_typed_page_resource_completion(&mut page_vm),
)
.await
.expect("child close blocker completion should arrive before timeout");
assert!(
arrived,
"child close blocker completion sender should remain open"
);
}
let _ = run_next_resource_completion_as_typed_page_turn(&mut page_vm)?;
run_expected_child_frame_task_source_after_realm_prerequisite_for_wait(
&mut page_vm,
ChildFrameSemanticTurnKind::DocumentScriptReady,
"document.close delayed external classic execution",
)
.await;
let events_after_external =
page_vm.vm_mut().eval("__childCloseEvents.join('|')")?;
run_child_interactive_domcontentloaded_then_host_load_for_wait(
&mut page_vm,
"document.close delayed child load",
)
.await;
let final_events = page_vm.vm_mut().eval("__childCloseEvents.join('|')")?;
let tail_exists = page_vm.vm_mut().eval(
"String(Boolean(document.getElementById('child-close-frame').contentDocument.getElementById('after-blocker')))",
)?;
Ok::<_, anyhow::Error>((
events_after_close,
source_load_source,
events_after_external,
final_events,
tail_exists,
))
})
.await
.expect("child document.close delayed EOF test should run");
assert_eq!(
events_after_close, "after-close",
"document.close() should return without executing or bypassing the blocker"
);
assert_eq!(
source_load_source,
ChildFrameSemanticTurnKind::ClassicScriptSourceLoad
);
assert_eq!(
events_after_external, "after-close|external:false",
"the external parser-blocking script must run before future markup is parsed"
);
assert_eq!(final_events, "after-close|external:false|dcl|load");
assert_eq!(tail_exists, "true");
server
.await
.expect("child close blocker server should finish");
})
.await;
}
#[tokio::test]
async fn page_vm_nested_frame_finish_releases_parent_document_lifecycle() {
run_page_vm_async_test(async move {