From 98e83401643bb4b0a5fcb7c236155cedce8b06b2 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 15 Jul 2026 06:23:55 +0800 Subject: [PATCH] fix(lifecycle): order defer timers before DOMContentLoaded --- .../wpt-cross-current/passed-cases.txt | 1 + moli-renderer-v8/src/document_runtime.rs | 3 + .../src/document_runtime/events.rs | 45 ++++ .../src/document_runtime/lifecycle.rs | 2 + .../src/document_runtime/runtime_core.rs | 4 + .../script_scheduling/post_parse_lifecycle.rs | 4 + .../document_script_scheduler/owner_hooks.rs | 4 + .../document_script_scheduler/owner_runner.rs | 8 + moli-renderer-v8/src/host/timers.rs | 35 +++- moli-renderer-v8/src/page_task_queue/tasks.rs | 3 + .../page_vm/document_lifecycle_turn.rs | 11 + moli-renderer-v8/src/runtime/page_vm/mod.rs | 6 + .../page_owned_document_script_hooks.rs | 14 ++ .../src/runtime/page_vm/page_timer.rs | 15 ++ .../src/runtime/page_vm/parser_completion.rs | 18 ++ .../page_vm/parser_deferred_classic.rs | 10 + .../tests/extracted/main_document_scripts.rs | 71 +++++++ .../src/runtime/page_vm/tests/lifecycle.rs | 133 ++++++++++++ moli-renderer-v8/src/script_vm.rs | 12 ++ .../src/script_vm/page_task_enqueue.rs | 24 +++ .../src/script_vm/post_parse_lifecycle.rs | 29 +++ .../src/script_vm/runtime_work.rs | 3 + moli-time/src/lib.rs | 6 +- moli-time/src/timers.rs | 193 +++++++++++++++++- 24 files changed, 650 insertions(+), 4 deletions(-) diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index a1b4530c52..025247631f 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -6990,6 +6990,7 @@ html/syntax/parsing-html-fragments/the-input-byte-stream-034.html html/syntax/parsing-html-fragments/the-input-byte-stream-037.html html/syntax/parsing-html-fragments/the-input-byte-stream-038.html html/syntax/parsing-html-fragments/tokenizer-modes-001.html +html/syntax/parsing/DOMContentLoaded-defer.html html/syntax/parsing/Document.getElementsByTagName-foreign-01.html html/syntax/parsing/Document.getElementsByTagName-foreign-02.html html/syntax/parsing/Element.getElementsByTagName-foreign-01.html diff --git a/moli-renderer-v8/src/document_runtime.rs b/moli-renderer-v8/src/document_runtime.rs index 4126a45ee4..f4fc42ccf5 100644 --- a/moli-renderer-v8/src/document_runtime.rs +++ b/moli-renderer-v8/src/document_runtime.rs @@ -77,6 +77,7 @@ pub(crate) use devtools_mutations::{ }; pub(crate) use inspector_issues::PendingInspectorIssue; pub(crate) use meta_refresh::MetaRefreshNavigation; +use moli_time::{TimerScheduleRange, TimerScheduleSnapshot}; use parser_blocking::{ParserInsertionWork, PendingParserBlockingWork, PendingParserInsertion}; pub(crate) use parser_modulepreload::MainDocumentModulepreloadFetchOutcome; pub(crate) use script_lifecycle::{ @@ -757,6 +758,8 @@ pub(super) struct DocumentRuntime { script_lifecycle: DocumentScriptLifecycle, parser_script_start_positions: HashMap, timeouts: HostTimeoutScheduler, + classic_defer_timer_schedule_start: Option, + classic_defer_timer_schedule_ranges: Vec, events: HostEventTargetRegistry, mutations: MutationCoordinator, meta_refresh_scheduler: meta_refresh::MetaRefreshScheduler, diff --git a/moli-renderer-v8/src/document_runtime/events.rs b/moli-renderer-v8/src/document_runtime/events.rs index 27a811df97..20c6ce5e5d 100644 --- a/moli-renderer-v8/src/document_runtime/events.rs +++ b/moli-renderer-v8/src/document_runtime/events.rs @@ -276,6 +276,51 @@ impl DocumentRuntime { self.timeouts.run_next_body(scope, selection) } + /// Starts the exact timer sequence range owned by one classic defer task. + /// + /// Parser-time scripts still execute inside the parser's task and module + /// evaluation has its own pending-completion semantics. Neither category + /// should make all of its older timers precede DOMContentLoaded. A classic + /// defer task, however, can queue a timer before the subsequent DCL task is + /// queued; record only that task-local interval. + pub(crate) fn begin_classic_defer_timer_schedule_range(&mut self) { + debug_assert!( + self.classic_defer_timer_schedule_start.is_none(), + "classic defer timer range must not overlap another script task" + ); + self.classic_defer_timer_schedule_start = Some(self.timeouts.schedule_snapshot()); + } + + pub(crate) fn finish_classic_defer_timer_schedule_range(&mut self) { + let Some(start) = self.classic_defer_timer_schedule_start.take() else { + // A defer script may replace the document and clear the old + // lifecycle state while it is executing. + return; + }; + let range = self.timeouts.schedule_range_since(start); + if !range.is_empty() { + self.classic_defer_timer_schedule_ranges.push(range); + } + } + + pub(crate) fn clear_classic_defer_timer_schedule_ranges(&mut self) { + self.classic_defer_timer_schedule_start = None; + self.classic_defer_timer_schedule_ranges.clear(); + } + + pub(crate) fn run_next_timeout_queued_by_classic_defer_script( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + ) -> HostTimeoutRunResult { + self.timeouts + .run_next_from_schedule_ranges(scope, &self.classic_defer_timer_schedule_ranges) + } + + pub(crate) fn has_ready_timeout_queued_by_classic_defer_script(&self) -> bool { + self.timeouts + .has_ready_from_schedule_ranges(&self.classic_defer_timer_schedule_ranges) + } + #[cfg(test)] pub(crate) fn has_ready_timeout(&self) -> bool { self.timeouts.has_ready_timer() diff --git a/moli-renderer-v8/src/document_runtime/lifecycle.rs b/moli-renderer-v8/src/document_runtime/lifecycle.rs index beaf3ba228..6e4c74939f 100644 --- a/moli-renderer-v8/src/document_runtime/lifecycle.rs +++ b/moli-renderer-v8/src/document_runtime/lifecycle.rs @@ -286,6 +286,7 @@ impl DocumentRuntime { self.document_write_script_preload_scanner = None; self.main_document_script_preloads = Default::default(); self.document_write_script_preloads.clear(); + self.clear_classic_defer_timer_schedule_ranges(); self.pending_parser_blocking_work = None; self.root_document_parser = None; self.delivered_meta_content_security_policies @@ -381,6 +382,7 @@ impl DocumentRuntime { pub(crate) fn note_dom_content_loaded_dispatched(&mut self) { self.dom_content_loaded_dispatched = true; + self.clear_classic_defer_timer_schedule_ranges(); } pub(crate) fn dom_content_loaded_dispatched(&self) -> bool { diff --git a/moli-renderer-v8/src/document_runtime/runtime_core.rs b/moli-renderer-v8/src/document_runtime/runtime_core.rs index c189211b82..397f0b9cec 100644 --- a/moli-renderer-v8/src/document_runtime/runtime_core.rs +++ b/moli-renderer-v8/src/document_runtime/runtime_core.rs @@ -111,6 +111,8 @@ impl DocumentRuntime { ), parser_script_start_positions: HashMap::new(), timeouts: HostTimeoutScheduler::default(), + classic_defer_timer_schedule_start: None, + classic_defer_timer_schedule_ranges: Vec::new(), events: HostEventTargetRegistry::default(), mutations: MutationCoordinator, meta_refresh_scheduler: super::meta_refresh::MetaRefreshScheduler::default(), @@ -232,6 +234,8 @@ impl DocumentRuntime { script_lifecycle: _, parser_script_start_positions: _, timeouts: _, + classic_defer_timer_schedule_start: _, + classic_defer_timer_schedule_ranges: _, events: _, mutations: _, meta_refresh_scheduler: _, 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 a2bb5a1c90..3de30e81ed 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 @@ -26,6 +26,10 @@ impl DocumentRuntime { .iter() .position(PostParsePageOwnedWork::starts_after_domcontentloaded_boundary) .unwrap_or(work.len()); + // This is an owner-queue boundary marker, not yet the HTML event-loop + // queue point for DOMContentLoaded. When it reaches the front after all + // defer-like and parser-owned pre-DCL work has drained, the driver first + // runs ready timer tasks recorded during classic defer execution. work.insert( domcontentloaded_index, PostParsePageOwnedWork::main_document_domcontentloaded(owner), diff --git a/moli-renderer-v8/src/document_script_scheduler/owner_hooks.rs b/moli-renderer-v8/src/document_script_scheduler/owner_hooks.rs index 673b40333b..36adfafec3 100644 --- a/moli-renderer-v8/src/document_script_scheduler/owner_hooks.rs +++ b/moli-renderer-v8/src/document_script_scheduler/owner_hooks.rs @@ -36,6 +36,10 @@ pub(crate) trait PageOwnedDocumentScriptHooks { fn perform_pre_script_checkpoint(&mut self, script_url: &Url) -> Result<()>; + fn begin_classic_defer_timer_schedule_range(&mut self) {} + + fn finish_classic_defer_timer_schedule_range(&mut self) {} + fn execute_prepared_script<'a>( &'a mut self, script: PreparedScript, diff --git a/moli-renderer-v8/src/document_script_scheduler/owner_runner.rs b/moli-renderer-v8/src/document_script_scheduler/owner_runner.rs index 66f1f1b66c..5eb4dc9ecc 100644 --- a/moli-renderer-v8/src/document_script_scheduler/owner_runner.rs +++ b/moli-renderer-v8/src/document_script_scheduler/owner_runner.rs @@ -238,10 +238,18 @@ where let dynamic_script_owner_id = runtime_script_claim .as_ref() .map(DynamicScriptPageTaskClaim::id); + let records_classic_defer_timer_range = + matches!(lane, DocumentScriptExecutionLane::ClassicDefer); + if records_classic_defer_timer_range { + self.hooks.begin_classic_defer_timer_schedule_range(); + } let execution = self .hooks .execute_prepared_script(*script, runtime_script_claim) .await; + if records_classic_defer_timer_range { + self.hooks.finish_classic_defer_timer_schedule_range(); + } let document_owner_token_after_body = self.hooks.current_document_owner_token(); let script_elapsed_ms = script_started.elapsed().as_millis(); Ok(PageOwnedDocumentScriptResult { diff --git a/moli-renderer-v8/src/host/timers.rs b/moli-renderer-v8/src/host/timers.rs index c019569cb9..a68bc3c8b9 100644 --- a/moli-renderer-v8/src/host/timers.rs +++ b/moli-renderer-v8/src/host/timers.rs @@ -22,7 +22,9 @@ use crate::{ get_private_value, script_nonce_from_host_defined_options, }, }; -use moli_time::{TimerId, TimerReadyAllowance, TimerScheduler}; +use moli_time::{ + TimerId, TimerReadyAllowance, TimerScheduleRange, TimerScheduleSnapshot, TimerScheduler, +}; use moli_webapi_declare::WebApiObject; #[derive(WebApiObject)] @@ -747,12 +749,43 @@ impl HostTimeoutScheduler { self.run_timer(scope, timer) } + pub(crate) fn run_next_from_schedule_ranges( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + ranges: &[TimerScheduleRange], + ) -> HostTimeoutRunResult { + let Some(timer) = self.scheduler.take_next_ready_from_schedule_ranges( + ranges, + Instant::now(), + min_delay_ready_allowance(), + ) else { + return HostTimeoutRunResult::Idle; + }; + self.run_timer(scope, timer) + } + #[cfg(test)] pub(crate) fn has_ready_timer(&self) -> bool { self.scheduler .has_ready_timer(Instant::now(), min_delay_ready_allowance()) } + pub(crate) fn has_ready_from_schedule_ranges(&self, ranges: &[TimerScheduleRange]) -> bool { + self.scheduler.has_ready_from_schedule_ranges( + ranges, + Instant::now(), + min_delay_ready_allowance(), + ) + } + + pub(crate) fn schedule_snapshot(&self) -> TimerScheduleSnapshot { + self.scheduler.schedule_snapshot() + } + + pub(crate) fn schedule_range_since(&self, start: TimerScheduleSnapshot) -> TimerScheduleRange { + self.scheduler.schedule_range_since(start) + } + pub(crate) fn next_ready_timer_deadline( &self, selection: RendererPageTimerSelection, diff --git a/moli-renderer-v8/src/page_task_queue/tasks.rs b/moli-renderer-v8/src/page_task_queue/tasks.rs index 2eef275763..cf30416944 100644 --- a/moli-renderer-v8/src/page_task_queue/tasks.rs +++ b/moli-renderer-v8/src/page_task_queue/tasks.rs @@ -162,6 +162,9 @@ pub(crate) enum PageTask { // into "script loop followed by immediate host callback". // - Keeping it in the queue means the owner lane sees one explicit task: // pre-task checkpoint -> dispatch DOMContentLoaded -> post-task effects. + // - Its initial placement is a lifecycle sentinel. Reaching the queue + // front after defer-like work establishes the actual DCL task queue + // point; ready timers recorded during classic defer tasks run first. // DispatchDomContentLoaded, DispatchConnectedStyleLoad(ReadyConnectedStyleLoad), diff --git a/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs b/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs index cbe89e43bc..bb97d14d0a 100644 --- a/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs +++ b/moli-renderer-v8/src/runtime/page_vm/document_lifecycle_turn.rs @@ -412,6 +412,17 @@ impl PageVm { ) .await } + PostParseLifecycleAdvance::TimerQueuedByClassicDeferBeforeDomContentLoaded => { + self.run_classic_defer_timer_before_domcontentloaded(&request_client) + .await?; + self.outcome_after_exact_post_parse_action( + pending_document_lifecycle_turn, + document, + stage, + DocumentLifecycleTurnAction::Progressed, + false, + ) + } PostParseLifecycleAdvance::NeedsContinuation => self .outcome_after_exact_post_parse_action( pending_document_lifecycle_turn, diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index d7f620759b..8db8989631 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -3204,6 +3204,12 @@ impl PageVm { *task, )))) } + PostParseLifecycleAdvance::TimerQueuedByClassicDeferBeforeDomContentLoaded => { + let request_client = self.request_client.clone(); + self.run_classic_defer_timer_before_domcontentloaded(&request_client) + .await?; + Ok(PostParseLifecycleLoopAdvance::Continue(Box::new(None))) + } PostParseLifecycleAdvance::NeedsContinuation => { Ok(PostParseLifecycleLoopAdvance::Continue(Box::new(None))) } diff --git a/moli-renderer-v8/src/runtime/page_vm/page_owned_document_script_hooks.rs b/moli-renderer-v8/src/runtime/page_vm/page_owned_document_script_hooks.rs index 1b22f9a4a9..b3066d6acd 100644 --- a/moli-renderer-v8/src/runtime/page_vm/page_owned_document_script_hooks.rs +++ b/moli-renderer-v8/src/runtime/page_vm/page_owned_document_script_hooks.rs @@ -71,6 +71,20 @@ impl PageOwnedDocumentScriptHooks for MainPageOwnedDocumentScriptHooks<'_, '_> { .perform_script_task_checkpoint(Some(script_url)) } + fn begin_classic_defer_timer_schedule_range(&mut self) { + self.page_vm + .vm_mut() + .document_runtime + .begin_classic_defer_timer_schedule_range(); + } + + fn finish_classic_defer_timer_schedule_range(&mut self) { + self.page_vm + .vm_mut() + .document_runtime + .finish_classic_defer_timer_schedule_range(); + } + fn execute_prepared_script<'a>( &'a mut self, script: PreparedScript, diff --git a/moli-renderer-v8/src/runtime/page_vm/page_timer.rs b/moli-renderer-v8/src/runtime/page_vm/page_timer.rs index da985ecfef..8f7a55ad8e 100644 --- a/moli-renderer-v8/src/runtime/page_vm/page_timer.rs +++ b/moli-renderer-v8/src/runtime/page_vm/page_timer.rs @@ -24,6 +24,21 @@ pub(in crate::runtime) enum PageTimerTurnAction { } impl PageVm { + /// Run the exact ready timer selected by the post-parse lifecycle's + /// classic-defer schedule ranges, then commit the ordinary callback task + /// completion once. + pub(in crate::runtime) async fn run_classic_defer_timer_before_domcontentloaded( + &mut self, + loader: &crate::network::ResourceRequestClient, + ) -> Result<()> { + let body = self.vm_mut().run_next_classic_defer_timer_callback_body()?; + ensure!( + body.consumed_heap_head(), + "a ready classic-defer timer selected before DOMContentLoaded must consume its heap entry" + ); + self.finish_selected_page_callback_task(loader).await + } + /// Execute one already-selected timer body without committing its /// task-end checkpoint. /// diff --git a/moli-renderer-v8/src/runtime/page_vm/parser_completion.rs b/moli-renderer-v8/src/runtime/page_vm/parser_completion.rs index bad8c9d522..0b9b1e8dac 100644 --- a/moli-renderer-v8/src/runtime/page_vm/parser_completion.rs +++ b/moli-renderer-v8/src/runtime/page_vm/parser_completion.rs @@ -170,6 +170,16 @@ impl ParserCompletion { // terminal reactions first; the lifecycle coordinator then owns DCL's // separate task-end checkpoint. Self::finish_task(page_vm, task_effect)?; + let request_client = page_vm.request_client.clone(); + while page_vm + .vm() + .document_runtime + .has_ready_timeout_queued_by_classic_defer_script() + { + page_vm + .run_classic_defer_timer_before_domcontentloaded(&request_client) + .await?; + } let run = super::main_document_lifecycle_completion::execute_parser_exact_domcontentloaded_on_owner_local_task( page_vm, successor_owner, @@ -408,6 +418,14 @@ impl PageVm { }; if let Some(dcl_task) = claimed_dcl { ParserCompletion::finish_task_with_replacement_admission(self, task_effect)?; + while self + .vm() + .document_runtime + .has_ready_timeout_queued_by_classic_defer_script() + { + self.run_classic_defer_timer_before_domcontentloaded(loader) + .await?; + } let replacement_lifecycle_snapshot = self.document_replacement_lifecycle_action_snapshot(); let execution = self diff --git a/moli-renderer-v8/src/runtime/page_vm/parser_deferred_classic.rs b/moli-renderer-v8/src/runtime/page_vm/parser_deferred_classic.rs index 13257ae15a..0ee6333eb6 100644 --- a/moli-renderer-v8/src/runtime/page_vm/parser_deferred_classic.rs +++ b/moli-renderer-v8/src/runtime/page_vm/parser_deferred_classic.rs @@ -304,6 +304,11 @@ impl ParserClassicDocumentScriptExecutionHooks Box::pin(async move { owner.record_network_result(&action.script, action.source_network_result.as_ref()); let expected_owner = action.owner; + owner + .page_vm + .vm_mut() + .document_runtime + .begin_classic_defer_timer_schedule_range(); let execution = owner .page_vm .execute_main_parser_deferred_classic_script_body_on_current_lane( @@ -311,6 +316,11 @@ impl ParserClassicDocumentScriptExecutionHooks action.script, ) .await; + owner + .page_vm + .vm_mut() + .document_runtime + .finish_classic_defer_timer_schedule_range(); let (run, navigation_triggered, completion, prepared_activity) = execution.into_parts(); let owner_replaced = !owner.owner_is_current(expected_owner); Ok(ParserClassicDocumentScriptExecutionResult::new( diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/extracted/main_document_scripts.rs b/moli-renderer-v8/src/runtime/page_vm/tests/extracted/main_document_scripts.rs index 60bc640e9d..7ffa34b280 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/extracted/main_document_scripts.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/extracted/main_document_scripts.rs @@ -1135,3 +1135,74 @@ async fn main_parser_after_parsing_queue_orders_module_before_ready_classic_defe }) .await; } + +#[tokio::test(flavor = "current_thread")] +async fn main_parser_classic_defer_records_its_timer_range_for_domcontentloaded() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let mut page_vm = test_page_vm_with_loader_and_document_url( + &loader, + Vec::new(), + Url::parse("https://example.com/defer-timer.html").expect("document URL"), + ); + page_vm + .vm_mut() + .eval( + "globalThis.__mainParserDeferTimerEvents = []; \ + setTimeout(() => __mainParserDeferTimerEvents.push('outside'), 0);", + ) + .expect("defer timer state should initialize"); + let script = append_parser_owned_external_classic_defer_for_page_vm_test( + &mut page_vm, + 1, + "timer-defer", + Url::parse("https://example.com/timer-defer.js").expect("script URL"), + ScriptSource::Loaded( + "setTimeout(() => __mainParserDeferTimerEvents.push('defer'), 0);".to_owned(), + ), + ("onload", ""), + ); + let task_owner = page_vm + .vm() + .current_main_document_task_owner() + .expect("defer timer test requires a document owner"); + assert!( + page_vm + .vm_mut() + .claim_main_parser_deferred_script( + task_owner, + script, + None, + None, + Default::default(), + ) + .expect("loaded classic defer should be accepted") + ); + page_vm + .seal_main_parser_deferred_scripts(task_owner) + .expect("defer timer queue should seal"); + + run_ready_parser_deferred_body_for_test(&mut page_vm, &loader, "timer classic defer").await; + assert!( + page_vm + .vm() + .document_runtime + .has_ready_timeout_queued_by_classic_defer_script(), + "the specialized main-parser defer executor must retain its task-local timer range" + ); + page_vm + .run_classic_defer_timer_before_domcontentloaded(&loader) + .await + .expect("the recorded defer timer should be selected"); + assert_eq!( + page_vm + .vm_mut() + .eval("__mainParserDeferTimerEvents.join('|')") + .expect("defer timer events should evaluate"), + "defer", + "the older unrelated timer must not enter the pre-DOMContentLoaded range" + ); + }) + .await; +} diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs b/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs index c828ae33e6..7594839874 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/lifecycle.rs @@ -4121,6 +4121,139 @@ fn drive_post_parse_lifecycle_loop_returns_at_domcontentloaded_before_trailing_t }); } +#[test] +fn defer_queued_timer_runs_before_domcontentloaded_without_draining_nested_timer() { + run_page_vm_local_runtime_async_test("page-vm-defer-timer-before-dcl", || async move { + let mut page_vm = test_page_vm(); + let local_executor = page_vm.local_executor.clone(); + + let (completion, before_later_timers, after_outside_timer, after_nested_timer) = + local_executor + .run(async move { + page_vm.vm_mut().eval( + r#" + globalThis.__deferDclTaskOrder = []; + setTimeout(() => { + globalThis.__deferDclTaskOrder.push("timer-outside-defer"); + }, 0); + document.addEventListener("DOMContentLoaded", () => { + globalThis.__deferDclTaskOrder.push("dcl"); + }); + "installed"; + "#, + )?; + let body = page_vm + .vm() + .document_runtime + .snapshot_document() + .document_body_handle() + .expect("test document should have a body"); + let defer_script_node = page_vm + .vm_mut() + .document_runtime + .dom_host_mut() + .create_parser_element_without_attributes( + "script".to_owned(), + "http://www.w3.org/1999/xhtml".to_owned(), + None, + ); + assert!( + page_vm + .vm_mut() + .document_runtime + .dom_host_mut() + .append_child(body, defer_script_node), + "defer script test node should attach to the document" + ); + let mut defer_script = prepared_loaded_classic_for_page_vm_test( + &page_vm, + 9100, + r#" + globalThis.__deferDclTaskOrder.push("defer"); + setTimeout(() => { + globalThis.__deferDclTaskOrder.push("timer-before-dcl"); + setTimeout(() => { + globalThis.__deferDclTaskOrder.push("timer-after-dcl"); + }, 0); + }, 0); + "#, + ); + defer_script.mode = ScriptMode::Defer; + defer_script.source_kind = ScriptSourceKind::External; + defer_script.node_id = defer_script_node; + + let lifecycle_driver = { + let PageVm { + vm, + page_task_queue, + report, + .. + } = &mut page_vm; + vm.as_mut() + .expect("page vm must retain a live ScriptVm until drop") + .start_post_parse_lifecycle_round( + PageVmInitStage::DomContentLoaded, + page_task_queue, + report, + vec![classic_defer_work(defer_script)], + ) + .await + }; + let completion = page_vm + .drive_post_parse_lifecycle_loop_on_named_owner_lane( + PageVmInitStage::DomContentLoaded, + lifecycle_driver, + ) + .await?; + let before_later_timers = page_vm + .vm_mut() + .eval("JSON.stringify(globalThis.__deferDclTaskOrder)")?; + + let loader = page_vm.request_client.clone(); + assert!( + page_vm + .run_one_due_timer_selected_task_for_test(&loader) + .await?, + "the timer queued outside defer must remain runnable after DCL" + ); + let after_outside_timer = page_vm + .vm_mut() + .eval("JSON.stringify(globalThis.__deferDclTaskOrder)")?; + assert!( + page_vm + .run_one_due_timer_selected_task_for_test(&loader) + .await?, + "the timer queued by the first timer must remain runnable after DCL" + ); + let after_nested_timer = page_vm + .vm_mut() + .eval("JSON.stringify(globalThis.__deferDclTaskOrder)")?; + Ok::<_, anyhow::Error>(( + completion, + before_later_timers, + after_outside_timer, + after_nested_timer, + )) + }) + .await + .expect("driver loop should preserve the DCL task boundary"); + + assert!(matches!( + completion, + PostParseLifecycleCompletionAction::ReturnAtStage("DOMContentLoaded") + )); + assert_eq!(before_later_timers, r#"["defer","timer-before-dcl","dcl"]"#); + assert_eq!( + after_outside_timer, + r#"["defer","timer-before-dcl","dcl","timer-outside-defer"]"# + ); + assert_eq!( + after_nested_timer, + r#"["defer","timer-before-dcl","dcl","timer-outside-defer","timer-after-dcl"]"# + ); + }); +} + #[test] fn drive_post_parse_lifecycle_loop_returns_at_domcontentloaded_before_listener_work() { run_page_vm_local_runtime_async_test("page-vm-dcl-before-listener-work", || async move { diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index 9de2ae13e5..41c7ee95d1 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -6002,6 +6002,18 @@ impl ScriptVm { Ok(result) } + /// Execute one ready timer whose scheduling sequence belongs to a classic + /// defer script, without committing its task-end callback completion. + pub(crate) fn run_next_classic_defer_timer_callback_body( + &mut self, + ) -> Result { + let result = self.run_next_timeout_queued_by_classic_defer_script_body()?; + if let HostTimeoutRunResult::CallbackError(error) = &result { + self.record_runtime_warning(format_args!("timer callback dispatch failed: {error}")); + } + Ok(result) + } + /// Complete one timer in a standalone ScriptVm fixture. /// /// Production and PageVm behavior tests must use the selected Page-task diff --git a/moli-renderer-v8/src/script_vm/page_task_enqueue.rs b/moli-renderer-v8/src/script_vm/page_task_enqueue.rs index 878ee8f7ab..7b542c745f 100644 --- a/moli-renderer-v8/src/script_vm/page_task_enqueue.rs +++ b/moli-renderer-v8/src/script_vm/page_task_enqueue.rs @@ -819,6 +819,30 @@ impl ScriptVm { Ok(result) } + /// Execute one ready timer selected from the closed schedule ranges + /// recorded around classic defer script execution. + /// + /// Like the ordinary timer body, this does not checkpoint or reconcile + /// callback effects. The PageVm lifecycle owner commits the exact selected + /// callback completion after this method returns. + pub(super) fn run_next_timeout_queued_by_classic_defer_script_body( + &mut self, + ) -> Result { + #[cfg(test)] + if let Some(message) = self.test_next_timeout_failure.take() { + return Err(anyhow!("{message}")); + } + + let document_runtime: *mut DocumentRuntime = &mut *self.document_runtime; + self.with_default_context_scope(|scope, _host_ptr| { + // SAFETY: this is the same synchronous, non-escaping + // document-runtime borrow used by `run_next_timeout_body`; the + // schedule ranges only narrow which timer may be selected. + Ok(unsafe { &mut *document_runtime } + .run_next_timeout_queued_by_classic_defer_script(scope)) + }) + } + #[cfg(test)] pub(super) fn fail_next_timeout_for_testing(&mut self, message: impl Into) { self.test_next_timeout_failure = Some(message.into()); diff --git a/moli-renderer-v8/src/script_vm/post_parse_lifecycle.rs b/moli-renderer-v8/src/script_vm/post_parse_lifecycle.rs index f33cc842cd..f4ad609271 100644 --- a/moli-renderer-v8/src/script_vm/post_parse_lifecycle.rs +++ b/moli-renderer-v8/src/script_vm/post_parse_lifecycle.rs @@ -961,6 +961,10 @@ impl ScriptVm { report: &mut ScriptExecutionReport, work: Vec, ) -> PostParseLifecycleRound { + if !self.document_runtime.dom_content_loaded_dispatched() { + self.document_runtime + .clear_classic_defer_timer_schedule_ranges(); + } let queue_stats = post_parse_lifecycle_queue_stats(&work); self.queue_initial_connected_style_loads_for_current_owner(); self.document_runtime.drain_document_processing_wakes(); @@ -1836,6 +1840,21 @@ impl ScriptVm { reached_boundary, invalidation_policy, } = PostParseProcessingAction::from_document_processing_action(action); + if work.is_domcontentloaded_task() + && self + .document_runtime + .has_ready_timeout_queued_by_classic_defer_script() + { + // The lifecycle plan pre-seeds this boundary marker so owner work + // stays ordered. It has now reached the front after defer-like and + // parser-owned pre-DCL work, which is the point at which HTML queues + // the actual DOMContentLoaded global task. Give only ready timers + // scheduled by classic defer scripts their intervening task turn. + page_task_queue.enqueue_front_post_parse_work_preserving_order(vec![work]); + return PostParseDriverStep::Ready(Box::new( + ReadyPostParseAction::TimerQueuedByClassicDeferBeforeDomContentLoaded, + )); + } if !(work.is_domcontentloaded_task() || work.is_window_load_task()) { return PostParseDriverStep::Ready(Box::new(ReadyPostParseAction::Processing( Box::new(PostParseProcessingAction { @@ -1958,6 +1977,11 @@ impl ScriptVm { ReadyPostParseAction::Processing(action) => { return Ok(PostParseProcessingStep::Action(action)); } + ReadyPostParseAction::TimerQueuedByClassicDeferBeforeDomContentLoaded => { + return Ok( + PostParseProcessingStep::TimerQueuedByClassicDeferBeforeDomContentLoaded, + ); + } }, PostParseDriverStep::NeedsContinuation => { return Ok(PostParseProcessingStep::NeedsContinuation); @@ -1996,6 +2020,11 @@ impl ScriptVm { execution.into_page_owned_task(), ))); } + PostParseProcessingStep::TimerQueuedByClassicDeferBeforeDomContentLoaded => { + return Ok( + PostParseLifecycleAdvance::TimerQueuedByClassicDeferBeforeDomContentLoaded, + ); + } PostParseProcessingStep::NeedsContinuation => { return Ok(PostParseLifecycleAdvance::NeedsContinuation); } diff --git a/moli-renderer-v8/src/script_vm/runtime_work.rs b/moli-renderer-v8/src/script_vm/runtime_work.rs index 495d8f3ecf..34acd51688 100644 --- a/moli-renderer-v8/src/script_vm/runtime_work.rs +++ b/moli-renderer-v8/src/script_vm/runtime_work.rs @@ -28,6 +28,7 @@ pub(crate) enum PostParseDrainResult { pub(crate) enum PostParseLifecycleAdvance { PageOwnedTask(Box), + TimerQueuedByClassicDeferBeforeDomContentLoaded, NeedsContinuation, AwaitProgress, Complete(PostParseLifecycleCompletionAction), @@ -322,6 +323,7 @@ impl PostParseProcessingAction { pub(crate) enum ReadyPostParseAction { Processing(Box), + TimerQueuedByClassicDeferBeforeDomContentLoaded, } pub(crate) enum PostParseRuntimeDriverStep { @@ -338,6 +340,7 @@ pub(crate) enum PostParseDriverStep { pub(crate) enum PostParseProcessingStep { Action(Box), + TimerQueuedByClassicDeferBeforeDomContentLoaded, NeedsContinuation, AwaitProgress, Idle, diff --git a/moli-time/src/lib.rs b/moli-time/src/lib.rs index 5c302cd75f..3549568c7d 100644 --- a/moli-time/src/lib.rs +++ b/moli-time/src/lib.rs @@ -6,7 +6,11 @@ use std::{ }; mod timers; -pub use timers::{ReadyTimer, TimerId, TimerReadyAllowance, TimerScheduler}; + +pub use timers::{ + ReadyTimer, TimerId, TimerReadyAllowance, TimerScheduleRange, TimerScheduleSnapshot, + TimerScheduler, +}; pub fn unix_epoch_millis() -> f64 { SystemTime::now() diff --git a/moli-time/src/timers.rs b/moli-time/src/timers.rs index da0e3fe32f..b187595268 100644 --- a/moli-time/src/timers.rs +++ b/moli-time/src/timers.rs @@ -23,6 +23,31 @@ pub struct TimerReadyAllowance { pub allowance: Duration, } +/// A scheduling-sequence boundary from which an exact timer range can be +/// recorded. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimerScheduleSnapshot { + next_sequence: u64, +} + +/// A half-open scheduling-sequence range for timers queued during one owner +/// operation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct TimerScheduleRange { + inclusive_sequence: u64, + exclusive_sequence: u64, +} + +impl TimerScheduleRange { + pub fn is_empty(self) -> bool { + self.inclusive_sequence == self.exclusive_sequence + } + + fn contains(self, sequence: u64) -> bool { + self.inclusive_sequence <= sequence && sequence < self.exclusive_sequence + } +} + impl TimerReadyAllowance { pub const NONE: Self = Self { max_delay_ms: 0, @@ -169,12 +194,71 @@ impl TimerScheduler { &mut self, now: Instant, allowance: TimerReadyAllowance, - predicate: F, + mut predicate: F, ) -> Option> where F: FnMut(&T) -> bool, { - let selected = self.next_ready_matching_timer(now, allowance, predicate)?; + self.take_next_ready_matching_scheduled( + now, + allowance, + |timer| predicate(&timer.payload), + |_| true, + ) + } + + /// Takes the next ready timer scheduled inside one of `ranges`. + /// + /// Timers scheduled by a callback that runs while draining the ranges are + /// outside those closed ranges and remain pending for a later task turn. + pub fn take_next_ready_from_schedule_ranges( + &mut self, + ranges: &[TimerScheduleRange], + now: Instant, + allowance: TimerReadyAllowance, + ) -> Option> { + self.take_next_ready_matching_scheduled( + now, + allowance, + |timer| schedule_ranges_contain(ranges, timer.sequence), + |timer| schedule_ranges_contain(ranges, timer.sequence), + ) + } + + fn take_next_ready_matching_scheduled( + &mut self, + now: Instant, + allowance: TimerReadyAllowance, + mut predicate: P, + mut blocks_if_not_ready: B, + ) -> Option> + where + P: FnMut(&ScheduledTimer) -> bool, + B: FnMut(&ScheduledTimer) -> bool, + { + let mut first_non_ready = None; + let mut selected = None; + for timer in &self.pending { + if !self.active.contains(&timer.id) { + continue; + } + if !timer_ready(timer.run_at, timer.delay_ms, now, allowance) { + if blocks_if_not_ready(timer) + && first_non_ready.is_none_or(|current| timer_precedes(timer, current)) + { + first_non_ready = Some(timer); + } + continue; + } + if predicate(timer) && selected.is_none_or(|current| timer_precedes(timer, current)) { + selected = Some(timer); + } + } + + let selected = selected?; + if first_non_ready.is_some_and(|barrier| timer_precedes(barrier, selected)) { + return None; + } let selected_id = selected.id; let selected_sequence = selected.sequence; @@ -259,6 +343,32 @@ impl TimerScheduler { .map(|timer| timer.run_at) } + pub fn has_ready_from_schedule_ranges( + &self, + ranges: &[TimerScheduleRange], + now: Instant, + allowance: TimerReadyAllowance, + ) -> bool { + self.pending.iter().any(|timer| { + self.active.contains(&timer.id) + && schedule_ranges_contain(ranges, timer.sequence) + && timer_ready(timer.run_at, timer.delay_ms, now, allowance) + }) + } + + pub fn schedule_snapshot(&self) -> TimerScheduleSnapshot { + TimerScheduleSnapshot { + next_sequence: self.next_sequence, + } + } + + pub fn schedule_range_since(&self, start: TimerScheduleSnapshot) -> TimerScheduleRange { + TimerScheduleRange { + inclusive_sequence: start.next_sequence, + exclusive_sequence: self.next_sequence, + } + } + pub fn next_deadline(&self) -> Option { self.pending .iter() @@ -342,6 +452,10 @@ impl TimerScheduler { } } +fn schedule_ranges_contain(ranges: &[TimerScheduleRange], sequence: u64) -> bool { + ranges.iter().any(|range| range.contains(sequence)) +} + fn timer_precedes(left: &ScheduledTimer, right: &ScheduledTimer) -> bool { match left.run_at.cmp(&right.run_at) { Ordering::Less => true, @@ -657,4 +771,79 @@ mod tests { scheduler.finish_running(ready.id); } } + + #[test] + fn schedule_ranges_select_only_timers_queued_inside_owner_operations() { + let now = Instant::now(); + let mut scheduler = TimerScheduler::default(); + let earlier = scheduler.schedule_after("earlier", 0, now); + let first_start = scheduler.schedule_snapshot(); + let first = scheduler.schedule_after("first", 0, now); + let first_range = scheduler.schedule_range_since(first_start); + let between = scheduler.schedule_after("between", 0, now); + let second_start = scheduler.schedule_snapshot(); + let second = scheduler.schedule_after("second", 0, now); + let second_range = scheduler.schedule_range_since(second_start); + let later = scheduler.schedule_after("later", 0, now); + let ranges = [first_range, second_range]; + + assert!(scheduler.has_ready_from_schedule_ranges(&ranges, now, TimerReadyAllowance::NONE)); + for (expected_id, expected_payload) in [(first, "first"), (second, "second")] { + let ready = scheduler + .take_next_ready_from_schedule_ranges(&ranges, now, TimerReadyAllowance::NONE) + .expect("timer scheduled inside an owner range should be ready"); + assert_eq!(ready.id, expected_id); + assert_eq!(ready.payload, expected_payload); + scheduler.finish_running(ready.id); + } + + assert!(!scheduler.has_ready_from_schedule_ranges(&ranges, now, TimerReadyAllowance::NONE)); + assert!( + scheduler + .take_next_ready_from_schedule_ranges(&ranges, now, TimerReadyAllowance::NONE) + .is_none(), + "timers outside the owner ranges must remain for later task turns" + ); + for (expected_id, expected_payload) in + [(earlier, "earlier"), (between, "between"), (later, "later")] + { + let ready = scheduler + .take_next_ready(now, TimerReadyAllowance::NONE) + .expect("timer outside the ranges should remain in the ordinary queue"); + assert_eq!(ready.id, expected_id); + assert_eq!(ready.payload, expected_payload); + scheduler.finish_running(ready.id); + } + } + + #[test] + fn interval_rescheduled_after_range_is_not_drained_twice() { + let now = Instant::now(); + let mut scheduler = TimerScheduler::default(); + let start = scheduler.schedule_snapshot(); + let interval = scheduler.schedule_after("tick", 0, now); + let range = scheduler.schedule_range_since(start); + + let ready = scheduler + .take_next_ready_from_schedule_ranges(&[range], now, TimerReadyAllowance::NONE) + .expect("initial interval task should belong to the range"); + assert_eq!(ready.id, interval); + assert!(scheduler.reschedule_running_after(ready.id, ready.payload, 1, now)); + + assert!( + scheduler + .take_next_ready_from_schedule_ranges( + &[range], + now + Duration::from_millis(1), + TimerReadyAllowance::NONE, + ) + .is_none(), + "an interval's newly scheduled task must not re-enter the closed range" + ); + let ready = scheduler + .take_next_ready(now + Duration::from_millis(1), TimerReadyAllowance::NONE) + .expect("rescheduled interval should remain in the ordinary queue"); + assert_eq!(ready.id, interval); + scheduler.finish_running(ready.id); + } }