From fdf91ceb19860c09364415c1bd7b6705c00a8137 Mon Sep 17 00:00:00 2001 From: KhooLy <73142442+KhooLy@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:46:52 +0300 Subject: [PATCH] perf(headless-engine): replace clone-and-diff dispatch with dirty tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the robustness plan's §9 (flagged as the single highest-leverage perf change in the crate): headless_engine_dispatch_json used to clone the entire EngineState before the reducer ran, clone it again after, then StatePatch::diff did a deep PartialEq walk across all 16 domains and cloned each changed one a third time — cost scaled with everything the user has ever loaded (full catalogs, episode lists), not with what the action actually touched. Introduces Tracked (src/headless_engine/state.rs): wraps each EngineState domain field so any mutable access — a field write, a whole-value replacement via DerefMut, a method call taking &mut — flips a dirty bit automatically. Serialize/Deserialize delegate straight to the wrapped value, so the wire format is byte-for-byte unchanged (verified by the golden wire fixtures added in the prior commit, which pass unmodified). EngineState::diff_dirty() replaces StatePatch::diff(before, after): it clones only domains whose dirty bit is set and clears it, so both the pre-dispatch full clone and the post-dispatch PartialEq walk are gone entirely. The ~16 call sites that did whole-domain replacement (`engine.state.detail = DetailState { ...}`) needed `*engine.state.detail = ...` instead — the compiler catches any site the migration missed as a type error, so there's no way for this rewrite to silently skip a domain the way a hand-added mark_dirty() call could. Also fixes headless_engine_snapshot_json, which used to serialize while still holding the engines mutex; it now clones the state and drops the lock before calling serde_json::to_string. Adds benches/ (criterion, `--features bench` dev-only surface via a new bench_targets re-export module mirroring the existing fuzz_targets pattern): headless_engine_dispatch benches a NavigationRequested dispatch against a 500-stream detail state (a domain the action doesn't touch, so the win is directly visible), and stream_ranking benches player_source_sidebar_plan_json grouping 500 streams. CI gets a bench-check job compiling and smoke-running both (`--test`) without paying for full timing runs on every push. --- .github/workflows/ci.yml | 8 ++ Cargo.lock | 139 +++++++++++++++++++++++++++- Cargo.toml | 14 +++ benches/headless_engine_dispatch.rs | 46 +++++++++ benches/stream_ranking.rs | 34 +++++++ src/headless_engine/auth.rs | 6 +- src/headless_engine/calendar.rs | 2 +- src/headless_engine/contracts.rs | 25 ----- src/headless_engine/detail.rs | 6 +- src/headless_engine/discover.rs | 2 +- src/headless_engine/home.rs | 2 +- src/headless_engine/mod.rs | 33 +++---- src/headless_engine/navigation.rs | 2 +- src/headless_engine/player.rs | 6 +- src/headless_engine/search.rs | 2 +- src/headless_engine/state.rs | 122 ++++++++++++++++++++---- src/headless_engine/sync.rs | 4 +- src/lib.rs | 14 +++ src/player_policy.rs | 2 +- 19 files changed, 387 insertions(+), 82 deletions(-) create mode 100644 benches/headless_engine_dispatch.rs create mode 100644 benches/stream_ranking.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ee8428..672e546 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,14 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo clippy --workspace --lib + bench-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo bench --features bench -- --test + wasm-check: runs-on: ubuntu-latest steps: diff --git a/Cargo.lock b/Cargo.lock index d7ba350..0ab0d12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstyle" version = "1.0.13" @@ -395,6 +401,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.63" @@ -439,6 +451,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.5.60" @@ -563,12 +602,52 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "darling" version = "0.23.0" @@ -740,6 +819,7 @@ version = "0.1.0" dependencies = [ "base64", "chrono", + "criterion", "dolby_vision", "jni 0.21.1", "regex", @@ -993,6 +1073,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1037,6 +1128,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1334,6 +1431,26 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1500,7 +1617,7 @@ dependencies = [ "hex", "http", "intervaltree", - "itertools", + "itertools 0.14.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1579,7 +1696,7 @@ dependencies = [ "data-encoding", "directories", "hex", - "itertools", + "itertools 0.14.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1632,7 +1749,7 @@ dependencies = [ "bitvec", "byteorder", "bytes", - "itertools", + "itertools 0.14.0", "librqbit-bencode", "librqbit-buffers", "librqbit-clone-to-owned", @@ -1893,6 +2010,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2928,6 +3051,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" diff --git a/Cargo.toml b/Cargo.toml index 0a92d71..3c9f967 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,7 @@ uniffi-bindings = ["dep:uniffi", "full-api"] uniffi-cli = ["uniffi-bindings", "uniffi/cli"] wasm = ["full-api", "dep:wasm-bindgen", "chrono/wasmbind"] fuzzing = [] +bench = [] [dependencies] base64 = "0.22" @@ -69,3 +70,16 @@ serde_json = { workspace = true } uniffi = { version = "0.31.1", optional = true } wasm-bindgen = { version = "0.2", optional = true } web-time = "1" + +[dev-dependencies] +criterion = { version = "0.5", default-features = false } + +[[bench]] +name = "headless_engine_dispatch" +harness = false +required-features = ["bench"] + +[[bench]] +name = "stream_ranking" +harness = false +required-features = ["bench"] diff --git a/benches/headless_engine_dispatch.rs b/benches/headless_engine_dispatch.rs new file mode 100644 index 0000000..7851fb4 --- /dev/null +++ b/benches/headless_engine_dispatch.rs @@ -0,0 +1,46 @@ +use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion}; +use fluxa_core::bench_targets::{ + create_headless_engine, destroy_headless_engine, headless_engine_dispatch_json, +}; +use serde_json::json; + +fn large_initial_state() -> String { + let streams: Vec<_> = (0..500) + .map(|i| { + json!({ + "id": i, + "name": format!("Stream {i}"), + "url": format!("https://cdn.example/{i}.mkv"), + }) + }) + .collect(); + json!({ + "detail": { + "id": "tt1", + "contentType": "movie", + "streams": streams, + } + }) + .to_string() +} + +fn navigation_dispatch_on_large_state(c: &mut Criterion) { + let initial = large_initial_state(); + c.bench_function("navigation_dispatch_on_large_state", |b| { + b.iter_batched( + || create_headless_engine(&initial), + |handle| { + let result = headless_engine_dispatch_json( + handle, + r#"{"type":"navigationRequested","route":"home","params":null}"#, + ); + destroy_headless_engine(handle); + black_box(result) + }, + BatchSize::SmallInput, + ) + }); +} + +criterion_group!(benches, navigation_dispatch_on_large_state); +criterion_main!(benches); diff --git a/benches/stream_ranking.rs b/benches/stream_ranking.rs new file mode 100644 index 0000000..844267d --- /dev/null +++ b/benches/stream_ranking.rs @@ -0,0 +1,34 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use fluxa_core::bench_targets::player_source_sidebar_plan_json; +use serde_json::json; + +fn request_with_streams(count: usize) -> String { + let addons = ["Torrentio", "Cinemeta Addon", "Public Domain", "USA TV"]; + let streams: Vec<_> = (0..count) + .map(|i| { + json!({ + "addonName": addons[i % addons.len()], + "title": format!("Stream {i}"), + "name": format!("Source {i}"), + "quality": "1080p", + }) + }) + .collect(); + json!({ + "streams": streams, + "currentStreamIndex": 0, + "availableAddons": addons, + "selectedAddon": Option::::None, + }) + .to_string() +} + +fn source_sidebar_plan_500_streams(c: &mut Criterion) { + let request = request_with_streams(500); + c.bench_function("source_sidebar_plan_500_streams", |b| { + b.iter(|| black_box(player_source_sidebar_plan_json(black_box(&request)))) + }); +} + +criterion_group!(benches, source_sidebar_plan_500_streams); +criterion_main!(benches); diff --git a/src/headless_engine/auth.rs b/src/headless_engine/auth.rs index 19a39db..52cd340 100644 --- a/src/headless_engine/auth.rs +++ b/src/headless_engine/auth.rs @@ -46,7 +46,7 @@ pub(super) fn dispatch_flow( mode: String, ) -> Vec { let generation = engine.bump_generation(GenerationKey::Auth); - engine.state.auth = AuthState { + *engine.state.auth = AuthState { provider: provider.clone(), mode: mode.clone(), is_loading: true, @@ -69,7 +69,7 @@ pub(super) fn dispatch_exchange( profile: Option, ) -> Vec { let generation = engine.bump_generation(GenerationKey::Auth); - engine.state.auth = AuthState { + *engine.state.auth = AuthState { provider: provider.clone(), mode: "exchange".to_string(), is_loading: true, @@ -95,7 +95,7 @@ pub(super) fn dispatch_token_refresh( profile: Value, ) -> Vec { let generation = engine.bump_generation(GenerationKey::Auth); - engine.state.auth = AuthState { + *engine.state.auth = AuthState { provider: provider.clone(), mode: "refresh".to_string(), is_loading: true, diff --git a/src/headless_engine/calendar.rs b/src/headless_engine/calendar.rs index 7a5ff34..13af583 100644 --- a/src/headless_engine/calendar.rs +++ b/src/headless_engine/calendar.rs @@ -52,7 +52,7 @@ pub(super) fn dispatch( let generation = engine.bump_generation(GenerationKey::Calendar); let profile_value = profile.unwrap_or_else(|| engine.state.profile.active.clone()); let profile_id = active_profile_id(&engine.state, &profile_value); - engine.state.calendar = CalendarState { + *engine.state.calendar = CalendarState { year, month, is_loading: true, diff --git a/src/headless_engine/contracts.rs b/src/headless_engine/contracts.rs index 2454e54..132102f 100644 --- a/src/headless_engine/contracts.rs +++ b/src/headless_engine/contracts.rs @@ -1,4 +1,3 @@ -use super::state::EngineState; use crate::runtime::EffectEnvelope; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -385,27 +384,3 @@ pub(super) struct StatePatch { #[serde(skip_serializing_if = "Option::is_none")] pub pending_effects: Option>, } - -impl StatePatch { - pub(super) fn diff(before: &EngineState, after: &EngineState) -> Self { - Self { - navigation: (before.navigation != after.navigation).then(|| after.navigation.clone()), - home: (before.home != after.home).then(|| after.home.clone()), - search: (before.search != after.search).then(|| after.search.clone()), - discover: (before.discover != after.discover).then(|| after.discover.clone()), - detail: (before.detail != after.detail).then(|| after.detail.clone()), - player: (before.player != after.player).then(|| after.player.clone()), - library: (before.library != after.library).then(|| after.library.clone()), - profile: (before.profile != after.profile).then(|| after.profile.clone()), - settings: (before.settings != after.settings).then(|| after.settings.clone()), - calendar: (before.calendar != after.calendar).then(|| after.calendar.clone()), - addons: (before.addons != after.addons).then(|| after.addons.clone()), - auth: (before.auth != after.auth).then(|| after.auth.clone()), - sync: (before.sync != after.sync).then(|| after.sync.clone()), - lookup: (before.lookup != after.lookup).then(|| after.lookup.clone()), - offline: (before.offline != after.offline).then(|| after.offline.clone()), - pending_effects: (before.pending_effects != after.pending_effects) - .then(|| after.pending_effects.clone()), - } - } -} diff --git a/src/headless_engine/detail.rs b/src/headless_engine/detail.rs index fe462fa..8a43ea7 100644 --- a/src/headless_engine/detail.rs +++ b/src/headless_engine/detail.rs @@ -195,7 +195,7 @@ pub(super) fn dispatch_load( ) -> Vec { let generation = engine.bump_generation(GenerationKey::Detail); let language = language.unwrap_or_else(|| "en".to_string()); - engine.state.detail = DetailState { + *engine.state.detail = DetailState { content_type: content_type.clone(), id: id.clone(), language: language.clone(), @@ -588,13 +588,13 @@ pub(super) fn complete( "fetchMetaDetailLookup" => { if generation == engine.state.runtime.get(GenerationKey::Lookup) { if result.status.is_ok() { - engine.state.lookup = LookupState { + *engine.state.lookup = LookupState { trailers: normalize_meta_trailers(&result.value), meta_detail: result.value.clone(), error: Value::Null, }; } else { - engine.state.lookup = LookupState { + *engine.state.lookup = LookupState { trailers: serde_json::json!([]), meta_detail: Value::Null, error: normalize_error(result.error.clone()), diff --git a/src/headless_engine/discover.rs b/src/headless_engine/discover.rs index c50f155..19c0c92 100644 --- a/src/headless_engine/discover.rs +++ b/src/headless_engine/discover.rs @@ -50,7 +50,7 @@ pub(super) fn dispatch_discover( let profile_value = profile.unwrap_or_else(|| engine.state.profile.active.clone()); let profile_id = active_profile_id(&engine.state, &profile_value); let filters_value = filters.unwrap_or(Value::Null); - engine.state.discover = DiscoverState { + *engine.state.discover = DiscoverState { content_type: content_type.clone(), filters: filters_value.clone(), is_loading: true, diff --git a/src/headless_engine/home.rs b/src/headless_engine/home.rs index d7a169e..147ee02 100644 --- a/src/headless_engine/home.rs +++ b/src/headless_engine/home.rs @@ -136,7 +136,7 @@ pub(super) fn dispatch_load( let generation = engine.bump_generation(GenerationKey::Home); let profile_value = profile.unwrap_or_else(|| engine.state.profile.active.clone()); let profile_id = active_profile_id(&engine.state, &profile_value); - engine.state.home = HomeState { + *engine.state.home = HomeState { is_loading: true, generation, ..HomeState::default() diff --git a/src/headless_engine/mod.rs b/src/headless_engine/mod.rs index 9209062..79b9a20 100644 --- a/src/headless_engine/mod.rs +++ b/src/headless_engine/mod.rs @@ -82,8 +82,11 @@ pub fn destroy_headless_engine(handle: u64) -> bool { } pub fn headless_engine_snapshot_json(handle: u64) -> Option { - let map = lock_engines(); - serde_json::to_string(&map.get(&handle)?.state).ok() + let state = { + let map = lock_engines(); + map.get(&handle)?.state.clone() + }; + serde_json::to_string(&state).ok() } pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option { @@ -93,7 +96,7 @@ pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option engine, @@ -105,12 +108,11 @@ pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option Option { @@ -120,7 +122,7 @@ pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> O detail: e.to_string(), }) .log_discard()?; - let (before, after, visible_effects) = { + let (patch, visible_effects) = { let mut map = lock_engines(); let engine = match map.get_mut(&handle) { Some(engine) => engine, @@ -132,12 +134,11 @@ pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> O } }; engine.expire_stale_pending_effects(Instant::now()); - let before = engine.state.clone(); let effects = engine.complete_effect(result); let visible_effects = engine.resolve_visible_effects(effects); - (before, engine.state.clone(), visible_effects) + (engine.state.diff_dirty(), visible_effects) }; - result_patch_json(&before, &after, visible_effects) + result_patch_json(patch, visible_effects) } impl HeadlessEngine { @@ -707,16 +708,8 @@ impl HeadlessEngine { // over a second, and every other Tauri command shares one global engine mutex — holding // it for that long would stall unrelated IPC calls behind it. Callers clone what they // need and drop the lock before calling this. -fn result_patch_json( - before: &EngineState, - after: &EngineState, - effects: Vec, -) -> Option { - serde_json::to_string(&DispatchResult { - state: StatePatch::diff(before, after), - effects, - }) - .ok() +fn result_patch_json(state: StatePatch, effects: Vec) -> Option { + serde_json::to_string(&DispatchResult { state, effects }).ok() } fn engines() -> &'static Mutex> { diff --git a/src/headless_engine/navigation.rs b/src/headless_engine/navigation.rs index e64a68b..14aa677 100644 --- a/src/headless_engine/navigation.rs +++ b/src/headless_engine/navigation.rs @@ -24,7 +24,7 @@ pub(super) fn dispatch( route: String, params: Option, ) -> Vec { - engine.state.navigation = NavigationState { + *engine.state.navigation = NavigationState { route, params: params.unwrap_or(Value::Null), }; diff --git a/src/headless_engine/player.rs b/src/headless_engine/player.rs index a450ca5..bdc29c4 100644 --- a/src/headless_engine/player.rs +++ b/src/headless_engine/player.rs @@ -301,7 +301,7 @@ pub(super) fn dispatch_load_streams( }; let mut flow_state = engine.state.player.to_flow_state(); let effects = player_flow::dispatch(&mut flow_state, action); - engine.state.player = PlayerState::from_flow_state(flow_state); + *engine.state.player = PlayerState::from_flow_state(flow_state); let pending = PendingStreamLoad { saved_url, @@ -376,7 +376,7 @@ pub(super) fn dispatch_streams_loaded( }; let mut flow_state = engine.state.player.to_flow_state(); let _ = player_flow::dispatch(&mut flow_state, action); - engine.state.player = PlayerState::from_flow_state(flow_state); + *engine.state.player = PlayerState::from_flow_state(flow_state); engine.state.player.generation = generation; vec![] } @@ -390,7 +390,7 @@ pub(super) fn dispatch_streams_failed( }; let mut flow_state = engine.state.player.to_flow_state(); let _ = player_flow::dispatch(&mut flow_state, action); - engine.state.player = PlayerState::from_flow_state(flow_state); + *engine.state.player = PlayerState::from_flow_state(flow_state); vec![] } diff --git a/src/headless_engine/search.rs b/src/headless_engine/search.rs index 23554b2..7f10f82 100644 --- a/src/headless_engine/search.rs +++ b/src/headless_engine/search.rs @@ -35,7 +35,7 @@ pub(super) fn dispatch( let generation = engine.bump_generation(GenerationKey::Search); let profile_value = profile.unwrap_or_else(|| engine.state.profile.active.clone()); let profile_id = active_profile_id(&engine.state, &profile_value); - engine.state.search = SearchState { + *engine.state.search = SearchState { query: query.clone(), is_loading: true, results: serde_json::json!([]), diff --git a/src/headless_engine/state.rs b/src/headless_engine/state.rs index 4247ad5..9229fe4 100644 --- a/src/headless_engine/state.rs +++ b/src/headless_engine/state.rs @@ -13,7 +13,72 @@ use super::search::SearchState; use super::settings::SettingsState; use super::sync::SyncState; use crate::runtime::EffectEnvelope; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::ops::{Deref, DerefMut}; + +// Wraps a domain's state so any mutable access (a direct field write, a whole-value +// replacement via DerefMut, a method call taking &mut) flips `dirty` automatically — +// StatePatch::from_dirty then clones only domains actually touched by a dispatch +// instead of cloning the whole EngineState before and after every action to diff it +// via PartialEq. Serializes/deserializes exactly like the wrapped value: the wire +// format is unaffected. +#[derive(Clone, Debug)] +pub(super) struct Tracked { + value: T, + dirty: bool, +} + +impl Default for Tracked { + fn default() -> Self { + Tracked { + value: T::default(), + dirty: false, + } + } +} + +impl Tracked { + pub(super) fn take_if_dirty(&mut self) -> Option + where + T: Clone, + { + if self.dirty { + self.dirty = false; + Some(self.value.clone()) + } else { + None + } + } +} + +impl Deref for Tracked { + type Target = T; + fn deref(&self) -> &T { + &self.value + } +} + +impl DerefMut for Tracked { + fn deref_mut(&mut self) -> &mut T { + self.dirty = true; + &mut self.value + } +} + +impl Serialize for Tracked { + fn serialize(&self, serializer: S) -> Result { + self.value.serialize(serializer) + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Tracked { + fn deserialize>(deserializer: D) -> Result { + Ok(Tracked { + value: T::deserialize(deserializer)?, + dirty: false, + }) + } +} #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum GenerationKey { @@ -105,22 +170,45 @@ impl RuntimeGenerations { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase", default)] pub(super) struct EngineState { - pub(super) navigation: NavigationState, - pub(super) home: HomeState, - pub(super) search: SearchState, - pub(super) discover: DiscoverState, - pub(super) detail: DetailState, - pub(super) player: PlayerState, - pub(super) library: LibraryState, - pub(super) profile: ProfileState, - pub(super) settings: SettingsState, - pub(super) calendar: CalendarState, - pub(super) addons: AddonsState, - pub(super) auth: AuthState, - pub(super) sync: SyncState, - pub(super) lookup: LookupState, - pub(super) offline: OfflineState, - pub(super) pending_effects: Vec, + pub(super) navigation: Tracked, + pub(super) home: Tracked, + pub(super) search: Tracked, + pub(super) discover: Tracked, + pub(super) detail: Tracked, + pub(super) player: Tracked, + pub(super) library: Tracked, + pub(super) profile: Tracked, + pub(super) settings: Tracked, + pub(super) calendar: Tracked, + pub(super) addons: Tracked, + pub(super) auth: Tracked, + pub(super) sync: Tracked, + pub(super) lookup: Tracked, + pub(super) offline: Tracked, + pub(super) pending_effects: Tracked>, #[serde(rename = "_runtime")] pub(super) runtime: RuntimeGenerations, } + +impl EngineState { + pub(super) fn diff_dirty(&mut self) -> super::contracts::StatePatch { + super::contracts::StatePatch { + navigation: self.navigation.take_if_dirty(), + home: self.home.take_if_dirty(), + search: self.search.take_if_dirty(), + discover: self.discover.take_if_dirty(), + detail: self.detail.take_if_dirty(), + player: self.player.take_if_dirty(), + library: self.library.take_if_dirty(), + profile: self.profile.take_if_dirty(), + settings: self.settings.take_if_dirty(), + calendar: self.calendar.take_if_dirty(), + addons: self.addons.take_if_dirty(), + auth: self.auth.take_if_dirty(), + sync: self.sync.take_if_dirty(), + lookup: self.lookup.take_if_dirty(), + offline: self.offline.take_if_dirty(), + pending_effects: self.pending_effects.take_if_dirty(), + } + } +} diff --git a/src/headless_engine/sync.rs b/src/headless_engine/sync.rs index 3befb43..2b2ef2f 100644 --- a/src/headless_engine/sync.rs +++ b/src/headless_engine/sync.rs @@ -43,7 +43,7 @@ pub(super) fn dispatch_external_sync( let generation = engine.bump_generation(GenerationKey::Sync); let profile_value = profile.unwrap_or_else(|| engine.state.profile.active.clone()); let profile_id = active_profile_id(&engine.state, &profile_value); - engine.state.sync = SyncState { + *engine.state.sync = SyncState { provider: provider.clone(), is_loading: true, snapshot: Value::Null, @@ -69,7 +69,7 @@ pub(super) fn dispatch_integration_sync( language: Option, ) -> Vec { let generation = engine.bump_generation(GenerationKey::Sync); - engine.state.sync = SyncState { + *engine.state.sync = SyncState { provider: provider.clone(), is_loading: true, snapshot: Value::Null, diff --git a/src/lib.rs b/src/lib.rs index d7c9873..4b51a2b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -118,6 +118,20 @@ pub mod fuzz_targets { }; } +// Re-exports for the `benches/` dev-dependency only, mirroring `fuzz_targets` above — +// these functions are `pub(crate)` for real consumers and only reachable from outside +// the crate through this feature-gated module. +#[cfg(all( + feature = "bench", + any(feature = "full-api", not(feature = "streaming-shared")) +))] +pub mod bench_targets { + pub use crate::headless_engine::{ + create_headless_engine, destroy_headless_engine, headless_engine_dispatch_json, + }; + pub use crate::player_policy::player_source_sidebar_plan_json; +} + #[cfg(all(test, any(feature = "full-api", not(feature = "streaming-shared"))))] mod tests { use crate::addon_protocol::{ diff --git a/src/player_policy.rs b/src/player_policy.rs index 56b6cea..cefe428 100644 --- a/src/player_policy.rs +++ b/src/player_policy.rs @@ -320,7 +320,7 @@ pub(crate) fn player_retry_policy_json(request_json: &str) -> Option { } /// Build the source sidebar option state: which streams to show and which is selected. -pub(crate) fn player_source_sidebar_plan_json(request_json: &str) -> Option { +pub fn player_source_sidebar_plan_json(request_json: &str) -> Option { let request = serde_json::from_str::(request_json) .map_err(|e| CoreError::BadInput { context: "player_source_sidebar_plan_json",