mirror of
https://github.com/lexmount/moli.git
synced 2026-10-01 08:00:38 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user