From 278b324e3369f14788b741c49a001c21d29762a5 Mon Sep 17 00:00:00 2001 From: ruccho Date: Mon, 24 Aug 2026 19:09:58 +0900 Subject: [PATCH 01/10] Run the e2e harness on devices and in a browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking an encoder change on a phone meant building a Unity player, so in practice the mobile and web backends were only ever exercised by hand. The harness now runs standalone wherever the encoders do, off one pipeline definition and one set of assertions in unienc_testkit. Each platform needed a different way in, and the reasons are worth stating: - **iOS simulator.** A Rust test binary is a plain executable, so simctl runs it directly — no app bundle, no provisioning profile, no signing. A runner in .cargo/config.toml is the whole integration. VideoToolbox does encode H.264 in the simulator, so this covers the encoder and not just the build. - **Android.** MediaCodec and MediaMuxer need a JavaVM but neither an Activity nor a Context, so a JVM is the only thing an adb shell lacks and app_process supplies one. Hence a library loaded by a small Java shim rather than an instrumented test inside an APK: no Gradle project, and the same command works against an emulator and a real device. - **Web.** The only target where the harness cannot be a `cargo test`. The encoders are the browser's own and report through callbacks delivered as browser tasks, so the thread has to keep returning to the event loop while libtest wants a test function that runs to completion. unienc_harness_web drives the pipeline the way Unity does instead: a callback per animation frame. ASYNCIFY is not the easier answer it looks like — it unwinds the stack while suspended and the encoder callbacks re-enter wasm during that window. Two web-specific details are worth knowing about. Emscripten is built here without pthreads, so TestRuntime uses a LocalPool, matching the build unienc_c ships for the web. And the muxer downloads its output rather than writing a file, so `web::capture_muxed_output` intercepts the one JavaScript function it calls and writes the same bytes into the in-memory filesystem — the backend is unchanged. The run now reports which stage it finished, because a harness that hangs on a device otherwise gives nothing to go on. The video duration check allows for one frame interval: MediaMuxer gives the last sample a duration of zero where AVFoundation and FFmpeg give it a full one. Verified on an iOS 26 simulator and an Android emulator; see docs/device-testing.md, which records what none of this covers. Co-Authored-By: Claude Opus 5 --- .../unienc/.cargo/config.toml | 16 ++ InstantReplay.Externals/unienc/Cargo.lock | 18 ++ .../crates/unienc_harness_android/Cargo.toml | 15 ++ .../co/cyberagent/unienc/harness/Harness.java | 27 +++ .../crates/unienc_harness_android/src/lib.rs | 61 +++++++ .../crates/unienc_harness_web/Cargo.toml | 12 ++ .../crates/unienc_harness_web/src/main.rs | 138 ++++++++++++++ .../crates/unienc_testkit/src/driver.rs | 52 ++++++ .../unienc/crates/unienc_testkit/src/e2e.rs | 50 ++++-- .../unienc/crates/unienc_testkit/src/lib.rs | 16 +- .../crates/unienc_testkit/src/runtime.rs | 79 ++++++-- .../crates/unienc_testkit/src/verify.rs | 24 +-- .../unienc/crates/unienc_testkit/src/web.rs | 76 ++++++++ .../unienc/crates/unienc_testkit/tests/e2e.rs | 28 ++- .../unienc/docs/device-testing.md | 168 ++++++++++++++++++ .../unienc/scripts/android-device-test.sh | 121 +++++++++++++ .../unienc/scripts/ios-simulator-test.sh | 63 +++++++ .../unienc/scripts/run-in-browser.sh | 81 +++++++++ .../unienc/scripts/web-browser-test.sh | 75 ++++++++ 19 files changed, 1062 insertions(+), 58 deletions(-) create mode 100644 InstantReplay.Externals/unienc/crates/unienc_harness_android/Cargo.toml create mode 100644 InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java create mode 100644 InstantReplay.Externals/unienc/crates/unienc_harness_android/src/lib.rs create mode 100644 InstantReplay.Externals/unienc/crates/unienc_harness_web/Cargo.toml create mode 100644 InstantReplay.Externals/unienc/crates/unienc_harness_web/src/main.rs create mode 100644 InstantReplay.Externals/unienc/crates/unienc_testkit/src/driver.rs create mode 100644 InstantReplay.Externals/unienc/crates/unienc_testkit/src/web.rs create mode 100644 InstantReplay.Externals/unienc/docs/device-testing.md create mode 100755 InstantReplay.Externals/unienc/scripts/android-device-test.sh create mode 100755 InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh create mode 100755 InstantReplay.Externals/unienc/scripts/run-in-browser.sh create mode 100755 InstantReplay.Externals/unienc/scripts/web-browser-test.sh diff --git a/InstantReplay.Externals/unienc/.cargo/config.toml b/InstantReplay.Externals/unienc/.cargo/config.toml index e1f7f0ff..0eb2f077 100644 --- a/InstantReplay.Externals/unienc/.cargo/config.toml +++ b/InstantReplay.Externals/unienc/.cargo/config.toml @@ -1,2 +1,18 @@ [target.'cfg(windows)'] rustflags = ["-C", "link-args=/Brepro"] + +# A Rust test binary is a plain executable, so the iOS simulator can run one +# directly through simctl. No app bundle, no provisioning profile and no code +# signing are involved, which is what keeps `cargo test --target +# aarch64-apple-ios-sim` usable as an everyday command. A simulator has to be +# booted first; scripts/ios-simulator-test.sh does that. +# +# This covers the encoders' own logic on the Apple platform. It does not cover +# the static library link, where the iOS build differs from macOS most (see the +# mimalloc symbol localization in build-unienc.yml), nor the hardware encoder of +# a real device. +[target.aarch64-apple-ios-sim] +runner = ["xcrun", "simctl", "spawn", "--standalone", "booted"] + +[target.x86_64-apple-ios] +runner = ["xcrun", "simctl", "spawn", "--standalone", "booted"] diff --git a/InstantReplay.Externals/unienc/Cargo.lock b/InstantReplay.Externals/unienc/Cargo.lock index bada5554..81681327 100644 --- a/InstantReplay.Externals/unienc/Cargo.lock +++ b/InstantReplay.Externals/unienc/Cargo.lock @@ -1203,6 +1203,24 @@ dependencies = [ "unienc_common", ] +[[package]] +name = "unienc_harness_android" +version = "1.4.1" +dependencies = [ + "jni", + "unienc", + "unienc_testkit", +] + +[[package]] +name = "unienc_harness_web" +version = "1.4.1" +dependencies = [ + "futures", + "unienc_common", + "unienc_testkit", +] + [[package]] name = "unienc_testkit" version = "1.4.1" diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_android/Cargo.toml b/InstantReplay.Externals/unienc/crates/unienc_harness_android/Cargo.toml new file mode 100644 index 00000000..b70cf44a --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_android/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "unienc_harness_android" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +jni = "0.21.1" +unienc = { workspace = true } +unienc_testkit = { workspace = true } diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java b/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java new file mode 100644 index 00000000..03205ad3 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java @@ -0,0 +1,27 @@ +package jp.co.cyberagent.unienc.harness; + +/** + * Shim that gives the native harness a JavaVM. + * + *

Loading the library is the whole point: {@code System.load} calls + * {@code JNI_OnLoad}, which is where the MediaCodec backend picks up the + * JavaVM it cannot work without. Everything else happens in native code. + * + *

