fluxa-core/docs/robustness-plan.md
KhooLy 603a94bf6c docs: mark §11 (desktop JSON tax) resolved after verifying fluxa-desktop
Per CLAUDE.md's rule to verify against fluxa-desktop/src-tauri call
sites before touching FluxaCore: the plan's premise for this section
no longer holds. dispatchAction/completeEffect/getSnapshot in
fluxa-desktop's src/core/engine.ts already go through dedicated Tauri
commands (engine_dispatch/engine_complete_effect/engine_snapshot) that
call FluxaCore::* directly in Rust, not through core_invoke. FluxaCore
itself has also grown from the "8 methods" this doc assumed to 21
(cast/AirPlay/Chromecast/Roku helpers), all with real call sites
verified via grep. The remaining core_invoke Tauri command is a
generic fallback for infrequent methods where a dedicated command
per method would be boilerplate with no measurable win, since Tauri
IPC serializes as JSON at the JS<->Rust boundary either way. No code
change needed; also corrected the stale "8 methods" claim in
CLAUDE.md's core_api description to reflect the current count.
2026-07-07 15:10:23 +03:00

291 lines
16 KiB
Markdown

# fluxa-core robustness & performance plan
Goal: move the crate toward "if it compiles, it doesn't break at runtime" and make the
hot paths (dispatch, effect completion, stream ranking) cheap enough to never show up in
a profile. This document is the result of a full review of the crate as of 2026-07-07
(master @ f00a09a) and lays out what's already good, where runtime breakage can still
hide, and a prioritized plan to close each gap.
## Current state, honestly assessed
What's already strong and should be preserved:
- `headless_engine` state is fully typed — `EngineState` contains zero `serde_json::Value`
fields at the top level (`state.rs`), cross-module mutation goes through `pub(super)`
setters, and generations use the `GenerationKey` enum.
- Every JNI entry point wraps its body in `catch_unwind`, and `core_invoke` does too, so
a panic can't cross the FFI boundary and abort the Android process.
- Production code is nearly panic-free: outside test modules the only `expect` is a
static regex in `addon_store.rs:748`, which can't fail for a fixed pattern.
- Poisoned-mutex recovery exists (`lock_engines` uses `into_inner()`), so one panicking
dispatch can't brick every later call.
- `StatePatch::diff` sends only changed domains over the wire instead of the full state.
- Fuzz targets exist (`episode_matching`, `manifest_parse`, `percent_decode`).
- Effect delivery has dedup (`delivered_effect_ids`) and expiry (`EFFECT_EXPIRY`), so a
lost `completeEffect` doesn't leak or double-execute.
What undermines the "compiles ⇒ works" goal, in order of real-world risk:
## P0 — fix immediately
### 1. Master has a failing test
`player_policy::tests::convert_dv81_hls_still_deferred_to_manifest_rewrite` fails.
The branch at `player_policy.rs:445` returns `hls_rpu_convert` for
HLS + P7 + `convert_dv81` + DV decoder, while the test still expects
`none`/`manifest_handled`. Either the new branch is a policy change and the test is
stale, or the branch regressed the "HLS is always the OkHttp interceptor's job" rule
that `sample_hls_stream_always_deferred_to_manifest_rewrite` documents. Decide which,
fix, and add a CI gate (see §8) so a red master can't happen silently again.
## P1 — silent failures at the boundary
### 2. `Option<String>` swallows every error
The dominant FFI pattern is `*_json(&str) -> Option<String>` (~295 functions), almost
always built on `serde_json::from_str(...).ok()?` (~135 sites). Any malformed or
schema-drifted input produces `None` → the host receives `null` with zero diagnostics.
This is the single biggest source of "compiles but breaks at runtime": a Kotlin-side
field rename doesn't crash anything, it just makes a feature quietly stop working.
Plan, incremental and wire-compatible:
- Introduce one internal error type:
```rust
pub(crate) enum CoreError {
BadInput { context: &'static str, detail: String },
NotFound { context: &'static str },
}
```
Migrate module internals from `Option<T>` to `Result<T, CoreError>`, mechanical
module by module (`?` still works). Keep the outer `Option<String>` shims so JNI
signatures and the Kotlin side don't change.
- At each boundary shim, on `Err` route the detail through a host-pluggable logger
before returning `None`/`null`. Add a `core.setLogSink` (JNI: one extern that stores
a JVM callback; desktop: a `fn(String)` setter) so failures land in logcat / desktop
logs instead of vanishing. This turns every future wire drift from "feature silently
dead" into a one-line log with the exact field that failed to parse.
- `core_invoke` already has an error envelope — have its shims return the real
`CoreError` kind/message instead of collapsing to `NotFound` via `Option`.
### 3. `EffectResultInput` accepts garbage as success
Every field is `#[serde(default)]` (`contracts.rs:318`), so a completely wrong-shaped
`completeEffect` payload deserializes into `effect_id: ""`, `status: ""` — the engine
then looks up effect `""`, finds nothing, and the completion is dropped without a trace.
Make `effect_id` required, make `status` an enum:
```rust
#[serde(rename_all = "camelCase")]
enum EffectStatus { Ok, Error, Cancelled }
```
Reject unknown statuses at parse time with a logged error (via §2's sink) instead of
treating them as an empty string that matches no arm.
## P2 — make invalid states unrepresentable
### 4. `Value` leaves inside the typed engine
`EngineState` is typed, but the payloads flowing through it are not: ~370 `Value` /
`Vec<Value>` / `Option<Value>` occurrences across `headless_engine/*.rs`. The repeat
offenders in `AppAction` are `profile: Option<Value>`, `meta: Value`,
`streams: Vec<Value>`, `item: Value`. Every `.get("...").and_then(as_str)` chain on
these is a stringly-typed runtime contract the compiler can't check.
Plan: define the three or four shared wire types once, in a new `types/wire.rs` (the
`types/` module dir already exists), with `#[serde(rename_all = "camelCase")]` and
`#[serde(default)]` on genuinely optional fields:
- `Profile``profile_prefs.rs` already does a typed read of the same JSON; promote
that struct instead of writing a new one.
- `MetaItem` / `Video` — the detail/library/watchlist modules all re-derive the same
fields (`id`, `type`, `name`, `poster`, `videos`, episode fields) from `Value`.
- `Stream``stream_policy.rs` already has typed stream structs; reuse them in
`AppAction::PlayerStreamsLoaded` and friends.
Crucial constraint: any field the platform sends that Rust doesn't model must survive
round-trips, because effect payloads echo these objects back. Use
`#[serde(flatten)] extra: serde_json::Map<String, Value>` on each wire type so unknown
fields pass through unchanged. This lets migration happen field-by-field without ever
breaking a consumer that sends more than we model.
Migrate one action at a time; the JSON on the wire never changes, so this is invisible
to the platforms. Priority order by breakage frequency: `profile` (touched by ~20
actions), `meta`, `streams`, `item`.
### 5. `json!` payload literals defeat compile-time checking
The stated convention is "effect payloads are small `#[derive(Serialize)]` structs",
but `headless_engine/` still has ~80 `json!({...})` payload literals. A typo'd key in
one of those compiles fine and the platform-side effect executor gets a field it
doesn't recognize. Finish the migration: one payload struct per effect kind, named
after it (`FetchDetailStreamsPayload`), living next to the module that emits it.
Once done, add `json!` to the review checklist / grep gate for `headless_engine/`
(it stays fine in tests and in `ffi.rs` envelope construction).
### 6. Stringly-typed enums that already have a closed set
- `EffectEnvelope.kind` is `String` even though `EffectKind` exists and has an
exhaustive test. Change the field to `EffectKind` with
`#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")]` on the enum —
serde then generates exactly the strings `as_str()` produces today, the 100-line
hand-written `as_str`/`from_str` pair and its roundtrip test get deleted, and adding
a variant can no longer be forgotten in one of three places. `EffectEnvelope::raw`
has to go or become test-only; audit its callers first.
- `fallback_mode` in `player_policy.rs` is compared as `"convert_dv81"` / `"auto"` /
`"off"` string literals throughout `dv_proxy_plan_json`. Parse once into an enum at
the request boundary; a typo in a mode string then fails at the one parse site, not
silently in whichever comparison spelled it wrong.
- Same treatment for `source_selection_mode`, scrobble `action_name`, and auth
`provider`/`mode` — each is a closed set spread across string comparisons today.
## P3 — the three uncoordinated FFI surfaces
### 7. One method inventory instead of three hand-wired ones
`ffi.rs` routes ~115 method names by string; `jni.rs` hand-wires ~105 externs; nothing
guarantees the same domain function is exposed consistently or spelled the same. The
CLAUDE.md assessment that a full consolidation isn't worth the rewrite risk stands —
but a cheap middle ground removes most drift without touching `jni.rs`'s hot path:
- Declare the string-routed surface in one table macro:
```rust
core_methods! {
"streamPolicy.rankStreams" => stream_policy::rank_streams_json(json),
"engine.dispatch" => (handle: u64, action: str) headless_engine::headless_engine_dispatch_json,
...
}
```
generating the `route_*` functions in `ffi.rs`. Adding a method becomes one line;
the per-domain router boilerplate and its UnknownMethod-chaining protocol disappear.
- Generate `core_capabilities` from the same table so the platform can feature-detect
instead of calling methods that may not exist in an older core build.
- Leave `jni.rs` as-is (it's Android's proven path), but add a unit test that walks the
table and asserts every `core_invoke` method name a consumer repo references (kept in
a checked-in fixture list) still routes. That converts "renamed a method string"
from an Android-runtime discovery into a test failure here.
## P4 — cross-repo wire contract
### 8. The wire format has no executable contract
Field names in `dispatch`/`completeEffect`/effect payloads are read by Kotlin/TS in
other repos; today the only protection is care. Two mechanisms, both cheap:
- **Golden fixtures**: a `tests/wire/` directory of checked-in JSON files — one real
`AppAction` input and one expected `DispatchResult` output per action family, plus
one payload sample per `EffectKind`. A snapshot test deserializes, dispatches, and
compares serialized output structurally. Any refactor that changes camelCase output
now fails a test in *this* repo instead of breaking Android in production. When a
change is intentional, the diff in the fixture file is the review artifact the
platform PRs can be checked against.
- **Fuzz the real boundary**: the existing fuzz targets cover parsers, but the highest-
value target is missing — `headless_engine_dispatch_json` + `complete_effect_json`
with arbitrary bytes and with structure-aware mutation of valid actions. The engine
holding global state behind `catch_unwind`-free internals (dispatch is only guarded
at the JNI layer, not in the desktop path via direct calls) makes this the place a
panic or logic corruption would actually hurt. Add `engine_dispatch.rs` to
`fuzz/fuzz_targets/`.
## P5 — performance
The release profile is already right (`lto = true`, `codegen-units = 1`, opt-level 3).
Keep `panic = "unwind"` — the FFI `catch_unwind` guards depend on it; `panic = "abort"`
would turn every guarded panic into a process kill. The actual costs found:
### 9. Two full state clones per dispatch
`headless_engine_dispatch_json` (`mod.rs:88-100`) clones the entire `EngineState`
before the reducer runs, clones it again after, then `StatePatch::diff` does a deep
`PartialEq` walk of all 16 domains and clones each changed one a third time. Cost
scales with everything the user has ever loaded (full catalogs, episode lists), not
with what the action touched — on a TV device after a long session this is the
dispatch latency.
Fix without changing the wire format: replace clone-and-diff with dirty tracking.
Each domain module already owns its state behind `pub(super)` setters — the setters
are the natural single place to flip a `dirty: DomainMask` bit on the engine.
`StatePatch` then clones only domains whose bit is set, and the before-clone
disappears entirely. `PartialEq` on the domain structs stays for tests. This is the
single highest-leverage performance change in the crate; do it before micro-work.
Second-order: `snapshot_json` and dispatch serialize while holding the global engines
mutex. Move `serde_json::to_string` outside the lock (clone the patch domains inside,
serialize after release) so a slow serialization of a big home state can't block a
concurrent `completeEffect`.
### 10. Per-call regex compilation in ranking loops
`stream_policy.rs:548,594,608` build a `Regex` from the user's language preference on
every call — and these run inside per-stream ranking. `external_sync.rs:65` compiles
`tt\d+` per history item; `watchlist_plan.rs:216` compiles a static GitHub-URL regex
per call. `content_identity.rs:980` already shows the house pattern (`OnceLock`).
Statics move to `OnceLock`; the preference-derived ones get a one-entry cache keyed by
the preference string (a `Mutex<Option<(String, Regex)>>` is enough — the preference
changes once per settings edit, not per stream).
### 11. Desktop pays JSON tax it doesn't owe — RESOLVED, already fixed on the desktop side
Verified against `fluxa-desktop/src-tauri` call sites (2026-07) per the CLAUDE.md rule, and
this section's premise no longer holds. The hottest paths — `dispatchAction`,
`completeEffect`, `getSnapshot` in `src/core/engine.ts` — already go through dedicated Tauri
commands (`engine_dispatch`, `engine_complete_effect`, `engine_snapshot` in
`src-tauri/src/lib.rs`) that call `FluxaCore::headless_engine_dispatch_json` etc. directly in
Rust, not through `core_invoke`. `FluxaCore` has also grown from the "8 methods" this doc
assumed to 21 (cast/AirPlay/Chromecast/Roku helpers included), all with real desktop call
sites. The one remaining `core_invoke` Tauri command is a generic fallback for the long tail
of infrequent methods (`playbackPreparePlan`, `preferencesSchema`, etc.) — writing a
dedicated Tauri command per method there would be pure boilerplate, since Tauri IPC already
serializes args as JSON crossing the JS↔Rust boundary regardless of whether the command is
generic or dedicated; there's no double-parse to eliminate. Nothing left to do here.
### 12. Measure before and after
Add a `benches/` with criterion (dev-dependency only): dispatch on a realistic large
state (fixture from §8's golden files), `StatePatch::diff` vs dirty-tracking, stream
ranking with 500 streams. Without these, §9-§11 are guesses; with them, regressions
show up in review.
## P6 — enforcement so it stays fixed
### 13. Lint and CI gates
There is no CI in this repo and no lint policy in `Cargo.toml`. Add:
```toml
[lints.rust]
unsafe_op_in_unsafe_fn = "warn"
[lints.clippy]
unwrap_used = "warn"
expect_used = "warn"
indexing_slicing = "warn"
panic = "warn"
```
`warn` not `deny` initially — test modules use these legitimately; add
`#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]` and ratchet to
`deny` once the count is zero. CI (GitHub Actions, single workflow): `cargo test --lib`,
`cargo clippy -- -D warnings` on default features, `cargo check --no-default-features
--features wasm` (allowing its known dead-code noise per CLAUDE.md), and `cargo fmt
--check`. The wasm job is what catches "compiles on Android, broken on webOS" cfg
mistakes.
## Sequencing
| Order | Work | Sections | Size |
|---|---|---|---|
| 1 | Fix failing DV test, stand up CI | §1, §13 | small |
| 2 | Golden wire fixtures + dispatch fuzz target | §8 | small-medium |
| 3 | `CoreError` + log sink at boundaries | §2, §3 | medium, mechanical |
| 4 | Dirty-flag `StatePatch`, serialize outside lock, benches | §9, §12 | medium |
| 5 | Typed `Profile`/`MetaItem`/`Stream` wire structs | §4 | large, incremental |
| 6 | `EffectKind` on envelope, mode enums, payload structs | §5, §6 | medium |
| 7 | `core_methods!` table + capability generation | §7 | medium |
| 8 | Desktop typed fast path | §11 | small |
Steps 2 and 3 are what make step 5 safe: once golden fixtures exist and parse failures
are logged instead of swallowed, converting `Value` fields to typed structs can be done
aggressively — any drift shows up as a fixture diff or a logged parse error instead of
a silent feature death on a platform we can't see from this repo.