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
This commit is contained in:
ldm0
2026-09-27 06:43:21 +08:00
parent 0e0f6dfa8f
commit 5ee47ac069
9 changed files with 257 additions and 22 deletions
@@ -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
@@ -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
+3
View File
@@ -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,
+161
View File
@@ -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<Vec<String>> {
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#"<!doctype html><body><script>
window.order = [];
document.addEventListener('readystatechange', () => {{
if (document.readyState === 'interactive') {{
order.push('interactive');
fetch('/parsed');
}}
}});
document.addEventListener('DOMContentLoaded', () => order.push('dcl'));
addEventListener('load', () => order.push('load'));
</script>
<script {blocked_mode} src='/blocked.js'></script>
<script async src='/ready.js'
onload="order.push('async-load'); fetch('/executed')"
onerror="order.push('async-error'); fetch('/executed')"></script>"#
),
),
};
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::<Vec<_>>(),
[
"interactive",
"async",
"async-microtask",
"async-load",
"blocked",
"load"
]
);
Ok(())
}
@@ -146,8 +146,17 @@ mod tests {
Url::parse("https://example.com/").unwrap(),
"<!doctype html><html><body></body></html>".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
@@ -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);
}
@@ -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::<Vec<_>>(),
[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(
@@ -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,
}
}
}
@@ -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 }
}