Run through {@code app_process} so that no APK is needed; see + * {@code scripts/android-device-test.sh}. + */ +public final class Harness { + private Harness() {} + + private static native int run(String outputPath); + + public static void main(String[] args) { + if (args.length != 2) { + System.out.println("usage: Harness "); + System.exit(2); + } + + System.load(args[0]); + System.exit(run(args[1])); + } +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_android/src/lib.rs b/InstantReplay.Externals/unienc/crates/unienc_harness_android/src/lib.rs new file mode 100644 index 00000000..c8cd07a5 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_android/src/lib.rs @@ -0,0 +1,61 @@ +//! Android device harness for the shared end-to-end test. +//! +//! The MediaCodec backend needs a `JavaVM`, and the only way to obtain one is to +//! be loaded by a JVM. A bare executable pushed with `adb` therefore cannot run +//! the encoders, however convenient that would be. +//! +//! MediaCodec and MediaMuxer do not need an `Activity` or a `Context` though, so +//! a JVM is the *only* thing missing, and `app_process` provides one from a +//! shell. That is why this is a library loaded by a small Java shim rather than +//! an instrumented test inside an APK: no Gradle project, no packaging, and the +//! same command works against an emulator and a real device. +//! +//! See `scripts/android-device-test.sh`. + +use std::ffi::{c_int, c_void}; +use std::path::PathBuf; + +use jni::JNIEnv; +use jni::objects::{JClass, JString}; + +use unienc_testkit::E2eConfig; + +/// Called by the JVM when the Java shim loads this library. +/// +/// Handing the `JavaVM` to `unienc_android_mc` here is what makes the encoders +/// usable at all; without it every call fails with `JavaVM not initialized`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn JNI_OnLoad(vm: *mut c_void, reserved: *mut c_void) -> c_int { + unsafe { unienc::android::set_java_vm(vm as *mut _, reserved) } +} + +/// Runs the harness and returns a process exit status: zero when everything the +/// harness checks holds, one when it does not. +/// +/// Both the description of a successful output and the reason for a failure go +/// to stdout, which under `app_process` is the shell that invoked it. +#[unsafe(no_mangle)] +pub extern "system" fn Java_jp_co_cyberagent_unienc_harness_Harness_run( + mut env: JNIEnv, + _class: JClass, + output_path: JString, +) -> c_int { + let output_path: String = match env.get_string(&output_path) { + Ok(path) => path.into(), + Err(error) => { + println!("harness: cannot read the output path argument: {error}"); + return 1; + } + }; + + match unienc_testkit::run_and_verify(&E2eConfig::default(), &PathBuf::from(output_path)) { + Ok(description) => { + println!("harness: ok\n{description}"); + 0 + } + Err(message) => { + println!("harness: FAILED\n{message}"); + 1 + } + } +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_web/Cargo.toml b/InstantReplay.Externals/unienc/crates/unienc_harness_web/Cargo.toml new file mode 100644 index 00000000..51c19424 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_web/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "unienc_harness_web" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +publish = false + +[dependencies] +futures = "0.3.31" +unienc_common = { workspace = true } +unienc_testkit = { workspace = true } diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_web/src/main.rs b/InstantReplay.Externals/unienc/crates/unienc_harness_web/src/main.rs new file mode 100644 index 00000000..b5e4260d --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_web/src/main.rs @@ -0,0 +1,138 @@ +//! Browser harness for the shared end-to-end test. +//! +//! The web is the one target where the harness cannot be a `cargo test`. The +//! encoders are the browser's own and report through callbacks delivered as +//! browser tasks, so the only thread must keep returning to the event loop — +//! while libtest expects a test function to run to completion and return. A +//! blocking driver would wait for results the browser cannot deliver. +//! +//! So this is a program instead, and it drives the pipeline the way Unity does in +//! production: a callback per animation frame, polling what has become ready. +//! `emscripten_set_main_loop` here plays the part `unienc_tick_runtime` plays +//! there. +//! +//! ASYNCIFY looks like an easier answer and is not one: it unwinds the wasm stack +//! while suspended, and the encoder callbacks re-enter wasm during exactly that +//! window, which is the reentrancy it does not support. +//! +//! Run it with `scripts/web-browser-test.sh`. + +use std::ffi::c_void; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::ExitCode; +use std::task::{Context, Poll}; + +use futures::executor::LocalPool; +use unienc_common::Result as UniencResult; +use unienc_testkit::e2e::{E2eConfig, E2eReport}; +use unienc_testkit::{TestRuntime, e2e, verify_output}; + +unsafe extern "C" { + fn emscripten_set_main_loop_arg( + callback: extern "C" fn(*mut c_void), + argument: *mut c_void, + fps: i32, + simulate_infinite_loop: i32, + ); + fn emscripten_cancel_main_loop(); + fn emscripten_force_exit(status: i32); +} + +/// Everything the per-frame callback needs, kept alive on the heap for as long as +/// the main loop runs. +struct Harness { + config: E2eConfig, + output_path: PathBuf, + pool: LocalPool, + future: Pin>>>, +} + +fn main() -> ExitCode { + let config = E2eConfig::default(); + // Emscripten's filesystem is in memory; the root is the one directory + // guaranteed to exist. + let output_path = PathBuf::from("/e2e.mp4"); + + // The muxer hands its bytes to a download rather than writing a file, so they + // have to be diverted before the run for the verification to find anything. + if let Err(message) = unienc_testkit::web::capture_muxed_output(&output_path) { + println!("harness: FAILED\n{message}"); + return ExitCode::FAILURE; + } + + let pool = LocalPool::new(); + let runtime = TestRuntime::from_spawner(pool.spawner()); + let encoding_system = e2e::new_platform_system(&config, runtime.clone()); + + // Boxed and leaked into the main loop: `main` returns before the work is + // done, so nothing here may live on its stack. + let harness = Box::new(Harness { + future: Box::pin(e2e::run_with( + encoding_system, + runtime, + config, + output_path.clone(), + )), + config, + output_path, + pool, + }); + + println!("harness: driving the pipeline from the browser's main loop"); + unsafe { + // fps 0 means requestAnimationFrame, and not simulating an infinite loop + // is what lets `main` return while the loop keeps running. + emscripten_set_main_loop_arg(tick, Box::into_raw(harness) as *mut c_void, 0, 0); + } + + // The real status is reported by `finish`; returning here would tear the + // runtime down before the loop has run at all. + ExitCode::SUCCESS +} + +/// Polls the pipeline once per frame, letting the browser run in between. +extern "C" fn tick(argument: *mut c_void) { + // SAFETY: the pointer is the box leaked in `main` and stays valid until + // `finish` reclaims it. + let harness = unsafe { &mut *(argument as *mut Harness) }; + + // Spawned tasks first: the future below is normally waiting on one of them. + harness.pool.run_until_stalled(); + + let waker = futures::task::noop_waker(); + let mut context = Context::from_waker(&waker); + + match harness.future.as_mut().poll(&mut context) { + Poll::Pending => {} + Poll::Ready(result) => finish(argument, result), + } +} + +/// Reports the outcome and exits, having stopped the loop first. +fn finish(argument: *mut c_void, result: UniencResult) { + // SAFETY: as in `tick`; taking ownership back so nothing is polled again. + let harness = unsafe { Box::from_raw(argument as *mut Harness) }; + unsafe { emscripten_cancel_main_loop() }; + + let status = match result { + Err(error) => { + println!("harness: FAILED\nthe encode failed: {error}"); + 1 + } + Ok(report) => match verify_output(&harness.config, &harness.output_path, &report) { + Ok(description) => { + println!("harness: ok\n{description}"); + 0 + } + Err(message) => { + println!("harness: FAILED\n{message}"); + 1 + } + }, + }; + + // The page has no other way to report a status, and emrun turns the exit + // status into the process's. + unsafe { emscripten_force_exit(status) }; +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/driver.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/driver.rs new file mode 100644 index 00000000..98f6a043 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/driver.rs @@ -0,0 +1,52 @@ +//! The one call a driver makes. +//! +//! Every target that runs this harness — `cargo test` on a build host, a JVM +//! shim on an Android device, a browser page — needs the same three steps in the +//! same order: encode, check the run, check the file. Putting them here rather +//! than in each driver is what keeps a platform from quietly skipping one. +//! +//! The result is a string either way, because a device harness usually has +//! nothing but a log to report through. + +use std::path::Path; + +// `run_and_verify` is the only user, and it is absent on the web. +#[cfg(not(target_os = "emscripten"))] +use crate::e2e; +use crate::e2e::{E2eConfig, E2eReport}; +use crate::{mp4, verify}; + +/// Encodes to `output_path`, then verifies the run and the file it produced. +/// +/// On success the description of the output is returned, so a driver can log +/// what it got rather than only that it was happy. On failure the message says +/// what did not hold, with the description appended when the file was readable. +#[cfg(not(target_os = "emscripten"))] +pub fn run_and_verify(config: &E2eConfig, output_path: &Path) -> Result { + let report = + e2e::run(config, output_path).map_err(|error| format!("the encode failed: {error}"))?; + verify_output(config, output_path, &report) +} + +/// The checks that follow a run. +/// +/// Split from the run itself for the sake of a driver that cannot block, which +/// has to do these when its future resolves rather than after a blocking call. +pub fn verify_output( + config: &E2eConfig, + output_path: &Path, + report: &E2eReport, +) -> Result { + verify::verify_report(report, config) + .map_err(|error| format!("the run did not do what was asked: {error}"))?; + + let bytes = std::fs::read(output_path) + .map_err(|error| format!("cannot read {}: {error}", output_path.display()))?; + let summary = mp4::summarize(&bytes) + .map_err(|error| format!("the output is not a readable MP4: {error}"))?; + + let described = verify::describe(&summary); + verify::verify_mp4(&summary, config).map_err(|error| format!("{error}{described}"))?; + + Ok(described) +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs index 6516f7fc..e79c0d02 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs @@ -5,7 +5,10 @@ //! linked into a device harness, or from an Emscripten `main` in a browser, //! without the platforms drifting apart. +// Only `run`, which the web does not have, borrows a path. +#[cfg(not(target_os = "emscripten"))] use std::path::Path; +use std::path::PathBuf; use futures::channel::oneshot::Canceled; use unienc_common::{ @@ -81,26 +84,47 @@ pub struct E2eReport { /// Runs the whole pipeline on this platform's encoding system and writes an MP4 /// to `output_path`. +/// +/// Absent on the web, where blocking the only thread would stop the event loop +/// the browser's encoders report through. A driver there owns a `LocalPool`, +/// builds the future with [`run_with`] and polls both from a main-loop callback; +/// see `unienc_harness_web`. +#[cfg(not(target_os = "emscripten"))] pub fn run(config: &E2eConfig, output_path: &Path) -> unienc_common::Result { let runtime = TestRuntime::new(); - let encoding_system = unienc::PlatformEncodingSystem::new( + let encoding_system = new_platform_system(config, runtime.clone()); + futures::executor::block_on(run_with( + encoding_system, + runtime, + *config, + output_path.to_path_buf(), + )) +} + +/// Builds the encoding system this target selects. +pub fn new_platform_system( + config: &E2eConfig, + runtime: TestRuntime, +) -> unienc::PlatformEncodingSystem { + unienc::PlatformEncodingSystem::new( &TestVideoOptions::from(config), &TestAudioOptions::from(config), - runtime.clone(), - ); - - futures::executor::block_on(run_with(encoding_system, runtime, config, output_path)) + runtime, + ) } /// Runs the pipeline on a specific encoding system. /// /// Generic over the system so that a driver can exercise one backend directly /// instead of whatever the target selects. +/// Takes its arguments by value so that the future owns everything it needs. A +/// driver that has to keep the future alive across main-loop callbacks cannot +/// lend it anything from a stack frame that will be gone. pub async fn run_with( encoding_system: S, runtime: TestRuntime, - config: &E2eConfig, - output_path: &Path, + config: E2eConfig, + output_path: PathBuf, ) -> unienc_common::Result where S: EncodingSystem + Send, @@ -109,14 +133,12 @@ where { let video_encoder = encoding_system.new_video_encoder()?; let audio_encoder = encoding_system.new_audio_encoder()?; - let muxer = encoding_system.new_muxer(output_path)?; + let muxer = encoding_system.new_muxer(&output_path)?; let (mut video_input, mut video_output) = video_encoder.get()?; let (mut audio_input, mut audio_output) = audio_encoder.get()?; let (mut mux_video, mut mux_audio, completion) = muxer.get_inputs()?; - let config = *config; - let emit_video = runtime.spawn_with_result(async move { for index in 0..config.video_frames() { let data = pattern::video_frame_bgra32(config.width, config.height, index); @@ -131,6 +153,7 @@ where }) .await?; } + println!("harness: pushed {} video frames", config.video_frames()); Ok(config.video_frames()) }); @@ -143,6 +166,7 @@ where }) .await?; } + println!("harness: pushed {} audio chunks", config.duration_secs); Ok(config.duration_secs) }); @@ -158,6 +182,7 @@ where pulled += 1; } mux_video.finish().await?; + println!("harness: transferred {pulled} encoded video items"); Ok(pulled) }); @@ -169,16 +194,19 @@ where pulled += 1; } mux_audio.finish().await?; + println!("harness: transferred {pulled} encoded audio items"); Ok(pulled) }); // The inputs have to be finished before the muxer is waited on, otherwise - // FFmpeg and MediaMuxer never see end of stream. + // FFmpeg and MediaMuxer never see end of stream. Each stage announces itself + // because a harness that hangs on a device gives nothing else to go on. let video_frames_pushed = join(emit_video).await?; let audio_chunks_pushed = join(emit_audio).await?; let video_data_pulled = join(transfer_video).await?; let audio_data_pulled = join(transfer_audio).await?; + println!("harness: waiting for the muxer"); completion.finish().await?; Ok(E2eReport { diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs index e9314470..4702757e 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs @@ -8,20 +8,24 @@ //! //! ```no_run //! let config = unienc_testkit::E2eConfig::default(); -//! let report = unienc_testkit::e2e::run(&config, std::path::Path::new("out.mp4")).unwrap(); -//! unienc_testkit::verify::verify_report(&report, &config).unwrap(); -//! -//! let bytes = std::fs::read("out.mp4").unwrap(); -//! let summary = unienc_testkit::mp4::summarize(&bytes).unwrap(); -//! unienc_testkit::verify::verify_mp4(&summary, &config).unwrap(); +//! match unienc_testkit::run_and_verify(&config, std::path::Path::new("out.mp4")) { +//! Ok(description) => println!("{description}"), +//! Err(message) => panic!("{message}"), +//! } //! ``` +pub mod driver; pub mod e2e; pub mod mp4; pub mod options; pub mod pattern; pub mod runtime; pub mod verify; +#[cfg(target_os = "emscripten")] +pub mod web; +#[cfg(not(target_os = "emscripten"))] +pub use driver::run_and_verify; +pub use driver::verify_output; pub use e2e::{E2eConfig, E2eReport}; pub use runtime::TestRuntime; diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs index decb2ac8..af4774e8 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs @@ -1,25 +1,46 @@ use futures::channel::oneshot::Canceled; -use futures::executor::ThreadPool; -use futures::task::SpawnExt; use std::pin::Pin; use unienc_common::{Spawn, SpawnBlocking}; /// The runtime the test harness drives the encoders with. /// -/// It deliberately mirrors `unienc_c::runtime::RuntimeSpawner`, the runtime used -/// in production: a `futures::executor::ThreadPool` for futures and the -/// `blocking` crate's pool for blocking work, with no Tokio runtime anywhere. A -/// backend that reaches for an ambient Tokio reactor therefore fails here -/// exactly as it would in a Unity player. +/// It deliberately mirrors the runtime `unienc_c` builds in production, because a +/// harness that gives the encoders a more capable runtime than they will really +/// have proves very little. That means two shapes, matching `unienc_c`'s +/// `multi-thread` feature: +/// +/// - Everywhere with threads: a `futures::executor::ThreadPool`, and blocking +/// work on the `blocking` crate's pool. No Tokio runtime exists here, so a +/// backend reaching for an ambient Tokio reactor fails exactly as it would in a +/// player. +/// - On Emscripten, which is built without pthreads: a single-threaded +/// `LocalPool`, whose owner has to keep driving it. See +/// [`crate::web::drive_to_completion`]. #[derive(Clone)] pub struct TestRuntime { - pool: ThreadPool, + #[cfg(not(target_os = "emscripten"))] + pool: futures::executor::ThreadPool, + #[cfg(target_os = "emscripten")] + spawner: SingleThreaded, } impl TestRuntime { + /// Builds a runtime backed by a thread pool. + #[cfg(not(target_os = "emscripten"))] pub fn new() -> Self { Self { - pool: ThreadPool::new().expect("Failed to build thread pool"), + pool: futures::executor::ThreadPool::new().expect("Failed to build thread pool"), + } + } + + /// Builds a runtime that spawns onto a caller-owned `LocalPool`. + /// + /// The pool has to be driven by whoever owns it; nothing here runs on its + /// own. + #[cfg(target_os = "emscripten")] + pub fn from_spawner(spawner: futures::executor::LocalSpawner) -> Self { + Self { + spawner: SingleThreaded(spawner), } } @@ -36,6 +57,7 @@ impl TestRuntime { } } +#[cfg(not(target_os = "emscripten"))] impl Default for TestRuntime { fn default() -> Self { Self::new() @@ -44,9 +66,22 @@ impl Default for TestRuntime { impl Spawn for TestRuntime { fn spawn(&self, future: impl Future + Send + 'static) { - self.pool - .spawn(future) - .expect("Failed to spawn task on threaded executor"); + #[cfg(not(target_os = "emscripten"))] + let result = { + use futures::task::SpawnExt; + self.pool.spawn(future) + }; + + // The backends spawn from `Drop`, which runs inside a task the pool is + // already running, so this must not need the pool itself. A cloned + // spawner queues the task without touching it. + #[cfg(target_os = "emscripten")] + let result = { + use futures::task::LocalSpawnExt; + self.spawner.0.spawn_local(future) + }; + + result.expect("Failed to spawn task"); } } @@ -55,8 +90,26 @@ impl SpawnBlocking for TestRuntime { &self, f: impl FnOnce() -> Result + Send + 'static, ) -> Pin + Send + 'static>> { - Box::pin(blocking::unblock(f)) + #[cfg(not(target_os = "emscripten"))] + return Box::pin(blocking::unblock(f)); + + // Nowhere to offload to on a target without threads, so the work happens + // here and the future is already resolved. + #[cfg(target_os = "emscripten")] + return Box::pin(std::future::ready(f())); } } impl unienc_common::Runtime for TestRuntime {} + +/// Asserts `Send` for a value only ever used on one thread. +/// +/// `Runtime` requires `Send`, but this target is built without pthreads, so the +/// process has a single thread and there is nowhere a value could be sent to. +#[cfg(target_os = "emscripten")] +#[derive(Clone)] +struct SingleThreaded(T); + +// SAFETY: see the type's documentation; the target has one thread. +#[cfg(target_os = "emscripten")] +unsafe impl Send for SingleThreaded {} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs index a950b7f3..954f346b 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs @@ -191,15 +191,20 @@ fn verify_video_track( ) }); - findings.check( - (track.duration - expected_duration).abs() <= DURATION_TOLERANCE_SECS, - || { - format!( - "video track is {:.3} s, expected {:.3} s", - track.duration, expected_duration - ) - }, - ); + // A muxer that derives sample durations from the gaps between presentation + // timestamps has nothing to derive the last one from. MediaMuxer gives it + // zero, so the track legitimately ends a frame short, while AVFoundation and + // FFmpeg give it a full interval. The frame count above is the strict check; + // this one is about the timeline being the right length. + let interval = 1.0 / config.fps as f64; + let shortest = expected_duration - interval - DURATION_TOLERANCE_SECS; + let longest = expected_duration + DURATION_TOLERANCE_SECS; + findings.check((shortest..=longest).contains(&track.duration), || { + format!( + "video track is {:.3} s, expected between {:.3} s and {:.3} s", + track.duration, shortest, longest + ) + }); // A decoder joining at the start needs the first sample to be a keyframe. findings.check(track.is_sync_sample(1), || { @@ -207,7 +212,6 @@ fn verify_video_track( }); let times = track.sample_times(); - let interval = 1.0 / config.fps as f64; let out_of_order = times.windows(2).position(|pair| pair[1] <= pair[0]); findings.check(out_of_order.is_none(), || { let at = out_of_order.unwrap(); diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/web.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/web.rs new file mode 100644 index 00000000..737b9c64 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/web.rs @@ -0,0 +1,76 @@ +//! Emscripten-specific glue, so that the web runs the same harness as everyone +//! else. + +use std::ffi::{CString, c_char}; +use std::path::Path; + +unsafe extern "C" { + fn emscripten_run_script(script: *const c_char); +} + +/// Redirects the muxer's browser download into the in-memory filesystem. +/// +/// On the web the muxer hands its finished bytes to a download instead of +/// writing a file, so the shared driver would have nothing to read back. Rather +/// than give the backend a test-only mode, this intercepts the one JavaScript +/// function it calls and writes the same bytes to `path`. Nothing in +/// `unienc_webcodecs` knows the difference. +/// +/// `window.unienc_webcodecs` does not exist yet at this point — the backend +/// creates it lazily when the first encoder is built — so the interception is a +/// property setter that patches the object as it is assigned and then replaces +/// itself with the plain value. +pub fn capture_muxed_output(path: &Path) -> Result<(), String> { + let target = path.to_string_lossy(); + if target.contains('"') || target.contains('\\') { + // The path is interpolated into a script; refuse anything needing care. + return Err(format!( + "output path is not usable from JavaScript: {target}" + )); + } + + let script = format!( + r#" + (function () {{ + const target = "{target}"; + const capture = function (partsPtr, numParts) {{ + const header = Module.HEAPU32.subarray( + partsPtr >> 2, (partsPtr >> 2) + numParts * 2); + const parts = []; + let total = 0; + for (let i = 0; i < numParts; i++) {{ + const ptr = header[i * 2]; + const len = header[i * 2 + 1]; + // Copied rather than viewed: the heap can be reallocated + // while this runs, which would leave a view dangling. + parts.push(Module.HEAPU8.slice(ptr, ptr + len)); + total += len; + }} + const joined = new Uint8Array(total); + let at = 0; + for (const part of parts) {{ + joined.set(part, at); + at += part.length; + }} + (Module.FS || FS).writeFile(target, joined); + }}; + Object.defineProperty(window, "unienc_webcodecs", {{ + configurable: true, + set: function (value) {{ + value.makeDownload = capture; + Object.defineProperty(window, "unienc_webcodecs", {{ + value: value, + writable: true, + configurable: true, + }}); + }}, + }}); + }})(); + "# + ); + + let script = CString::new(script).map_err(|error| error.to_string())?; + // SAFETY: the script is a valid NUL-terminated C string. + unsafe { emscripten_run_script(script.as_ptr()) }; + Ok(()) +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs index beb20dc4..e715eeb6 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/tests/e2e.rs @@ -1,11 +1,13 @@ -//! Desktop driver for the shared end-to-end harness. +//! Desktop and simulator driver for the shared end-to-end harness. //! //! Whichever backend the target selects is the one under test: VideoToolbox on -//! Apple platforms, Media Foundation on Windows, FFmpeg elsewhere. +//! Apple platforms, Media Foundation on Windows, FFmpeg elsewhere. With a +//! simulator runner configured in `.cargo/config.toml`, the very same test runs +//! inside the iOS simulator. use std::path::PathBuf; -use unienc_testkit::{E2eConfig, e2e, mp4, verify}; +use unienc_testkit::E2eConfig; /// Cargo hands integration tests a directory for their artifacts, which keeps /// the muxed file out of the crate directory. @@ -16,21 +18,11 @@ fn output_path(name: &str) -> PathBuf { #[test] fn encodes_and_muxes_a_playable_mp4() { let config = E2eConfig::default(); - let path = output_path("e2e.mp4"); - let report = e2e::run(&config, &path).expect("the encode failed"); - if let Err(error) = verify::verify_report(&report, &config) { - panic!("the run did not do what was asked: {error}"); - } - - let bytes = std::fs::read(&path).expect("the muxed file is missing"); - let summary = mp4::summarize(&bytes).expect("the output is not a readable MP4"); - - // Printed unconditionally: when this fails on a machine that is not to hand, - // the log is the only evidence of what came out. - println!("{}", verify::describe(&summary)); - - if let Err(error) = verify::verify_mp4(&summary, &config) { - panic!("{}\n{}", error, verify::describe(&summary)); + match unienc_testkit::run_and_verify(&config, &output_path("e2e.mp4")) { + // Printed unconditionally: when this fails on a machine that is not to + // hand, the log is the only evidence of what came out. + Ok(description) => println!("{description}"), + Err(message) => panic!("{message}"), } } diff --git a/InstantReplay.Externals/unienc/docs/device-testing.md b/InstantReplay.Externals/unienc/docs/device-testing.md new file mode 100644 index 00000000..010f0591 --- /dev/null +++ b/InstantReplay.Externals/unienc/docs/device-testing.md @@ -0,0 +1,168 @@ +# Testing unienc without Unity + +Building a Unity player to check an encoder change is slow, so the end-to-end +harness runs standalone on every platform it can. `unienc_testkit` holds one +pipeline definition and one set of assertions; each platform only supplies a way +to start it. That is the point: a backend cannot pass on a build host for reasons +that would not hold on a phone. + +What every platform runs is the same: encode ten seconds of colour bars and a +tone, mux them, then read the resulting MP4 back and check it. See +`crates/unienc_testkit/src/verify.rs` for the assertions. + +## What this does not cover + +- **The GPU blit path.** `is_blit_supported()` requires a Unity graphics device, + so a standalone harness always exercises the CPU readback path. Testing blit + needs Unity. +- **The static library link.** On iOS the shipped artifact is a `.a` linked into + UnityFramework, with mimalloc symbols localized (see `build-unienc.yml`). The + simulator harness builds its own executable and does not go through that. +- **Vendor hardware encoders.** An emulator and a simulator use software or host + encoders. The frame drops, size alignment faults and colour shifts that have + needed fixing before are specific to a device's own encoder, so a real device + is still the only place they show up. + +## Desktop + +```bash +cargo test -p unienc_testkit -p unienc_common -p +``` + +`` is `unienc_apple_vt` on macOS, `unienc_windows_mf` on Windows +and `unienc_ffmpeg` on Linux; a platform backend only builds for its own +platform, so its unit tests can only run there. On Linux the FFmpeg backend +shells out to `ffmpeg`, which has to be installed and to have an H.264 and an AAC +encoder. This is what CI runs (`.github/workflows/ci-unienc.yml`). + +## iOS simulator + +```bash +scripts/ios-simulator-test.sh +``` + +A Rust test binary is a plain executable, so `simctl` runs one directly — no app +bundle, no provisioning profile, no code signing. The runner that hands the +binary to `simctl` lives in `.cargo/config.toml`, so once a simulator is booted, +plain `cargo test -p unienc_testkit --target aarch64-apple-ios-sim` works too. + +VideoToolbox does encode H.264 in the simulator, so this covers the encoder logic +rather than just compilation. The first run after booting a simulator can take a +minute while the media frameworks load; later runs take a few seconds. + +Most of the Apple backend is shared between iOS and macOS, so the desktop run +already covers it. What the simulator adds is the iOS deployment target and the +iOS variants of the frameworks. + +## Android device or emulator + +```bash +export ANDROID_HOME=~/Library/Android/sdk +export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/" +scripts/android-device-test.sh # add -s to pick a device +``` + +There is no APK and no Gradle project. MediaCodec and MediaMuxer need a `JavaVM` +but neither an `Activity` nor a `Context`, so a JVM is the only thing an `adb` +shell is missing, and `app_process` supplies one. The script builds +`unienc_harness_android` as a shared library, compiles a small Java shim to a +dex, pushes both, and runs them: + +``` +CLASSPATH=…/harness.dex app_process … jp.co.cyberagent.unienc.harness.Harness … +``` + +`System.load` in the shim is what calls `JNI_OnLoad`, which is where the backend +picks up the `JavaVM`. The muxed file is pulled back to +`target/android-harness/e2e.mp4` whether or not the checks passed. + +The same command works against an emulator and a real device, which is the reason +for this shape rather than an instrumented test: reaching for a device when a +result looks suspicious costs nothing extra. + +### A note on track duration + +MediaMuxer derives sample durations from the gaps between presentation +timestamps, and has nothing to derive the last one from, so it gives the final +sample a duration of zero and the video track ends one frame interval short. +AVFoundation and FFmpeg give it a full interval. The harness allows for this; the +frame count is the strict check. + +## Web + +```bash +source /path/to/emsdk/emsdk_env.sh +scripts/web-browser-test.sh # add --release for the release-wasm profile +``` + +This is the one target where the harness is a program rather than a `cargo test`. +The encoders are the browser's own and report through callbacks delivered as +browser tasks, so the only thread has to keep returning to the event loop, while +libtest expects a test function to run to completion. `unienc_harness_web` +therefore drives the pipeline the way Unity does in production: a callback per +animation frame, polling whatever has become ready. +`emscripten_set_main_loop` here plays the part `unienc_tick_runtime` plays there. + +ASYNCIFY looks like an easier answer and is not one. It unwinds the wasm stack +while suspended, and the encoder callbacks re-enter wasm during exactly that +window, which is the reentrancy it does not support. Trying it deadlocks. + +Two more things are specific to this target: + +- **The runtime has no threads.** Emscripten is built here without pthreads, so + `TestRuntime` uses a `LocalPool` rather than a thread pool, matching the + `--no-default-features` build `unienc_c` ships for the web. Blocking work runs + inline because there is nowhere to offload it to. +- **The muxer downloads its output instead of writing a file**, so there would be + nothing for the verification to read. Rather than give the backend a test-only + mode, `unienc_testkit::web::capture_muxed_output` intercepts the one JavaScript + function the muxer calls and writes the same bytes into the in-memory + filesystem. Nothing in `unienc_webcodecs` knows the difference. + +emrun serves the page, forwards its stdout and turns the harness's exit status +into the script's. Chrome's software H.264 encoder (OpenH264) does the encoding on +a machine without hardware support. + +The link flags in `scripts/web-browser-test.sh` are not incidental. The backend's +own JavaScript reaches for `_malloc`, `_free` and the heap views, and it does so +from inside an encoder callback — a browser task, where a `TypeError` is reported +nowhere the harness can see. Omitting those exports does not fail the build or +raise an error: encoded chunks simply never arrive, and the run hangs. The page +errors the runner's HTML forwards to stdout exist for the same reason. + +`cargo` does not treat `EMCC_CFLAGS` as a build input, so a change to the link +flags would leave the previous binary in place and appear to have no effect. The +script records the flags it used and forces a relink when they differ. + +### Known open issue + +The browser run does not pass yet. It gets as far as encoding both streams — ten +video frames in and ten encoded frames out, ten audio chunks accepted — and then +the muxer refuses the first audio frame: + +``` +Failed to write encoded frame: audio frame arrived before any video frame: +write at least one video frame before writing audio +``` + +`muxide`, which the WebCodecs backend muxes with, will not take audio before the +first video frame. The pipeline pushes both streams concurrently, so which +arrives first depends on the encoders, and on the web the audio wins. The other +backends' muxers accept either order, so this is specific to this one. Whether to +hold audio back in `WebCodecsMuxer` until the first video frame lands, or to +relax the constraint in `muxide`, is a decision about the backend rather than +about the harness. + +## Real devices + +Both mobile harnesses run unchanged on real hardware, but neither is wired into +CI: + +- **Android**: connect a device with USB debugging and pass `-s `. +- **iOS**: a real device needs the binary wrapped in a signed app bundle. + `cargo-dinghy` automates that, at the cost of a signing identity. An Xcode test + target linking `libunienc_c.a` is the other option, and the only way to cover + the static library link described above. + +Run these before a release, and whenever a change touches a platform backend's +interaction with the vendor encoder. diff --git a/InstantReplay.Externals/unienc/scripts/android-device-test.sh b/InstantReplay.Externals/unienc/scripts/android-device-test.sh new file mode 100755 index 00000000..5b3fe2f6 --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/android-device-test.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# +# Runs the unienc end-to-end harness on a connected Android device or emulator. +# +# There is no APK and no Gradle project. MediaCodec and MediaMuxer need a JavaVM +# but neither an Activity nor a Context, so a JVM is the only thing a shell is +# missing, and `app_process` supplies one. The same command therefore works +# against an emulator and a real device, which matters because the defects worth +# catching here are the ones specific to a vendor's hardware encoder. +# +# The GPU blit path is not covered: it needs a Unity graphics device. +# +# Usage: +# scripts/android-device-test.sh [--release] [-s ] +# +# Requirements: ANDROID_HOME (or ANDROID_SDK_ROOT), ANDROID_NDK_HOME, cargo-ndk, +# a JDK for javac, and adb build-tools for d8. + +set -euo pipefail + +profile=dev +profile_dir=debug +serial="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --release) + profile=release + profile_dir=release + shift + ;; + -s) + serial="$2" + shift 2 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$here" + +sdk="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}" +if [[ -z "$sdk" ]]; then + echo "set ANDROID_HOME to the Android SDK" >&2 + exit 1 +fi +if [[ -z "${ANDROID_NDK_HOME:-}" ]]; then + # cargo-ndk needs this and gives a less obvious error without it. + echo "set ANDROID_NDK_HOME to an NDK under $sdk/ndk" >&2 + exit 1 +fi + +# Kept as one array including the binary so that an empty serial does not +# expand to an unset element, which bash 3.2 rejects under `set -u`. +adb=("$sdk/platform-tools/adb") +if [[ -n "$serial" ]]; then + adb+=(-s "$serial") +fi +# Any recent build-tools release will do; take the highest installed. +build_tools="$(ls -1 "$sdk/build-tools" | sort -V | tail -1)" +d8="$sdk/build-tools/$build_tools/d8" + +# The library has to match the device, not the host. +abi="$("${adb[@]}" shell getprop ro.product.cpu.abi | tr -d '\r')" +case "$abi" in + arm64-v8a) triple=aarch64-linux-android ;; + armeabi-v7a) triple=armv7-linux-androideabi ;; + x86_64) triple=x86_64-linux-android ;; + *) + echo "unsupported device ABI: $abi" >&2 + exit 1 + ;; +esac + +echo "==> device ABI $abi, building $profile" +# --platform 26 matches the minimum the Java API bindings are checked against. +cargo ndk -t "$abi" --platform 26 build --profile "$profile" -p unienc_harness_android + +library="target/$triple/$profile_dir/libunienc_harness_android.so" +[[ -f "$library" ]] || { + echo "missing $library" >&2 + exit 1 +} + +echo "==> building the JVM shim" +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +java_src=crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java +# The shim only touches java.lang, so it needs no android.jar to compile. +javac --release 11 -d "$staging/classes" "$java_src" +"$d8" --min-api 26 --output "$staging" \ + "$staging/classes/jp/co/cyberagent/unienc/harness/Harness.class" + +remote=/data/local/tmp/unienc-harness +echo "==> pushing to $remote" +"${adb[@]}" shell "rm -rf $remote && mkdir -p $remote" +"${adb[@]}" push "$library" "$remote/libunienc_harness_android.so" >/dev/null +"${adb[@]}" push "$staging/classes.dex" "$remote/harness.dex" >/dev/null + +echo "==> running" +# app_process wants a "parent directory" argument it does not use for anything +# here, and finds the shim through CLASSPATH. +set +e +"${adb[@]}" shell "cd $remote && CLASSPATH=$remote/harness.dex app_process $remote jp.co.cyberagent.unienc.harness.Harness $remote/libunienc_harness_android.so $remote/e2e.mp4; echo EXIT:\$?" \ + | tr -d '\r' | tee "$staging/output" +set -e + +status="$(grep '^EXIT:' "$staging/output" | tail -1 | cut -d: -f2)" + +# Keep the muxed file for inspection whether or not the checks passed. +if "${adb[@]}" shell "test -f $remote/e2e.mp4" 2>/dev/null; then + mkdir -p target/android-harness + "${adb[@]}" pull "$remote/e2e.mp4" target/android-harness/e2e.mp4 >/dev/null + echo "==> pulled target/android-harness/e2e.mp4" +fi + +exit "${status:-1}" diff --git a/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh b/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh new file mode 100755 index 00000000..59eb21af --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# +# Runs the unienc end-to-end harness inside an iOS simulator. +# +# A Rust test binary is a plain executable, so simctl can run one directly: no +# app bundle, no provisioning profile, no code signing. The runner that hands the +# binary to simctl is configured in .cargo/config.toml, so all this script has to +# do is make sure a simulator is booted first. +# +# VideoToolbox does encode H.264 in the simulator, so this covers the encoders' +# own logic. It does not cover the static library link, where the iOS build +# differs from macOS most, nor a real device's hardware encoder. Both still need +# a device and a signing identity. +# +# Usage: +# scripts/ios-simulator-test.sh [] +# +# With no argument, any already booted simulator is used, otherwise the newest +# available iPhone is booted. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$here" + +case "$(uname -m)" in + arm64) target=aarch64-apple-ios-sim ;; + x86_64) target=x86_64-apple-ios ;; + *) + echo "unsupported host architecture: $(uname -m)" >&2 + exit 1 + ;; +esac + +wanted="${1:-}" +if [[ -n "$wanted" ]]; then + device="$wanted" + # Consumed here so that anything after it reaches cargo. + shift +else + device="$(xcrun simctl list devices booted | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()')" + if [[ -z "$device" ]]; then + # Newest runtime last, so the last iPhone listed is the newest. + device="$(xcrun simctl list devices available \ + | grep -E '^\s+iPhone' | tail -1 \ + | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()')" + if [[ -z "$device" ]]; then + echo "no iPhone simulator is available; install one through Xcode" >&2 + exit 1 + fi + fi +fi + +echo "==> booting $device" +# Already booted is not an error worth stopping for. +xcrun simctl boot "$device" 2>/dev/null || true +xcrun simctl bootstatus "$device" -b + +echo "==> testing on $target" +# The first run in a freshly booted simulator can take a minute or so while the +# media frameworks load for the first time. +rustup target add "$target" >/dev/null +exec cargo test -p unienc_testkit --target "$target" "$@" diff --git a/InstantReplay.Externals/unienc/scripts/run-in-browser.sh b/InstantReplay.Externals/unienc/scripts/run-in-browser.sh new file mode 100755 index 00000000..06db4b9b --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/run-in-browser.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Cargo runner for wasm32-unknown-emscripten: runs a test binary in a browser. +# +# The WebCodecs backend drives the browser's own encoders through +# `emscripten_run_script` and touches `window`, so there is no headless +# JavaScript runtime that can stand in for a real browser. emrun serves the page, +# forwards its stdout and reports its exit status, which is what makes a browser +# usable as a cargo test runner at all. +# +# emcc emits a .js module rather than a page, so a minimal HTML shell to load it +# is generated here. `Module.arguments` is how the test binary receives the +# arguments cargo appends, such as a test name filter or --nocapture. +# +# Invoked through .cargo/config.toml; see scripts/web-browser-test.sh for the +# wrapper that sets up the build. + +set -euo pipefail + +module="$1" +shift + +if [[ ! -f "$module" ]]; then + echo "no such module: $module" >&2 + exit 1 +fi + +browser="${UNIENC_TEST_BROWSER:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}" +if [[ ! -e "$browser" ]]; then + # On Linux runners Chrome is on PATH under one of these names. + for candidate in google-chrome chromium chromium-browser; do + if command -v "$candidate" >/dev/null; then + browser="$(command -v "$candidate")" + break + fi + done +fi +if [[ ! -e "$browser" ]]; then + echo "no browser found; set UNIENC_TEST_BROWSER" >&2 + exit 1 +fi + +# Cargo's arguments become the process arguments, as a JSON array for the shell. +arguments="" +for argument in "$@"; do + escaped="${argument//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + arguments+="\"$escaped\"," +done + +page="${module%.js}.html" +cat >"$page" < + +unienc harness + + + + + +HTML + +# --kill-start clears any browser left behind by an interrupted run: it would +# still be showing the previous page and posting its output to this server, which +# is indistinguishable from the current run misbehaving. +# --kill-exit stops the browser once the page calls exit, otherwise emrun waits +# for a window that headless Chrome never shows. The silence timeout is the +# backstop for a page that fails before it can report anything. +# A stale server from an interrupted run would otherwise hold the default port +# and this would fail with nothing but a Python traceback. +exec emrun \ + --port "${UNIENC_TEST_PORT:-6931}" \ + --browser "$browser" \ + --browser-args="--headless=new --no-sandbox --disable-gpu --autoplay-policy=no-user-gesture-required" \ + --kill-start \ + --kill-exit \ + --silence-timeout "${UNIENC_TEST_SILENCE_TIMEOUT:-120}" \ + --timeout "${UNIENC_TEST_TIMEOUT:-300}" \ + "$page" diff --git a/InstantReplay.Externals/unienc/scripts/web-browser-test.sh b/InstantReplay.Externals/unienc/scripts/web-browser-test.sh new file mode 100755 index 00000000..a1653bce --- /dev/null +++ b/InstantReplay.Externals/unienc/scripts/web-browser-test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Runs the unienc end-to-end harness in a browser, on WebAssembly. +# +# There is no headless JavaScript runtime that can stand in for a browser here: +# the WebCodecs backend drives the browser's own encoders and reaches for +# `window`, so the harness is a page. emrun serves it, forwards its stdout and +# turns its exit status into this script's. +# +# The build follows build-unienc.yml: nightly with build-std, because the +# Emscripten target has no prebuilt std, and mvp so the output runs where Unity's +# does. +# +# Requirements: EMSDK (an activated emsdk, i.e. `source emsdk_env.sh`), a nightly +# toolchain with rust-src, and Chrome or Chromium. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$here" + +if ! command -v emcc >/dev/null; then + echo "emcc is not on PATH; source your emsdk's emsdk_env.sh first" >&2 + exit 1 +fi + +profile=dev +profile_dir=debug +if [[ "${1:-}" == "--release" ]]; then + # The wasm profile is where the workspace keeps its size settings. + profile=release-wasm + profile_dir=release-wasm + shift +fi + +# What the backend's own JavaScript reaches for has to be exported, or it fails +# inside a browser task where nothing reports it: +# _malloc, _free copying an encoded chunk out of the browser's encoder +# HEAPU8, HEAPU32 the same copies, and reading the muxer's fragments back +# UTF8ToString reading string arguments +# printErr read by emrun's own injected code; a read of an +# unexported runtime method aborts the page +# The harness itself needs two more: +# FORCE_FILESYSTEM, FS reading the muxed file back out of the in-memory +# filesystem, which a build making no filesystem calls +# would otherwise omit +# EXIT_RUNTIME reporting pass or fail as an exit status +export EMCC_CFLAGS="${EMCC_CFLAGS:-} -sEXIT_RUNTIME=1 -sALLOW_MEMORY_GROWTH=1 -sFORCE_FILESYSTEM=1 -sEXPORTED_FUNCTIONS=_main,_malloc,_free -sEXPORTED_RUNTIME_METHODS=FS,UTF8ToString,HEAPU8,HEAPU32,printErr --emrun" + +# cargo does not treat EMCC_CFLAGS as a build input, so a changed link line would +# otherwise be ignored and the stale binary reused — which is hard to spot, +# because the symptom is the previous flags still being in effect. +flags_stamp="target/.emcc-cflags-$profile_dir" +if [[ ! -f "$flags_stamp" || "$(cat "$flags_stamp")" != "$EMCC_CFLAGS" ]]; then + mkdir -p "$(dirname "$flags_stamp")" + printf '%s' "$EMCC_CFLAGS" >"$flags_stamp" + touch crates/unienc_harness_web/src/main.rs +fi + +echo "==> building for wasm32-unknown-emscripten ($profile)" +rustup target add wasm32-unknown-emscripten >/dev/null +rustup component add rust-src --toolchain nightly >/dev/null +RUSTFLAGS="${RUSTFLAGS:-} -Ctarget-cpu=mvp" \ + cargo +nightly build -Z build-std=panic_abort,std \ + --target wasm32-unknown-emscripten --profile "$profile" \ + -p unienc_harness_web + +module="target/wasm32-unknown-emscripten/$profile_dir/unienc_harness_web.js" +[[ -f "$module" ]] || { + echo "missing $module" >&2 + exit 1 +} + +echo "==> running in a browser" +exec scripts/run-in-browser.sh "$module" "$@" From 5c137bae3e8ac2212c783e312d51ef0f719692f4 Mon Sep 17 00:00:00 2001 From: ruccho Date: Mon, 24 Aug 2026 19:10:15 +0900 Subject: [PATCH 02/10] Run the device and browser harnesses in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three jobs alongside the desktop matrix: an iOS simulator on a macOS runner, an Android emulator through android-emulator-runner, and Chrome on a Linux runner for WebAssembly. None of them covers a vendor's hardware encoder — an emulator and a simulator use software or host encoders, and the frame drops and colour shifts that have needed fixing before are specific to real hardware. What these do cover is that the backends work at all on their own platform, which until now nothing did. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-unienc.yml | 100 ++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/.github/workflows/ci-unienc.yml b/.github/workflows/ci-unienc.yml index ceb4d3ca..c503e65d 100644 --- a/.github/workflows/ci-unienc.yml +++ b/.github/workflows/ci-unienc.yml @@ -81,3 +81,103 @@ jobs: path: InstantReplay.Externals/unienc/target/tmp/*.mp4 if-no-files-found: warn retention-days: 7 + + test-ios-simulator: + name: Test (iOS simulator) + runs-on: macos-15 + timeout-minutes: 45 + env: + RUST_BACKTRACE: 1 + defaults: + run: + working-directory: InstantReplay.Externals/unienc + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: 'recursive' + - run: rustup default stable + # A Rust test binary is a plain executable, so simctl runs it directly and + # no signing identity is needed. VideoToolbox does encode H.264 in the + # simulator, so this covers the encoder logic and not just the build. + - name: Run tests in the simulator + run: scripts/ios-simulator-test.sh + - name: Upload muxed output + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-output-ios-simulator + path: InstantReplay.Externals/unienc/target/aarch64-apple-ios-sim/tmp/*.mp4 + if-no-files-found: warn + retention-days: 7 + + test-android-emulator: + name: Test (Android emulator) + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RUST_BACKTRACE: 1 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: 'recursive' + - run: rustup default stable + - run: rustup target add aarch64-linux-android x86_64-linux-android + - run: cargo install cargo-ndk + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - run: echo "ANDROID_NDK_HOME=$ANDROID_NDK_LATEST_HOME" >> $GITHUB_ENV + # KVM has to be reachable or the emulator falls back to an unusably slow + # software CPU. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + # The emulator's encoder is software (c2.android.avc.encoder), so this + # covers the MediaCodec and MediaMuxer plumbing rather than any vendor's + # hardware encoder. Those still need a real device. + - name: Run the harness on an emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + arch: x86_64 + target: google_apis + script: InstantReplay.Externals/unienc/scripts/android-device-test.sh + - name: Upload muxed output + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-output-android-emulator + path: InstantReplay.Externals/unienc/target/android-harness/*.mp4 + if-no-files-found: warn + retention-days: 7 + + test-web: + name: Test (WebAssembly, browser) + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RUST_BACKTRACE: 1 + defaults: + run: + working-directory: InstantReplay.Externals/unienc + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + submodules: 'recursive' + # build-std needs a nightly toolchain, because the Emscripten target has no + # prebuilt std. This matches build-unienc.yml. + - run: rustup toolchain install nightly --component rust-src + - run: rustup target add wasm32-unknown-emscripten + - uses: mymindstorm/setup-emsdk@v14 + - uses: browser-actions/setup-chrome@v1 + id: chrome + # The WebCodecs backend drives the browser's own encoders, so a real + # browser is the only thing that can run this. Chrome's software H.264 + # encoder (OpenH264) is what does the work on a runner. + - name: Run the harness in a browser + run: scripts/web-browser-test.sh + env: + UNIENC_TEST_BROWSER: ${{ steps.chrome.outputs.chrome-path }} From 56260dd37de5d84f80bac9b0b37abdfdf9b21e6d Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 11:12:26 +0900 Subject: [PATCH 03/10] Record that the browser harness now passes The muxer no longer rejects audio that arrives before the first video frame, so the open issue the document described is gone. Co-Authored-By: Claude Opus 5 --- .../unienc/docs/device-testing.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/InstantReplay.Externals/unienc/docs/device-testing.md b/InstantReplay.Externals/unienc/docs/device-testing.md index 010f0591..09a2da12 100644 --- a/InstantReplay.Externals/unienc/docs/device-testing.md +++ b/InstantReplay.Externals/unienc/docs/device-testing.md @@ -134,25 +134,6 @@ errors the runner's HTML forwards to stdout exist for the same reason. flags would leave the previous binary in place and appear to have no effect. The script records the flags it used and forces a relink when they differ. -### Known open issue - -The browser run does not pass yet. It gets as far as encoding both streams — ten -video frames in and ten encoded frames out, ten audio chunks accepted — and then -the muxer refuses the first audio frame: - -``` -Failed to write encoded frame: audio frame arrived before any video frame: -write at least one video frame before writing audio -``` - -`muxide`, which the WebCodecs backend muxes with, will not take audio before the -first video frame. The pipeline pushes both streams concurrently, so which -arrives first depends on the encoders, and on the web the audio wins. The other -backends' muxers accept either order, so this is specific to this one. Whether to -hold audio back in `WebCodecsMuxer` until the first video frame lands, or to -relax the constraint in `muxide`, is a decision about the backend rather than -about the harness. - ## Real devices Both mobile harnesses run unchanged on real hardware, but neither is wired into From f3c91efb0ab87062585666ed8ab5c5a01ca9ab9b Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 11:19:01 +0900 Subject: [PATCH 04/10] Fix the simulator lookup when none is already booted The script fell over silently with no simulator running: BSD grep does not take \s in a pattern, so the search for an available iPhone matched nothing, and under `set -euo pipefail` the failing pipeline ended the script before the message explaining what was missing could run. That is the path CI takes every time, and the only path a machine with a simulator already open never exercises. Spelled out as [[:space:]], with the no-match case allowed through so the explanation is what the reader gets. Verified from a cold start: the script booted a simulator, waited out its first boot, and ran the tests. Co-Authored-By: Claude Opus 5 --- .../unienc/scripts/ios-simulator-test.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh b/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh index 59eb21af..158ec0b2 100755 --- a/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh +++ b/InstantReplay.Externals/unienc/scripts/ios-simulator-test.sh @@ -38,12 +38,15 @@ if [[ -n "$wanted" ]]; then # Consumed here so that anything after it reaches cargo. shift else - device="$(xcrun simctl list devices booted | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()')" + device="$(xcrun simctl list devices booted | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()' || true)" if [[ -z "$device" ]]; then - # Newest runtime last, so the last iPhone listed is the newest. + # Newest runtime last, so the last iPhone listed is the newest. The + # character class is spelled out because BSD grep does not take \s, and + # `|| true` keeps a no-match from ending the script through `set -e` + # before the message below can explain what is missing. device="$(xcrun simctl list devices available \ - | grep -E '^\s+iPhone' | tail -1 \ - | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()')" + | grep -E '^[[:space:]]+iPhone' | tail -1 \ + | grep -oE '\(([0-9A-F-]{36})\)' | head -1 | tr -d '()' || true)" if [[ -z "$device" ]]; then echo "no iPhone simulator is available; install one through Xcode" >&2 exit 1 From a257a7532678e3d35d266241ac6b547af26ebd8d Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 14:58:10 +0900 Subject: [PATCH 05/10] Make an Android hang on a device diagnosable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The emulator job in CI sat for 45 minutes after printing "running" and produced nothing at all — not even the JNI_OnLoad line that appears immediately on a local emulator. There was no way to tell what it was doing, because a hang left no trace and the job's own timeout was the only thing that ended it. Three changes, each aimed at a question that could not be answered: - The harness now runs under the device's own `timeout`, not a host-side one, because what hangs is the process on the device. A hang now ends in five minutes and says so, rather than holding the job until its overall limit. - The device log is dumped on failure. A native crash, a missing library or an ART complaint appears there and nowhere else; the harness's own output stops at whatever it managed to print. The log is cleared first so the dump covers only this run. - The Java shim reports either side of `System.load`. Producing no output at all has three quite different causes — app_process never reaching main, the load hanging, or the native harness hanging — and these two lines separate them. The hang itself is unexplained. It does not reproduce on arm64 against API 36, or against API 34 with the google_apis image and the same emulator options CI uses; both pass. What is left is the x86_64 ABI and the CI environment. Co-Authored-By: Claude Opus 5 --- .../co/cyberagent/unienc/harness/Harness.java | 6 ++++ .../unienc/scripts/android-device-test.sh | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java b/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java index 03205ad3..7b83fe5a 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java +++ b/InstantReplay.Externals/unienc/crates/unienc_harness_android/java/jp/co/cyberagent/unienc/harness/Harness.java @@ -21,7 +21,13 @@ public static void main(String[] args) { System.exit(2); } + // These two lines separate the ways this can produce no output at all: + // app_process never reaching main, System.load hanging or failing, and + // the native harness itself hanging. + System.out.println("harness: loading " + args[0]); System.load(args[0]); + System.out.println("harness: loaded, starting"); + System.exit(run(args[1])); } } diff --git a/InstantReplay.Externals/unienc/scripts/android-device-test.sh b/InstantReplay.Externals/unienc/scripts/android-device-test.sh index 5b3fe2f6..84982a86 100755 --- a/InstantReplay.Externals/unienc/scripts/android-device-test.sh +++ b/InstantReplay.Externals/unienc/scripts/android-device-test.sh @@ -102,15 +102,30 @@ echo "==> pushing to $remote" "${adb[@]}" push "$staging/classes.dex" "$remote/harness.dex" >/dev/null echo "==> running" +# Start from an empty log so the dump below only holds this run, and so a native +# crash or an ANR is attributable. +"${adb[@]}" logcat -c 2>/dev/null || true + +# The harness runs under the device's own `timeout` rather than a host-side one, +# because what hangs is the process on the device. Without this a hang holds the +# job until its overall timeout and leaves nothing to look at. TERM first, then +# KILL, so a process ignoring TERM still goes. +# # app_process wants a "parent directory" argument it does not use for anything # here, and finds the shim through CLASSPATH. set +e -"${adb[@]}" shell "cd $remote && CLASSPATH=$remote/harness.dex app_process $remote jp.co.cyberagent.unienc.harness.Harness $remote/libunienc_harness_android.so $remote/e2e.mp4; echo EXIT:\$?" \ +"${adb[@]}" shell "cd $remote && timeout -s KILL ${UNIENC_TEST_TIMEOUT:-300} env CLASSPATH=$remote/harness.dex app_process $remote jp.co.cyberagent.unienc.harness.Harness $remote/libunienc_harness_android.so $remote/e2e.mp4; echo EXIT:\$?" \ | tr -d '\r' | tee "$staging/output" set -e status="$(grep '^EXIT:' "$staging/output" | tail -1 | cut -d: -f2)" +# `timeout` reports 137 for a KILL. Say so plainly: the distinction between a +# hang and a failed check matters more than the exit code. +if [[ "${status:-1}" == "137" ]]; then + echo "==> the harness did not finish within ${UNIENC_TEST_TIMEOUT:-300}s and was killed" >&2 +fi + # Keep the muxed file for inspection whether or not the checks passed. if "${adb[@]}" shell "test -f $remote/e2e.mp4" 2>/dev/null; then mkdir -p target/android-harness @@ -118,4 +133,16 @@ if "${adb[@]}" shell "test -f $remote/e2e.mp4" 2>/dev/null; then echo "==> pulled target/android-harness/e2e.mp4" fi +# On failure the device log is the only place a native crash, a missing library +# or an ART complaint shows up; the harness's own output stops at whatever it +# managed to print. +if [[ "${status:-1}" != "0" ]]; then + mkdir -p target/android-harness + "${adb[@]}" logcat -d > target/android-harness/logcat.txt 2>/dev/null || true + echo "==> device log saved to target/android-harness/logcat.txt" >&2 + echo "--- last 40 lines mentioning the harness ---" >&2 + grep -aiE "unienc|harness|app_process|DEBUG|AndroidRuntime|dalvik|art :" \ + target/android-harness/logcat.txt | tail -40 >&2 || true +fi + exit "${status:-1}" From ff4ea3310068cb40accda97ca5a75328b1549566 Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 15:22:38 +0900 Subject: [PATCH 06/10] Report progress in a way a hung device run still shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android job's first diagnostic run answered one question and raised another. It got much further than the silence suggested — through System.load, JNI_OnLoad and the creation of the video encoder — and only then stopped. But every line of that arrived at once, when the process was killed: stdout is block-buffered behind a pipe, which is what `adb shell` gives it, so nothing appears until the process exits and a hung one never does. So progress is now flushed as it is written, and each stage reports its first item as well as its total. A run that stops before the encoder accepts a frame has a different cause from one that stops part-way, and stage totals alone cannot tell those apart. Two things also stopped the evidence reaching anyone: the CI artifact collected `*.mp4`, which is exactly the file a hang does not produce, and the log filter was broad enough that Play services drowned it. The artifact now takes the directory, and the filter names the codec and process machinery. Also reads the H.264 profile out of `avcC` and reports it. What an encoder produces is not always what it was asked for — on the web it is negotiated with the browser at run time — so the summary should describe the file rather than the request. All three desktop backends turn out to produce High 3.1. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-unienc.yml | 2 +- .../unienc/crates/unienc_testkit/src/e2e.rs | 26 ++++++++--- .../unienc/crates/unienc_testkit/src/lib.rs | 1 + .../unienc/crates/unienc_testkit/src/mp4.rs | 45 ++++++++++++++++++- .../crates/unienc_testkit/src/progress.rs | 24 ++++++++++ .../crates/unienc_testkit/src/verify.rs | 7 ++- .../unienc/scripts/android-device-test.sh | 7 ++- 7 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 InstantReplay.Externals/unienc/crates/unienc_testkit/src/progress.rs diff --git a/.github/workflows/ci-unienc.yml b/.github/workflows/ci-unienc.yml index c503e65d..f21e0ce4 100644 --- a/.github/workflows/ci-unienc.yml +++ b/.github/workflows/ci-unienc.yml @@ -150,7 +150,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: e2e-output-android-emulator - path: InstantReplay.Externals/unienc/target/android-harness/*.mp4 + path: InstantReplay.Externals/unienc/target/android-harness/ if-no-files-found: warn retention-days: 7 diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs index e79c0d02..51692696 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/e2e.rs @@ -152,8 +152,12 @@ where timestamp: index as f64 / config.fps as f64 + config.timestamp_offset, }) .await?; + + if index == 0 { + crate::progress!("harness: the video encoder accepted the first frame"); + } } - println!("harness: pushed {} video frames", config.video_frames()); + crate::progress!("harness: pushed {} video frames", config.video_frames()); Ok(config.video_frames()) }); @@ -165,8 +169,12 @@ where timestamp_in_samples: second * config.sample_rate as u64, }) .await?; + + if second == 0 { + crate::progress!("harness: the audio encoder accepted the first chunk"); + } } - println!("harness: pushed {} audio chunks", config.duration_secs); + crate::progress!("harness: pushed {} audio chunks", config.duration_secs); Ok(config.duration_secs) }); @@ -180,9 +188,13 @@ where data.set_timestamp(data.timestamp() - offset); mux_video.push(data).await?; pulled += 1; + + if pulled == 1 { + crate::progress!("harness: the video encoder produced its first output"); + } } mux_video.finish().await?; - println!("harness: transferred {pulled} encoded video items"); + crate::progress!("harness: transferred {pulled} encoded video items"); Ok(pulled) }); @@ -192,9 +204,13 @@ where let data = reencode(data)?; mux_audio.push(data).await?; pulled += 1; + + if pulled == 1 { + crate::progress!("harness: the audio encoder produced its first output"); + } } mux_audio.finish().await?; - println!("harness: transferred {pulled} encoded audio items"); + crate::progress!("harness: transferred {pulled} encoded audio items"); Ok(pulled) }); @@ -206,7 +222,7 @@ where let video_data_pulled = join(transfer_video).await?; let audio_data_pulled = join(transfer_audio).await?; - println!("harness: waiting for the muxer"); + crate::progress!("harness: waiting for the muxer"); completion.finish().await?; Ok(E2eReport { diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs index 4702757e..d085968c 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/lib.rs @@ -19,6 +19,7 @@ pub mod e2e; pub mod mp4; pub mod options; pub mod pattern; +pub mod progress; pub mod runtime; pub mod verify; #[cfg(target_os = "emscripten")] diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs index dd950810..356b3085 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/mp4.rs @@ -64,6 +64,12 @@ pub struct Track { /// True when the sample entry carries a decoder configuration, i.e. an /// `avcC` or `esds` box. A track without one does not play back. pub has_decoder_config: bool, + /// H.264 profile and level, read from `avcC`. + /// + /// Which profile an encoder produces is not always what was asked for — on + /// the web it is negotiated with the browser at run time — so this reports + /// what the file actually contains. + pub avc_profile: Option, pub timescale: u32, /// Track duration in seconds, from `mdhd`. /// @@ -171,6 +177,7 @@ fn parse_track(trak: &[u8], movie_timescale: u32) -> Result { kind, format: entry.format, has_decoder_config: entry.has_decoder_config, + avc_profile: entry.avc_profile, timescale, duration: duration as f64 / timescale.max(1) as f64, width: entry.width, @@ -225,10 +232,32 @@ fn parse_leading_empty_edits(elst: &[u8]) -> Result { Ok(delay) } +/// The profile and level an H.264 track was encoded at. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AvcProfile { + pub profile: u8, + pub level: u8, +} + +impl fmt::Display for AvcProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self.profile { + 0x42 => "Baseline", + 0x4d => "Main", + 0x58 => "Extended", + 0x64 => "High", + _ => "unknown", + }; + // Levels are tenths, so 0x1f is 3.1. + write!(f, "{name} {}.{}", self.level / 10, self.level % 10) + } +} + #[derive(Default)] struct SampleEntry { format: String, has_decoder_config: bool, + avc_profile: Option, width: u32, height: u32, sample_rate: u32, @@ -279,8 +308,20 @@ impl SampleEntry { }; if let Some(children) = entry.get(children_at..) { - parsed.has_decoder_config = iter_boxes(children) - .any(|(box_type, _)| &box_type == b"avcC" || &box_type == b"esds"); + for (box_type, body) in iter_boxes(children) { + match &box_type { + b"avcC" => { + parsed.has_decoder_config = true; + // configurationVersion, AVCProfileIndication, + // profile_compatibility, AVCLevelIndication. + if let (Some(&profile), Some(&level)) = (body.get(1), body.get(3)) { + parsed.avc_profile = Some(AvcProfile { profile, level }); + } + } + b"esds" => parsed.has_decoder_config = true, + _ => {} + } + } } Ok(parsed) diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/progress.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/progress.rs new file mode 100644 index 00000000..6dfdde25 --- /dev/null +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/progress.rs @@ -0,0 +1,24 @@ +//! Progress reporting that survives a hang. +//! +//! Stdout is block-buffered whenever it is not a terminal, and a device harness +//! runs behind a pipe: its output reaches `adb shell` only when the buffer fills +//! or the process exits. A harness that hangs never exits, so without flushing +//! every line, the run that most needs a trace produces none at all — which is +//! exactly what the first Android CI failure looked like. + +use std::io::Write; + +/// Prints one progress line and flushes it. +pub fn report(args: std::fmt::Arguments<'_>) { + let mut out = std::io::stdout().lock(); + let _ = writeln!(out, "{args}"); + let _ = out.flush(); +} + +/// Reports progress in a way a hung run still shows. See [`report`]. +#[macro_export] +macro_rules! progress { + ($($arg:tt)*) => { + $crate::progress::report(std::format_args!($($arg)*)) + }; +} diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs index 954f346b..d83fe7f0 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/verify.rs @@ -299,14 +299,17 @@ pub fn describe(summary: &Mp4Summary) -> String { for track in &summary.tracks { out.push_str(&match track.kind { TrackKind::Video => format!( - " video: {} {}x{}, {} frames, start {:.3} s, {:.3} s, decoder config: {}\n", + " video: {} {}x{} ({}), {} frames, start {:.3} s, {:.3} s\n", track.format, track.width, track.height, + match track.avc_profile { + Some(profile) => profile.to_string(), + None => "no decoder config".to_string(), + }, track.sample_count, track.start_time, track.duration, - track.has_decoder_config ), TrackKind::Audio => format!( " audio: {} {} Hz {} ch, {} frames, start {:.3} s, {:.3} s, decoder config: {}\n", diff --git a/InstantReplay.Externals/unienc/scripts/android-device-test.sh b/InstantReplay.Externals/unienc/scripts/android-device-test.sh index 84982a86..6854e510 100755 --- a/InstantReplay.Externals/unienc/scripts/android-device-test.sh +++ b/InstantReplay.Externals/unienc/scripts/android-device-test.sh @@ -140,8 +140,11 @@ if [[ "${status:-1}" != "0" ]]; then mkdir -p target/android-harness "${adb[@]}" logcat -d > target/android-harness/logcat.txt 2>/dev/null || true echo "==> device log saved to target/android-harness/logcat.txt" >&2 - echo "--- last 40 lines mentioning the harness ---" >&2 - grep -aiE "unienc|harness|app_process|DEBUG|AndroidRuntime|dalvik|art :" \ + # Narrow to the codec and process machinery. A `google_apis` image runs Play + # services, whose logging drowns out everything else, and the harness's own + # output goes to stdout rather than here. + echo "--- last 40 relevant lines ---" >&2 + grep -aiE "unienc|harness|app_process|AndroidRuntime|DEBUG *:|CCodec|Codec2|MediaCodec|OMX|c2\.android|ACodec|BufferQueue" \ target/android-harness/logcat.txt | tail -40 >&2 || true fi From ec57e4ee18b2a337c5b84e5af8a1f69f2fa7b73a Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 15:50:43 +0900 Subject: [PATCH 07/10] Let the harness narrow the executor to a given number of workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android emulator job hangs in CI and passes on a development machine, on the same emulator image and the same code. `UNIENC_TEST_THREADS` narrows the pool that `TestRuntime` builds, which is what made that difference reproducible: with two workers the run hangs after the encoder accepts its first frame, exactly as it does in CI, and with three or more it passes. A CI runner has two cores and a development machine has many, and the pool is sized to the machine. The cause is not yet understood — see HANDOFF.md — but the harness can now produce the failure on demand, which it could not before. Co-Authored-By: Claude Opus 5 --- .../unienc/crates/unienc_testkit/src/runtime.rs | 15 ++++++++++++++- .../unienc/scripts/android-device-test.sh | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs index af4774e8..29163383 100644 --- a/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs +++ b/InstantReplay.Externals/unienc/crates/unienc_testkit/src/runtime.rs @@ -28,8 +28,21 @@ impl TestRuntime { /// Builds a runtime backed by a thread pool. #[cfg(not(target_os = "emscripten"))] pub fn new() -> Self { + // The pool is as wide as the machine by default, as it is in production. + // `UNIENC_TEST_THREADS` narrows it, because a backend that blocks a + // worker while waiting on the encoder behaves differently when there are + // fewer workers than concurrent pipeline stages — and a build host + // usually has far more cores than a CI runner. + let mut builder = futures::executor::ThreadPoolBuilder::new(); + if let Ok(threads) = std::env::var("UNIENC_TEST_THREADS") { + let threads: usize = threads + .parse() + .expect("UNIENC_TEST_THREADS is not a number"); + builder.pool_size(threads.max(1)); + } + Self { - pool: futures::executor::ThreadPool::new().expect("Failed to build thread pool"), + pool: builder.create().expect("Failed to build thread pool"), } } diff --git a/InstantReplay.Externals/unienc/scripts/android-device-test.sh b/InstantReplay.Externals/unienc/scripts/android-device-test.sh index 6854e510..181b19a9 100755 --- a/InstantReplay.Externals/unienc/scripts/android-device-test.sh +++ b/InstantReplay.Externals/unienc/scripts/android-device-test.sh @@ -114,7 +114,7 @@ echo "==> running" # app_process wants a "parent directory" argument it does not use for anything # here, and finds the shim through CLASSPATH. set +e -"${adb[@]}" shell "cd $remote && timeout -s KILL ${UNIENC_TEST_TIMEOUT:-300} env CLASSPATH=$remote/harness.dex app_process $remote jp.co.cyberagent.unienc.harness.Harness $remote/libunienc_harness_android.so $remote/e2e.mp4; echo EXIT:\$?" \ +"${adb[@]}" shell "cd $remote && timeout -s KILL ${UNIENC_TEST_TIMEOUT:-300} env CLASSPATH=$remote/harness.dex ${UNIENC_TEST_THREADS:+UNIENC_TEST_THREADS=$UNIENC_TEST_THREADS} app_process $remote jp.co.cyberagent.unienc.harness.Harness $remote/libunienc_harness_android.so $remote/e2e.mp4; echo EXIT:\$?" \ | tr -d '\r' | tee "$staging/output" set -e From c10b086d225f2089648489083bae6b1a128ebaef Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 16:46:20 +0900 Subject: [PATCH 08/10] Make the browser job cover what it was meant to cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The job ran on Linux against whatever `browser-actions/setup-chrome` installed by default, and neither of those choices worked. `setup-chrome` defaults to `latest`, which is a Chromium snapshot rather than a Google Chrome build, and Chromium ships without the proprietary codecs. Every H.264 profile the harness offered was refused, so the job could not encode anything at all: No supported H.264 profile for 1280x720 at 1000000 bps; tried avc1.640028, avc1.4d0028, avc1.42001f Linux could not have covered the audio either. Chrome reaches WebCodecs' AAC encoder through `MojoAudioEncoder`, whose `IsSupported` is gated on the `media::kPlatformAudioEncoder` feature, and `media/base/media_switches.cc` enables that by default only on Windows, macOS and Android — the platforms with an OS-level AAC encoder behind it. On Linux the harness's audio configuration is refused outright, so the path #178 fixed would have gone untested. Run the job on macOS against the Chrome the runner image already carries, which is where `run-in-browser.sh` looks by default. That covers video and audio and needs no browser installed. TypeScript is installed explicitly because `unienc_webcodecs`'s build script shells out to `tsc`: the Linux image ships it globally, so the job worked there by accident, and the macOS image does not. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-unienc.yml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-unienc.yml b/.github/workflows/ci-unienc.yml index f21e0ce4..223dbe5f 100644 --- a/.github/workflows/ci-unienc.yml +++ b/.github/workflows/ci-unienc.yml @@ -156,7 +156,15 @@ jobs: test-web: name: Test (WebAssembly, browser) - runs-on: ubuntu-latest + # macOS rather than Linux because of the audio. Chrome reaches WebCodecs' + # AAC encoder through `MojoAudioEncoder`, which is gated on the + # `media::kPlatformAudioEncoder` feature; `media/base/media_switches.cc` + # enables that by default only on Windows, macOS and Android, because those + # are the platforms with an OS-level AAC encoder behind it. On Linux the + # harness's audio configuration is refused outright, so the backend's audio + # path — the one that had never worked before #178 — could not be covered + # there at all. + runs-on: macos-15 timeout-minutes: 45 env: RUST_BACKTRACE: 1 @@ -171,13 +179,16 @@ jobs: # prebuilt std. This matches build-unienc.yml. - run: rustup toolchain install nightly --component rust-src - run: rustup target add wasm32-unknown-emscripten + # `unienc_webcodecs`'s build script shells out to `tsc`. The Linux runner + # image happens to ship TypeScript globally and the macOS one does not, so + # install it rather than depend on what an image includes. + - run: npm install -g typescript - uses: mymindstorm/setup-emsdk@v14 - - uses: browser-actions/setup-chrome@v1 - id: chrome # The WebCodecs backend drives the browser's own encoders, so a real - # browser is the only thing that can run this. Chrome's software H.264 - # encoder (OpenH264) is what does the work on a runner. + # browser is the only thing that can run this, and it has to be Google + # Chrome rather than Chromium: Chromium ships without the proprietary + # codecs and refuses every H.264 profile the harness offers. The runner + # image already carries Chrome at the path `run-in-browser.sh` defaults + # to, so nothing needs installing or pointing at. - name: Run the harness in a browser run: scripts/web-browser-test.sh - env: - UNIENC_TEST_BROWSER: ${{ steps.chrome.outputs.chrome-path }} From 99004eed181e6aa33ef1753f808d7c9d8ae55acd Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 17:05:22 +0900 Subject: [PATCH 09/10] Stop running the whole matrix twice for every push The workflow triggers on both `pull_request` and an unrestricted `push`, so a branch with an open pull request runs every job twice for the same commit. That cost nothing when the workflow was a single `cargo fmt --check`, and it is easy to miss because both runs report the same names. With the jobs this branch adds it duplicates a simulator boot, an emulator boot and a second macOS runner. Restrict `push` to the trunk. A branch is covered by `pull_request`, and `main` is not the head of a pull request, so nothing loses coverage. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-unienc.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci-unienc.yml b/.github/workflows/ci-unienc.yml index 223dbe5f..79b56f69 100644 --- a/.github/workflows/ci-unienc.yml +++ b/.github/workflows/ci-unienc.yml @@ -5,7 +5,13 @@ on: paths: - '.github/workflows/ci-unienc.yml' - 'InstantReplay.Externals/unienc/**' + # Only the trunk, because a branch with an open pull request is already + # covered by the `pull_request` trigger and an unrestricted `push` runs the + # whole matrix a second time for the same commit. That cost nothing when this + # workflow was one `cargo fmt --check`; it is a duplicate of every job below. push: + branches: + - main paths: - '.github/workflows/ci-unienc.yml' - 'InstantReplay.Externals/unienc/**' From 7394524d64bbbe96075c40606ddedf34ba8888d5 Mon Sep 17 00:00:00 2001 From: ruccho Date: Tue, 25 Aug 2026 17:19:53 +0900 Subject: [PATCH 10/10] Cancel a branch's run when a new push supersedes it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These jobs are long — a simulator boot, an emulator boot and two macOS runners — so letting a superseded run finish holds runners for a result nobody will read. Runs on the trunk are exempt. Each of those is the record for one merged commit, and cancelling one loses the only result that commit ever gets; a branch's superseded run has a replacement on the way by definition. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci-unienc.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci-unienc.yml b/.github/workflows/ci-unienc.yml index 79b56f69..09aef5b6 100644 --- a/.github/workflows/ci-unienc.yml +++ b/.github/workflows/ci-unienc.yml @@ -16,6 +16,15 @@ on: - '.github/workflows/ci-unienc.yml' - 'InstantReplay.Externals/unienc/**' +# A new push to a branch makes the run already in flight for it pointless, and +# these jobs are long enough — a simulator boot, an emulator boot, two macOS +# runners — that leaving it to finish holds runners for a result nobody will +# read. Runs on the trunk are left alone: each of those is the record for one +# merged commit, so cancelling one loses the only result that commit ever gets. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + jobs: fmt: name: Format