From 375c48544bbae67e9fc8d334cc314d0d0a60160a Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 12 Aug 2026 11:15:01 +0800 Subject: [PATCH] fix(parser): defer document close while blocked --- .../src/document_runtime/document_write.rs | 7 +- moli-renderer-v8/src/live_document_parser.rs | 53 ++++++- .../child_documents/live_parser.rs | 16 ++- .../src/runtime/page_vm/tests/mod.rs | 131 ++++++++++++++++++ 4 files changed, 191 insertions(+), 16 deletions(-) diff --git a/moli-renderer-v8/src/document_runtime/document_write.rs b/moli-renderer-v8/src/document_runtime/document_write.rs index cef24a6a6c..dc4f214068 100644 --- a/moli-renderer-v8/src/document_runtime/document_write.rs +++ b/moli-renderer-v8/src/document_runtime/document_write.rs @@ -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 diff --git a/moli-renderer-v8/src/live_document_parser.rs b/moli-renderer-v8/src/live_document_parser.rs index b4594910d1..d0b3a718d5 100644 --- a/moli-renderer-v8/src/live_document_parser.rs +++ b/moli-renderer-v8/src/live_document_parser.rs @@ -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(); 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 c8386aacce..5a44148a4b 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 @@ -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 { diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs index b519a765b0..9f75d2bc89 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/mod.rs @@ -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(`