mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-07-26 20:02:13 +00:00
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:
parent
b171adf721
commit
fdf91ceb19
19 changed files with 387 additions and 82 deletions
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
|
|
@ -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
139
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
14
Cargo.toml
14
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"]
|
||||
|
|
|
|||
46
benches/headless_engine_dispatch.rs
Normal file
46
benches/headless_engine_dispatch.rs
Normal 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
34
benches/stream_ranking.rs
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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![]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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!([]),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
14
src/lib.rs
14
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::{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue