From 45f3211939c088096ddbc17903f76088d1afc7cf Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 15 Oct 2026 13:40:58 +0800 Subject: [PATCH] fix(media): dispatch canplaythrough after canplay --- .../native_bridge/element/media/attributes.rs | 7 +- .../page_vm/tests/media_element_event.rs | 209 ++++++++++++++++++ .../extracted/main_document_lifecycle.rs | 39 +++- .../script_vm/tests/extracted/window_apis.rs | 12 +- 4 files changed, 260 insertions(+), 7 deletions(-) diff --git a/moli-renderer-v8/src/native_bridge/element/media/attributes.rs b/moli-renderer-v8/src/native_bridge/element/media/attributes.rs index 4ac0f28a98..44b4ffc2a0 100644 --- a/moli-renderer-v8/src/native_bridge/element/media/attributes.rs +++ b/moli-renderer-v8/src/native_bridge/element/media/attributes.rs @@ -554,6 +554,7 @@ pub(crate) enum MediaLoadEventPhase { LoadedMetadata, LoadedData, CanPlay, + CanPlayThrough, Error, } @@ -564,6 +565,7 @@ impl MediaLoadEventPhase { Self::LoadedMetadata => "loadedmetadata", Self::LoadedData => "loadeddata", Self::CanPlay => "canplay", + Self::CanPlayThrough => "canplaythrough", Self::Error => "error", } } @@ -573,7 +575,8 @@ impl MediaLoadEventPhase { Self::LoadStart => Some(Self::LoadedMetadata), Self::LoadedMetadata => Some(Self::LoadedData), Self::LoadedData => Some(Self::CanPlay), - Self::CanPlay | Self::Error => None, + Self::CanPlay => Some(Self::CanPlayThrough), + Self::CanPlayThrough | Self::Error => None, } } } @@ -604,7 +607,7 @@ pub(crate) fn dispatch_media_load_event_phase( return false; } match phase { - MediaLoadEventPhase::LoadStart => {} + MediaLoadEventPhase::LoadStart | MediaLoadEventPhase::CanPlayThrough => {} MediaLoadEventPhase::LoadedMetadata => { let _ = unsafe { &mut *runtime_ptr }.set_media_ready_state(handle, 1); } diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/media_element_event.rs b/moli-renderer-v8/src/runtime/page_vm/tests/media_element_event.rs index 9971e61071..b1d78d650f 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/media_element_event.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/media_element_event.rs @@ -383,3 +383,212 @@ emptyMedia.volume === 0.25 && !emptyMedia.muted && emptyMedia.defaultMuted"#, .await .expect("empty local media uses its owned error task without readiness events"); } + +#[tokio::test(flavor = "current_thread")] +async fn media_canplaythrough_runs_in_a_separate_owned_task_after_canplay_microtasks() { + run_page_vm_async_test(async move { + let loader = crate::network::ResourceRequestClient::new(&FetchConfig::default()) + .expect("loader"); + let (mut page, _source, _wake) = page_vm_with_bound_task_sources_and_owner_wake( + &loader, Url::parse("https://media-canplaythrough.test/")?, + ); + page.vm_mut().set_layout_policy(moli_page_types::LayoutPolicy::Mock); + page.vm_mut().eval(r#" +globalThis.mediaEvents = []; +globalThis.media = document.createElement('video'); +for (const type of ['loadstart', 'loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough']) { + media.addEventListener(type, event => { + mediaEvents.push(type + ':' + media.readyState); + if (type === 'canplay' || type === 'canplaythrough') { + Promise.resolve().then(() => mediaEvents.push(type + '-microtask')); + } + }); +} +media.oncanplay = () => setTimeout(() => mediaEvents.push('timer'), 0); +document.body.append(media); +media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQx'; +"#)?; + for _ in 0..4 { + let turn = page.claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ).expect("each load phase must have its own media-element task"); + page.run_claimed_selected_page_task_for_test(turn, &loader).await?; + } + assert_eq!(page.vm_mut().eval("mediaEvents.join('|')")?, + "loadstart:0|loadedmetadata:1|loadeddata:2|canplay:4|canplay-microtask"); + assert!(page.vm().has_ready_timeout(), "the unrelated timer must remain queued"); + let through = page.claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ).expect("HAVE_ENOUGH_DATA must queue canplaythrough after canplay"); + page.run_claimed_selected_page_task_for_test(through, &loader).await?; + assert_eq!(page.vm_mut().eval("mediaEvents.join('|')")?, + "loadstart:0|loadedmetadata:1|loadeddata:2|canplay:4|canplay-microtask|canplaythrough:4|canplaythrough-microtask"); + assert_eq!(page.vm_mut().eval("media.paused && media.networkState === 1")?, "true"); + assert!(page.vm().has_ready_timeout(), "canplaythrough must not consume timers"); + assert!(page.claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ).is_none(), "the terminal phase must finish the load sequence exactly once"); + Ok::<_, anyhow::Error>(()) + }).await.expect("canplaythrough task ordering and timer isolation"); +} + +#[tokio::test(flavor = "current_thread")] +async fn media_canplaythrough_from_a_retired_document_does_not_checkpoint_the_replacement() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let (mut page, _source, _wake) = page_vm_with_bound_task_sources_and_owner_wake( + &loader, + Url::parse("https://retired-media-canplaythrough.test/")?, + ); + page.vm_mut() + .set_layout_policy(moli_page_types::LayoutPolicy::Mock); + page.vm_mut().eval( + r#" +globalThis.retiredMediaEvents = []; +const media = document.createElement('video'); +media.oncanplaythrough = () => retiredMediaEvents.push('stale canplaythrough'); +document.body.append(media); +media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQx'; +"#, + )?; + for _ in 0..4 { + let turn = page + .claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .expect("load phase before Document retirement"); + page.run_claimed_selected_page_task_for_test(turn, &loader) + .await?; + } + let retired = page + .claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .expect("capture the terminal task before its Document is retired"); + page.vm_mut().eval( + "document.open();document.write('replacement');document.close();", + )?; + page.vm_mut().eval_without_microtask_checkpoint_for_test( + r#" +globalThis.replacementMediaEvents = []; +Promise.resolve().then(() => replacementMediaEvents.push('replacement microtask')); +"#, + )?; + page.vm_mut().enqueue_test_pending_runtime_source_load(); + page.run_claimed_selected_page_task_for_test(retired, &loader) + .await?; + assert_eq!( + page.vm_mut().eval_without_microtask_checkpoint_for_test( + "retiredMediaEvents.join('|') + ':' + replacementMediaEvents.join('|')", + )?, + ":" + ); + assert_eq!( + page.vm() + .document_runtime + .runtime_script_work() + .dynamic_scripts + .pending_source_load_count_for_test(), + 1, + "retired terminal events must not advance replacement script work" + ); + assert!( + page.claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .is_none() + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("retired canplaythrough owner and completion isolation"); +} + +#[tokio::test(flavor = "current_thread")] +async fn media_canplaythrough_is_cancelled_when_canplay_replaces_the_load_sequence() { + run_page_vm_async_test(async move { + let loader = + crate::network::ResourceRequestClient::new(&FetchConfig::default()).expect("loader"); + let (mut page, _source, _wake) = page_vm_with_bound_task_sources_and_owner_wake( + &loader, + Url::parse("https://replaced-media-canplaythrough.test/")?, + ); + page.vm_mut() + .set_layout_policy(moli_page_types::LayoutPolicy::Mock); + page.vm_mut().eval( + r#" +globalThis.replacedMediaEvents = []; +globalThis.media = document.createElement('video'); +let first = true; +media.oncanplay = () => { + replacedMediaEvents.push('canplay'); + if (first) { + first = false; + Promise.resolve().then(() => { + media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQxAA=='; + }); + } +}; +media.oncanplaythrough = () => replacedMediaEvents.push('canplaythrough'); +document.body.append(media); +media.src = 'data:video/mp4;base64,AAAAGGZ0eXBtcDQyAAAAAG1wNDJtcDQx'; +"#, + )?; + for _ in 0..4 { + let turn = page + .claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .expect("first load phase"); + page.run_claimed_selected_page_task_for_test(turn, &loader) + .await?; + } + assert_eq!( + page.vm_mut().eval("replacedMediaEvents.join('|')")?, + "canplay" + ); + let restarted = page + .claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .expect("consume the old terminal retained ahead of the replacement"); + page.run_claimed_selected_page_task_for_test(restarted, &loader) + .await?; + assert_eq!(page.vm_mut().eval("media.readyState")?, "0"); + assert_eq!( + page.vm_mut().eval("replacedMediaEvents.join('|')")?, + "canplay" + ); + let loadstart = page + .claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .expect("replacement loadstart follows the discarded old terminal"); + page.run_claimed_selected_page_task_for_test(loadstart, &loader) + .await?; + assert_eq!(page.vm_mut().eval("media.readyState")?, "0"); + for _ in 0..4 { + let turn = page + .claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .expect("replacement load phase"); + page.run_claimed_selected_page_task_for_test(turn, &loader) + .await?; + } + assert_eq!( + page.vm_mut().eval("replacedMediaEvents.join('|')")?, + "canplay|canplay|canplaythrough" + ); + assert!( + page.claim_exact_selected_page_task_for_test( + PageSelectedTaskTestSelector::MediaElementEvent, + ) + .is_none() + ); + Ok::<_, anyhow::Error>(()) + }) + .await + .expect("load sequence replacement cancels only the old terminal event"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/extracted/main_document_lifecycle.rs b/moli-renderer-v8/src/script_vm/tests/extracted/main_document_lifecycle.rs index 4858c6b322..1c1a5a5b7f 100644 --- a/moli-renderer-v8/src/script_vm/tests/extracted/main_document_lifecycle.rs +++ b/moli-renderer-v8/src/script_vm/tests/extracted/main_document_lifecycle.rs @@ -1336,7 +1336,7 @@ async fn main_static_media_load_delays_complete_until_loadeddata_owner_turn() { r#" globalThis.__mainMediaLifecycleEvents = []; const clip = document.getElementById("clip"); - for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay"]) { + for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough"]) { clip.addEventListener(type, () => { __mainMediaLifecycleEvents.push(`${type}:${document.readyState}`); }); @@ -1421,6 +1421,24 @@ async fn main_static_media_load_delays_complete_until_loadeddata_owner_turn() { )); run_next_page_media_element_event_for_test(&mut vm, &loader, "canplay owner turn").await; + assert!( + vm._context_host + .borrow() + .pending_media_load_sequence(media) + .is_some(), + "canplay must leave the terminal event owned by this sequence" + ); + assert_eq!( + vm.eval("__mainMediaLifecycleEvents.some(event => event.startsWith('canplaythrough:'))") + .unwrap(), + "false" + ); + run_next_page_media_element_event_for_test(&mut vm, &loader, "canplaythrough owner turn").await; + assert_eq!( + vm.eval("__mainMediaLifecycleEvents.at(-1).startsWith('canplaythrough:')") + .unwrap(), + "true" + ); assert!( vm._context_host .borrow() @@ -1985,7 +2003,7 @@ async fn main_text_track_network_terminal_gates_canplay_without_delaying_complet globalThis.__trackMediaEvents = []; const clip = document.getElementById("clip"); const captions = document.getElementById("captions"); - for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay"]) { + for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough"]) { clip.addEventListener(type, () => __trackMediaEvents.push(type)); } captions.addEventListener("load", () => { @@ -2131,6 +2149,23 @@ async fn main_text_track_network_terminal_gates_canplay_without_delaying_complet .expect("canplay event trace"), "loadstart|loadedmetadata|loadeddata|track-load:2:1|canplay" ); + assert!( + vm._context_host + .borrow() + .pending_media_load_sequence(media) + .is_some(), + "canplay must retain its canplaythrough continuation" + ); + run_next_page_media_element_event_for_test( + &mut vm, + &loader, + "media canplaythrough follow-up owner turn", + ) + .await; + assert_eq!( + vm.eval("__trackMediaEvents.join('|')").unwrap(), + "loadstart|loadedmetadata|loadeddata|track-load:2:1|canplay|canplaythrough" + ); assert!( vm._context_host .borrow() diff --git a/moli-renderer-v8/src/script_vm/tests/extracted/window_apis.rs b/moli-renderer-v8/src/script_vm/tests/extracted/window_apis.rs index d70c041eb9..29d12b9279 100644 --- a/moli-renderer-v8/src/script_vm/tests/extracted/window_apis.rs +++ b/moli-renderer-v8/src/script_vm/tests/extracted/window_apis.rs @@ -476,7 +476,7 @@ async fn default_video_fetch_policy_synthesizes_lifecycle_without_a_network_requ globalThis.__defaultVideoPolicyEvents = []; const video = document.createElement("video"); video.id = "default-video-policy"; - for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "error"]) { + for (const type of ["loadstart", "loadedmetadata", "loadeddata", "canplay", "canplaythrough", "error"]) { video.addEventListener(type, () => __defaultVideoPolicyEvents.push(type)); } video.src = "https://media-policy.test/large-video.mp4"; @@ -511,7 +511,13 @@ async fn default_video_fetch_policy_synthesizes_lifecycle_without_a_network_requ "policy-skipped video must not emit a requestWillBeSent source record" ); - for phase in ["loadstart", "loadedmetadata", "loadeddata", "canplay"] { + for phase in [ + "loadstart", + "loadedmetadata", + "loadeddata", + "canplay", + "canplaythrough", + ] { run_next_page_media_element_event_for_test( &mut vm, &loader, @@ -524,7 +530,7 @@ async fn default_video_fetch_policy_synthesizes_lifecycle_without_a_network_requ "JSON.stringify({events: __defaultVideoPolicyEvents, ready: document.getElementById('default-video-policy').readyState, network: document.getElementById('default-video-policy').networkState})" ) .expect("default-policy video terminal state"), - r#"{"events":["loadstart","loadedmetadata","loadeddata","canplay"],"ready":4,"network":1}"# + r#"{"events":["loadstart","loadedmetadata","loadeddata","canplay","canplaythrough"],"ready":4,"network":1}"# ); assert!( vm._context_host