perf(headless-engine): replace clone-and-diff dispatch with dirty tracking

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<T> (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.
This commit is contained in:
KhooLy 2026-07-07 14:46:52 +03:00
parent b171adf721
commit fdf91ceb19
19 changed files with 387 additions and 82 deletions

View file

@ -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:

139
Cargo.lock generated
View file

@ -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"

View file

@ -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"]

View file

@ -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);

34
benches/stream_ranking.rs Normal file
View file

@ -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::<String>::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);

View file

@ -46,7 +46,7 @@ pub(super) fn dispatch_flow(
mode: String,
) -> Vec<EffectEnvelope> {
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<Value>,
) -> Vec<EffectEnvelope> {
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<EffectEnvelope> {
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,

View file

@ -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,

View file

@ -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<Vec<EffectEnvelope>>,
}
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()),
}
}
}

View file

@ -195,7 +195,7 @@ pub(super) fn dispatch_load(
) -> Vec<EffectEnvelope> {
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()),

View file

@ -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,

View file

@ -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()

View file

@ -82,8 +82,11 @@ pub fn destroy_headless_engine(handle: u64) -> bool {
}
pub fn headless_engine_snapshot_json(handle: u64) -> Option<String> {
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<String> {
@ -93,7 +96,7 @@ pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option<S
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,
@ -105,12 +108,11 @@ pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option<S
}
};
engine.expire_stale_pending_effects(Instant::now());
let before = engine.state.clone();
let effects = engine.dispatch(action);
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)
}
pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> Option<String> {
@ -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<EffectEnvelope>,
) -> Option<String> {
serde_json::to_string(&DispatchResult {
state: StatePatch::diff(before, after),
effects,
})
.ok()
fn result_patch_json(state: StatePatch, effects: Vec<EffectEnvelope>) -> Option<String> {
serde_json::to_string(&DispatchResult { state, effects }).ok()
}
fn engines() -> &'static Mutex<HashMap<u64, HeadlessEngine>> {

View file

@ -24,7 +24,7 @@ pub(super) fn dispatch(
route: String,
params: Option<Value>,
) -> Vec<EffectEnvelope> {
engine.state.navigation = NavigationState {
*engine.state.navigation = NavigationState {
route,
params: params.unwrap_or(Value::Null),
};

View file

@ -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![]
}

View file

@ -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!([]),

View file

@ -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<T> {
value: T,
dirty: bool,
}
impl<T: Default> Default for Tracked<T> {
fn default() -> Self {
Tracked {
value: T::default(),
dirty: false,
}
}
}
impl<T> Tracked<T> {
pub(super) fn take_if_dirty(&mut self) -> Option<T>
where
T: Clone,
{
if self.dirty {
self.dirty = false;
Some(self.value.clone())
} else {
None
}
}
}
impl<T> Deref for Tracked<T> {
type Target = T;
fn deref(&self) -> &T {
&self.value
}
}
impl<T> DerefMut for Tracked<T> {
fn deref_mut(&mut self) -> &mut T {
self.dirty = true;
&mut self.value
}
}
impl<T: Serialize> Serialize for Tracked<T> {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.value.serialize(serializer)
}
}
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Tracked<T> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<EffectEnvelope>,
pub(super) navigation: Tracked<NavigationState>,
pub(super) home: Tracked<HomeState>,
pub(super) search: Tracked<SearchState>,
pub(super) discover: Tracked<DiscoverState>,
pub(super) detail: Tracked<DetailState>,
pub(super) player: Tracked<PlayerState>,
pub(super) library: Tracked<LibraryState>,
pub(super) profile: Tracked<ProfileState>,
pub(super) settings: Tracked<SettingsState>,
pub(super) calendar: Tracked<CalendarState>,
pub(super) addons: Tracked<AddonsState>,
pub(super) auth: Tracked<AuthState>,
pub(super) sync: Tracked<SyncState>,
pub(super) lookup: Tracked<LookupState>,
pub(super) offline: Tracked<OfflineState>,
pub(super) pending_effects: Tracked<Vec<EffectEnvelope>>,
#[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(),
}
}
}

View file

@ -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<String>,
) -> Vec<EffectEnvelope> {
let generation = engine.bump_generation(GenerationKey::Sync);
engine.state.sync = SyncState {
*engine.state.sync = SyncState {
provider: provider.clone(),
is_loading: true,
snapshot: Value::Null,

View file

@ -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::{

View file

@ -320,7 +320,7 @@ pub(crate) fn player_retry_policy_json(request_json: &str) -> Option<String> {
}
/// 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<String> {
pub fn player_source_sidebar_plan_json(request_json: &str) -> Option<String> {
let request = serde_json::from_str::<SourceSidebarRequest>(request_json)
.map_err(|e| CoreError::BadInput {
context: "player_source_sidebar_plan_json",