From 5ee47ac0697bbac3fce010b6b2392d8d5ffa9faf Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 10 Sep 2026 02:59:28 +0800 Subject: [PATCH] fix(script): keep parser async work runnable after EOF Async scripts still fetching at parser EOF previously entered a queue behind DOMContentLoaded, allowing a slow defer script to block a ready async script or its error event. Transfer remaining async work to the exact Document runtime producer and publish each task when its source completes, retaining its load-delay lease. Preserve observed async completion order across parser handoff. Add gated success, failure, and out-of-order completion tests, and update the WPT ledger for execution-timing/085.html. Validation: - cargo fmt --all - cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo nextest run --no-fail-fast: 17,879 passed, 13 skipped - 270 WPT cases: 257 to 258 passes, no case or subtest regressions --- .../wpt-cross-current/failed-cases.txt | 1 - .../wpt-cross-current/passed-cases.txt | 1 + moli-core/tests/scripts.rs | 3 + moli-core/tests/scripts/post_parse_async.rs | 161 ++++++++++++++++++ .../src/document_runtime/script_scheduling.rs | 31 +++- .../script_scheduling/post_parse_lifecycle.rs | 7 + .../src/document_script_scheduler.rs | 58 ++++++- .../post_parse_task.rs | 14 -- .../src/document_script_scheduler/runner.rs | 3 +- 9 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 moli-core/tests/scripts/post_parse_async.rs diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 00aece7268..30a7b34c5e 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -3149,7 +3149,6 @@ html/semantics/popovers/popover-types-with-hints.html html/semantics/scripting-1/the-script-element/css-module/content-type-checking.html html/semantics/scripting-1/the-script-element/css-module/cors-crossorigin-requests.html html/semantics/scripting-1/the-script-element/execution-timing/083.html -html/semantics/scripting-1/the-script-element/execution-timing/085.html html/semantics/scripting-1/the-script-element/execution-timing/128.html html/semantics/scripting-1/the-script-element/execution-timing/137.html html/semantics/scripting-1/the-script-element/json-module/cors-crossorigin-requests.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 3976a2be71..4bdc03c920 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -6696,6 +6696,7 @@ html/semantics/scripting-1/the-script-element/execution-timing/079.html html/semantics/scripting-1/the-script-element/execution-timing/081.html html/semantics/scripting-1/the-script-element/execution-timing/082.html html/semantics/scripting-1/the-script-element/execution-timing/084.html +html/semantics/scripting-1/the-script-element/execution-timing/085.html html/semantics/scripting-1/the-script-element/execution-timing/086.html html/semantics/scripting-1/the-script-element/execution-timing/087.html html/semantics/scripting-1/the-script-element/execution-timing/088.html diff --git a/moli-core/tests/scripts.rs b/moli-core/tests/scripts.rs index ab64a402e1..5a2141804c 100644 --- a/moli-core/tests/scripts.rs +++ b/moli-core/tests/scripts.rs @@ -40,6 +40,9 @@ mod parser_preparation; #[path = "scripts/lifecycle_task_order.rs"] mod lifecycle_task_order; +#[path = "scripts/post_parse_async.rs"] +mod post_parse_async; + fn diagnostic_global<'a>( page: &'a moli_core::page::Page, name: &str, diff --git a/moli-core/tests/scripts/post_parse_async.rs b/moli-core/tests/scripts/post_parse_async.rs new file mode 100644 index 0000000000..fe77a0690b --- /dev/null +++ b/moli-core/tests/scripts/post_parse_async.rs @@ -0,0 +1,161 @@ +use super::*; +use std::sync::Arc; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::Notify, +}; + +async fn async_completion_after_parser_eof( + blocked_mode: &'static str, + source_failure: bool, +) -> Result> { + let browser = Browser::new(AppConfig::default())?; + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let parser_finished = Arc::new(Notify::new()); + let async_executed = Arc::new(Notify::new()); + let server = tokio::spawn(async move { + let mut requests = tokio::task::JoinSet::new(); + loop { + let (mut stream, _) = listener.accept().await.expect("script fixture connection"); + let parser_finished = parser_finished.clone(); + let async_executed = async_executed.clone(); + requests.spawn(async move { + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).await.expect("script fixture request"); + if read == 0 { + return; + } + request.extend_from_slice(&buffer[..read]); + } + let request = String::from_utf8(request).expect("HTTP request text"); + let path = request.split_whitespace().nth(1).expect("request path"); + let (status, mime, body) = match path { + "/parsed" => { + parser_finished.notify_one(); + (200, "text/plain", String::new()) + } + "/executed" => { + async_executed.notify_one(); + (200, "text/plain", String::new()) + } + "/blocked.js" => { + async_executed.notified().await; + (200, "text/javascript", "order.push('blocked');".to_owned()) + } + "/ready.js" => { + parser_finished.notified().await; + if source_failure { + (404, "text/javascript", String::new()) + } else { + ( + 200, + "text/javascript", + "order.push('async'); queueMicrotask(() => order.push('async-microtask'));" + .to_owned(), + ) + } + } + _ => ( + 200, + "text/html", + format!( + r#" + + "# + ), + ), + }; + let response = format!( + "HTTP/1.1 {status} Response\r\nContent-Type: {mime}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(response.as_bytes()).await; + }); + } + }); + let result = async { + let mut page = tokio::time::timeout( + Duration::from_secs(5), + browser.fetch(&format!("http://{address}/")), + ) + .await??; + let result = page + .evaluate_runtime_expression_with_await_async("JSON.stringify(order)", true) + .await?; + Ok(serde_json::from_str( + result["value"] + .as_str() + .expect("async completion event order"), + )?) + } + .await; + server.abort(); + let _ = server.await; + result +} + +#[tokio::test(flavor = "multi_thread")] +async fn post_parse_async_script_runs_while_earlier_defer_source_is_pending() -> Result<()> { + assert_eq!( + async_completion_after_parser_eof("defer", false).await?, + [ + "interactive", + "async", + "async-microtask", + "async-load", + "blocked", + "dcl", + "load" + ] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn post_parse_async_error_runs_while_earlier_defer_source_is_pending() -> Result<()> { + assert_eq!( + async_completion_after_parser_eof("defer", true).await?, + ["interactive", "async-error", "blocked", "dcl", "load"] + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn post_parse_async_scripts_run_in_source_completion_order() -> Result<()> { + let order = async_completion_after_parser_eof("async", false).await?; + assert_eq!(order.first().map(String::as_str), Some("interactive")); + assert_eq!(order.last().map(String::as_str), Some("load")); + assert_eq!(order.iter().filter(|event| *event == "dcl").count(), 1); + assert_eq!( + order + .iter() + .filter(|event| *event != "dcl") + .map(String::as_str) + .collect::>(), + [ + "interactive", + "async", + "async-microtask", + "async-load", + "blocked", + "load" + ] + ); + Ok(()) +} diff --git a/moli-renderer-v8/src/document_runtime/script_scheduling.rs b/moli-renderer-v8/src/document_runtime/script_scheduling.rs index dbf2d4296b..4f59a23aac 100644 --- a/moli-renderer-v8/src/document_runtime/script_scheduling.rs +++ b/moli-renderer-v8/src/document_runtime/script_scheduling.rs @@ -146,8 +146,17 @@ mod tests { Url::parse("https://example.com/").unwrap(), "".to_owned(), ); - let mut runtime = DocumentRuntime::new(&document); let mut task_queue = crate::page_task_queue::PageTaskQueueTestHarness::new(); + let sender = task_queue.owner_attached_runtime_page_task_sender_for_test(); + let (parser_boundary_tx, _parser_boundary_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut runtime = DocumentRuntime::from_main_frame_dom_host( + DomHost::from_dom(document), + main_document_owner(), + Some(sender.page_task_sender()), + parser_boundary_tx, + sender.stylesheet_task_sender(), + sender.main_parser_continuation_sender(), + ); let mut report = ScriptExecutionReport::default(); let detached_run = ScriptRun::skipped( NodeId::new(99), @@ -188,10 +197,22 @@ mod tests { task_queue.post_parse_pop_front(), Some(work) if work.is_domcontentloaded_task() )); - assert!(matches!( - task_queue.post_parse_pop_front(), - Some(work) if work.is_async_phase_document_script() - )); + let async_task = task_queue + .task_sources() + .take_main_document_runtime_for_executor_test() + .expect("async work must enter the independent ready runtime source"); + assert_eq!(async_task.owner().document_owner(), main_document_owner()); + let crate::page_task_queue::RendererPageMainDocumentRuntimeAction::ExecuteReadyPostParseWork( + async_work, + ) = async_task.into_action() + else { + panic!("async work must retain its executable payload"); + }; + assert!( + async_work + .into_post_parse_work() + .is_async_phase_document_script() + ); assert!(matches!( task_queue.post_parse_pop_front(), Some(work) if work.detached_run_count() == 1 diff --git a/moli-renderer-v8/src/document_runtime/script_scheduling/post_parse_lifecycle.rs b/moli-renderer-v8/src/document_runtime/script_scheduling/post_parse_lifecycle.rs index 98e1ec7d76..996a13d706 100644 --- a/moli-renderer-v8/src/document_runtime/script_scheduling/post_parse_lifecycle.rs +++ b/moli-renderer-v8/src/document_runtime/script_scheduling/post_parse_lifecycle.rs @@ -77,6 +77,13 @@ impl DocumentRuntime { "dropping stale main parser-deferred adapter marker without owned parser work" ); } + } else if item.is_async_phase_document_script() { + // Parser EOF does not make async scripts wait for the defer + // list or DOMContentLoaded. Transfer each remaining script to + // its exact Document producer; pending sources publish their + // own ready task when they complete. + self.enqueue_main_document_post_parse_work(item) + .expect("post-parse async work must retain its main Document runtime route"); } else { queued_work.push(item); } diff --git a/moli-renderer-v8/src/document_script_scheduler.rs b/moli-renderer-v8/src/document_script_scheduler.rs index 1f9c4074a6..253a178de8 100644 --- a/moli-renderer-v8/src/document_script_scheduler.rs +++ b/moli-renderer-v8/src/document_script_scheduler.rs @@ -953,7 +953,10 @@ mod tests { .await .into_async_tasks(); assert_eq!(async_scripts.len(), 1); - assert_eq!(async_scripts[0].position(), 2); + assert_eq!( + async_scripts[0].as_script().expect("async script").position, + 2 + ); } #[tokio::test] @@ -1957,6 +1960,59 @@ mod tests { // Finalize: remaining async handed back correctly // ----------------------------------------------------------------------- + #[tokio::test] + async fn finalize_plan_preserves_observed_async_completion_order() { + for source_failure in [false, true] { + let first = prepared_script( + 1, + ScriptMode::Async, + ScriptKind::Classic, + ScriptSourceKind::External, + ); + let second = prepared_script( + 2, + ScriptMode::Async, + ScriptKind::Classic, + ScriptSourceKind::External, + ); + let mut scheduler = scheduler_with_async_state( + vec![ + parse_time_async_entry(first.clone()), + parse_time_async_entry(second.clone()), + ], + Vec::new(), + ); + assert!(scheduler.runner.async_parse_time_queue.apply_completion( + async_load_completion_ok(second.node_id, "second-ready-first") + )); + let completion = if source_failure { + async_load_completion_err(first.node_id, "first-failed-later") + } else { + async_load_completion_ok(first.node_id, "first-ready-later") + }; + assert!( + scheduler + .runner + .async_parse_time_queue + .apply_completion(completion) + ); + + let tasks = scheduler + .finalize_owned_script_work() + .await + .into_async_tasks(); + assert_eq!( + tasks + .iter() + .map(|task| task.as_script().expect("async script").node_id) + .collect::>(), + [second.node_id, first.node_id], + "parser handoff must preserve observed completion order, source_failure={source_failure}" + ); + assert_eq!(tasks[1].is_async_script_failure(), source_failure); + } + } + #[tokio::test] async fn finalize_plan_hands_back_ready_completions_before_post_dcl_fallback() { let original = prepared_script( diff --git a/moli-renderer-v8/src/document_script_scheduler/post_parse_task.rs b/moli-renderer-v8/src/document_script_scheduler/post_parse_task.rs index e99737c5aa..1fcd17370b 100644 --- a/moli-renderer-v8/src/document_script_scheduler/post_parse_task.rs +++ b/moli-renderer-v8/src/document_script_scheduler/post_parse_task.rs @@ -63,12 +63,6 @@ impl PostParseDocumentScriptTask { })) } - pub(super) fn position(&self) -> usize { - match self { - Self::AsyncScript(task) => task.position(), - } - } - #[cfg(test)] pub(super) fn as_script(&self) -> Option<&PreparedScript> { match self { @@ -102,12 +96,4 @@ impl PostParseAsyncScriptTask { | Self::Failure { script, .. } => script, } } - - fn position(&self) -> usize { - match self { - Self::Ready { script, .. } - | Self::WaitingForSource { script, .. } - | Self::Failure { script, .. } => script.position, - } - } } diff --git a/moli-renderer-v8/src/document_script_scheduler/runner.rs b/moli-renderer-v8/src/document_script_scheduler/runner.rs index 93d69533d4..de1ed7faa3 100644 --- a/moli-renderer-v8/src/document_script_scheduler/runner.rs +++ b/moli-renderer-v8/src/document_script_scheduler/runner.rs @@ -189,7 +189,8 @@ impl< } = self; let mut async_tasks = async_fallback_queue.into_async_phase_tasks(); async_tasks.extend(async_parse_time_queue.into_remaining_async_phase_tasks()); - async_tasks.sort_by_key(PostParseDocumentScriptTask::position); + // Keep the order of already-observed async completions across parser + // handoff. Pending sources acquire a ready position when they finish. DocumentScriptRunnerPostParsePlan { async_tasks } }