diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 097d159..be88369 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,8 @@ on: push: branches: [master] pull_request: + schedule: + - cron: '17 3 * * 1' env: CARGO_TERM_COLOR: always @@ -15,7 +17,8 @@ jobs: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --workspace --lib + - run: cargo test --workspace --all-targets + - run: cargo test --doc clippy: runs-on: ubuntu-latest @@ -25,7 +28,7 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --workspace --lib + - run: cargo clippy --workspace --all-targets -- -D warnings bench-check: runs-on: ubuntu-latest @@ -43,6 +46,48 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo check --no-default-features --features wasm + feature-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + features: [streaming-shared, desktop, uniffi-bindings] + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --no-default-features --features ${{ matrix.features }} + + android-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android,armv7-linux-androideabi,x86_64-linux-android + - uses: Swatinem/rust-cache@v2 + - run: cargo check --target aarch64-linux-android + - run: cargo check --target armv7-linux-androideabi + - run: cargo check --target x86_64-linux-android + + dependency-audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: EmbarkStudios/cargo-deny-action@v2 + + fuzz-smoke: + if: github.event_name == 'schedule' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - run: cargo install cargo-fuzz --locked + - run: cd fuzz && cargo fuzz run episode_matching -- -runs=1000 + - run: cd fuzz && cargo fuzz run percent_decode -- -runs=1000 + - run: cd fuzz && cargo fuzz run manifest_parse -- -runs=1000 + - run: cd fuzz && cargo fuzz run engine_dispatch -- -runs=1000 + fmt: runs-on: ubuntu-latest steps: diff --git a/fluxa-streaming-engine/src/dv_rewrite.rs b/fluxa-streaming-engine/src/dv_rewrite.rs index cf61c30..0e0b59c 100644 --- a/fluxa-streaming-engine/src/dv_rewrite.rs +++ b/fluxa-streaming-engine/src/dv_rewrite.rs @@ -188,7 +188,10 @@ pub(crate) fn start_dv_rewrite_local_stream_server( }; let thread = thread::spawn(move || { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread().enable_all().build() else { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { return; }; runtime.block_on(async move { diff --git a/fluxa-streaming-engine/src/local_stream.rs b/fluxa-streaming-engine/src/local_stream.rs index e285994..d0a955b 100644 --- a/fluxa-streaming-engine/src/local_stream.rs +++ b/fluxa-streaming-engine/src/local_stream.rs @@ -245,7 +245,10 @@ pub(crate) fn start_local_stream_server( port, }; let thread = thread::spawn(move || { - let Ok(runtime) = tokio::runtime::Builder::new_current_thread().enable_all().build() else { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { return; }; runtime.block_on(async move { diff --git a/fluxa-streaming-engine/src/torrent_engine.rs b/fluxa-streaming-engine/src/torrent_engine.rs index 5101dfa..f96be5a 100644 --- a/fluxa-streaming-engine/src/torrent_engine.rs +++ b/fluxa-streaming-engine/src/torrent_engine.rs @@ -13,14 +13,14 @@ use librqbit::{ use serde::Deserialize; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; -use std::io::SeekFrom; use std::future::Future; -use std::pin::Pin; -use std::task::{Context, Poll}; +use std::io::SeekFrom; use std::net::SocketAddr; use std::path::PathBuf; +use std::pin::Pin; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; +use std::task::{Context, Poll}; use std::thread; use std::time::{Duration, Instant}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; @@ -95,6 +95,21 @@ struct PlaybackSession { cancel: CancellationToken, } +#[derive(Default, Clone, Copy)] +struct PlaybackTelemetry { + first_frame_ms: Option, + stall_count: u64, + stall_duration_ms: u64, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct TelemetryEvent { + link: String, + event: String, + elapsed_ms: Option, +} + struct CancellableReader { inner: R, // Keep the cancellation future alive while the underlying rqbit reader is @@ -159,6 +174,7 @@ struct EngineState { prioritized_files: Arc>>, playback_windows: Arc>>, playback_sessions: Arc>>, + playback_telemetry: Arc>>, /// Root cancellation per torrent. Probe readers use child tokens too, so /// they cannot outlive playback deactivation or cache eviction. torrent_cancellations: Arc>>, @@ -322,6 +338,7 @@ pub fn start_torrent_server( prioritized_files: Arc::new(Mutex::new(HashMap::new())), playback_windows: Arc::new(Mutex::new(HashMap::new())), playback_sessions: Arc::new(Mutex::new(HashMap::new())), + playback_telemetry: Arc::new(Mutex::new(HashMap::new())), torrent_cancellations: Arc::new(Mutex::new(HashMap::new())), lifecycle: Arc::new(Mutex::new(HashMap::new())), active_torrent: Arc::new(Mutex::new(None)), @@ -337,6 +354,7 @@ pub fn start_torrent_server( .route("/health", get(health)) .route("/settings", post(update_settings)) .route("/torrents", post(torrents)) + .route("/telemetry", post(record_telemetry)) .route("/stream/fname", get(stream_fname)) .with_state(state); @@ -611,6 +629,37 @@ async fn torrents( } } +async fn record_telemetry( + State(state): State, + ConnectInfo(remote_addr): ConnectInfo, + Json(event): Json, +) -> Response { + if !request_authorized(&state, remote_addr, None) { + return error_response(StatusCode::UNAUTHORIZED, "unauthorized"); + } + let Some(id) = lookup_known_link(&state, Some(&event.link)) else { + return error_response(StatusCode::NOT_FOUND, "torrent not found"); + }; + let mut telemetry = match state.playback_telemetry.lock() { + Ok(telemetry) => telemetry, + Err(_) => { + return error_response(StatusCode::INTERNAL_SERVER_ERROR, "telemetry unavailable"); + } + }; + let entry = telemetry.entry(id).or_default(); + match event.event.as_str() { + "firstFrame" => entry.first_frame_ms = event.elapsed_ms.or(entry.first_frame_ms), + "stallStarted" => entry.stall_count = entry.stall_count.saturating_add(1), + "stallEnded" => { + entry.stall_duration_ms = entry + .stall_duration_ms + .saturating_add(event.elapsed_ms.unwrap_or_default()) + } + _ => return error_response(StatusCode::BAD_REQUEST, "unsupported telemetry event"), + } + (StatusCode::OK, Json(json!({}))).into_response() +} + async fn stream_fname( State(state): State, Query(query): Query, @@ -954,10 +1003,7 @@ async fn status_response( .and_then(|stats| stats.live.as_ref()) .map(|live| live.snapshot.peer_stats.seen) .unwrap_or(0); - let peer_quality = state - .api - .api_peer_quality(TorrentIdOrHash::Id(id)) - .ok(); + let peer_quality = state.api.api_peer_quality(TorrentIdOrHash::Id(id)).ok(); let preload_size = state.preload_size.lock().map(|value| *value).unwrap_or(0); let mut window = focus_file.and_then(|file_id| playback_window_for(state, id, file_id)); let playback_offset = window.map(|window| window.playback_offset).unwrap_or(0); @@ -972,7 +1018,9 @@ async fn status_response( TorrentIdOrHash::Id(id), file_id, playback_offset, - window.map(|window| window.warm_ahead_bytes).unwrap_or(preload_size), + window + .map(|window| window.warm_ahead_bytes) + .unwrap_or(preload_size), ) .ok() .map(|response| response.contiguous_bytes) @@ -1002,8 +1050,29 @@ async fn status_response( }) .unwrap_or(0.0); let speed_to_bitrate_ratio = window - .map(|window| window.smoothed_download_bps * 8.0 / window.estimated_bitrate_bps.max(1) as f64) + .map(|window| { + window.smoothed_download_bps * 8.0 / window.estimated_bitrate_bps.max(1) as f64 + }) .unwrap_or(0.0); + let playback_telemetry = state + .playback_telemetry + .lock() + .ok() + .and_then(|telemetry| telemetry.get(&id).copied()) + .unwrap_or_default(); + let scheduler = window.map(|window| { + json!({ + "torrentId": window.torrent_id, + "fileId": window.file_id, + "urgentAheadBytes": window.urgent_ahead_bytes, + "warmAheadBytes": window.warm_ahead_bytes, + "contiguousReadyBytes": window.contiguous_ready_bytes, + "seekGeneration": window.seek_generation, + "seekElapsedMs": window.seek_started_at.map(|started| started.elapsed().as_millis() as u64), + "windowAgeMs": window.updated_at.elapsed().as_millis() as u64, + "wasReady": window.was_ready, + }) + }); let stat = match stats.as_ref().map(|stats| stats.state) { Some(TorrentStatsState::Live) if loaded_size >= target_buffer_bytes && target_buffer_bytes > 0 => @@ -1044,6 +1113,17 @@ async fn status_response( "phase": playback_phase(stats.as_ref(), loaded_size, target_buffer_bytes, window), "playback_offset": playback_offset, "requested_end": window.map(|window| window.requested_end).unwrap_or(0), + "telemetry": { + "downloadSpeedBps": download_speed, + "speedToBitrateRatio": speed_to_bitrate_ratio, + "bufferedAheadBytes": loaded_size, + "bufferedAheadSeconds": buffered_ahead_seconds, + "phase": playback_phase(stats.as_ref(), loaded_size, target_buffer_bytes, window), + "scheduler": scheduler, + "firstFrameMs": playback_telemetry.first_frame_ms, + "stallCount": playback_telemetry.stall_count, + "stallDurationMs": playback_telemetry.stall_duration_ms + }, "file_stats": file_stats })) } @@ -1229,7 +1309,9 @@ async fn prewarm_reaper(state: EngineState) { .api .api_torrent_action_pause(TorrentIdOrHash::Id(torrent_id)) .await; - debug_log(format!("[TorrServer] paused idle prewarm torrent={torrent_id}")); + debug_log(format!( + "[TorrServer] paused idle prewarm torrent={torrent_id}" + )); } enforce_cache_limit(&state).await; } @@ -1239,7 +1321,9 @@ async fn enforce_cache_limit(state: &EngineState) { let Some(limit) = state.cache_limit_bytes.lock().ok().and_then(|limit| *limit) else { return; }; - let snapshots = state.api.api_torrent_list_ext(ApiTorrentListOpts { with_stats: true }); + let snapshots = state + .api + .api_torrent_list_ext(ApiTorrentListOpts { with_stats: true }); let mut entries = state .lifecycle .lock() @@ -1254,7 +1338,11 @@ async fn enforce_cache_limit(state: &EngineState) { id, lifecycle.active, lifecycle.last_accessed, - torrent.stats.as_ref().map(|stats| stats.progress_bytes).unwrap_or(0), + torrent + .stats + .as_ref() + .map(|stats| stats.progress_bytes) + .unwrap_or(0), )) }) .collect::>() @@ -1297,7 +1385,9 @@ async fn enforce_cache_limit(state: &EngineState) { } } cancel_torrent_root(state, torrent_id); - debug_log(format!("[TorrServer] evicted inactive torrent={torrent_id} for cache limit")); + debug_log(format!( + "[TorrServer] evicted inactive torrent={torrent_id} for cache limit" + )); } } } @@ -1331,13 +1421,17 @@ fn playback_session_for( .ok() .and_then(|windows| windows.get(&key).map(|window| window.playback_offset)); if let Ok(mut sessions) = state.playback_sessions.lock() { - let seek = previous_offset.is_some_and(|previous| offset.abs_diff(previous) > seek_threshold); + let seek = + previous_offset.is_some_and(|previous| offset.abs_diff(previous) > seek_threshold); if seek { if let Some(previous) = sessions.get(&key) { previous.cancel.cancel(); } } - let generation = sessions.get(&key).map(|session| session.generation).unwrap_or(0) + let generation = sessions + .get(&key) + .map(|session| session.generation) + .unwrap_or(0) + u64::from(seek || !sessions.contains_key(&key)); let session = sessions.entry(key).or_insert_with(|| PlaybackSession { generation, @@ -1393,7 +1487,12 @@ fn is_probe_range( is_probe_for_window(window, offset, length, MAX_PROBE_BYTES) } -fn is_probe_for_window(window: PlaybackWindow, offset: u64, length: u64, max_probe_bytes: u64) -> bool { +fn is_probe_for_window( + window: PlaybackWindow, + offset: u64, + length: u64, + max_probe_bytes: u64, +) -> bool { length <= max_probe_bytes && offset.abs_diff(window.playback_offset) > (window.warm_ahead_bytes / 4).max(max_probe_bytes) @@ -1417,7 +1516,9 @@ fn playback_phase( Some(TorrentStatsState::Paused) => "stalled", Some(TorrentStatsState::Live) if window.is_some_and(|window| { - window.seek_started_at.is_some_and(|started| started.elapsed() < Duration::from_secs(2)) + window + .seek_started_at + .is_some_and(|started| started.elapsed() < Duration::from_secs(2)) }) => { "seeking" @@ -1528,8 +1629,18 @@ mod tests { seek_started_at: None, updated_at: std::time::Instant::now(), }; - assert!(is_probe_for_window(window, 500 * 1024 * 1024, 1024, 2 * 1024 * 1024)); - assert!(!is_probe_for_window(window, 500 * 1024 * 1024, 8 * 1024 * 1024, 2 * 1024 * 1024)); + assert!(is_probe_for_window( + window, + 500 * 1024 * 1024, + 1024, + 2 * 1024 * 1024 + )); + assert!(!is_probe_for_window( + window, + 500 * 1024 * 1024, + 8 * 1024 * 1024, + 2 * 1024 * 1024 + )); } #[test] diff --git a/src/app_state.rs b/src/app_state.rs index 9215ebb..aca8f23 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -255,25 +255,31 @@ struct AppCoreAction { } static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); -static STORE: OnceLock>> = OnceLock::new(); +static STORE: OnceLock>>>> = OnceLock::new(); -fn store() -> &'static Mutex> { +fn store() -> &'static Mutex>>> { STORE.get_or_init(|| Mutex::new(HashMap::new())) } // See headless_engine::lock_engines — recovering from poison keeps this store // usable after a single caught panic instead of going dark for every handle. -fn lock_store() -> std::sync::MutexGuard<'static, HashMap> { +fn lock_store() -> std::sync::MutexGuard<'static, HashMap>>> { store() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } pub fn create_app_core_state(initial_json: &str) -> u64 { - let state = serde_json::from_str(initial_json).unwrap_or_default(); + let state = match serde_json::from_str(initial_json) { + Ok(state) => state, + Err(error) => { + crate::log_sink::record("create_app_core_state", &error.to_string()); + return 0; + } + }; let mut states = lock_store(); let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); - states.insert(handle, state); + states.insert(handle, Arc::new(Mutex::new(state))); handle } @@ -282,21 +288,42 @@ pub fn destroy_app_core_state(handle: u64) -> bool { } pub fn app_core_state_json(handle: u64) -> Option { - let states = lock_store(); - states - .get(&handle) - .and_then(|state| serde_json::to_string(state).ok()) + let state = lock_store().get(&handle)?.clone(); + let snapshot = lock_app_state(&state)?.clone(); + serde_json::to_string(&snapshot).ok() } pub fn app_core_dispatch_json(handle: u64, action_json: &str) -> Option { - let action: AppCoreAction = serde_json::from_str(action_json).ok()?; - let mut states = lock_store(); - let state = states.get_mut(&handle)?; - reduce(state, action); - serde_json::to_string(state).ok() + let action: AppCoreAction = serde_json::from_str(action_json) + .map_err(|error| { + crate::log_sink::record("app_core_dispatch_json", &error.to_string()); + }) + .ok()?; + let state = lock_store().get(&handle)?.clone(); + let snapshot = { + let mut state = lock_app_state(&state)?; + if !reduce(&mut state, action) { + crate::log_sink::record("app_core_dispatch_json", "unknown action"); + return None; + } + state.clone() + }; + serde_json::to_string(&snapshot).ok() } -fn reduce(state: &mut AppCoreState, action: AppCoreAction) { +fn lock_app_state( + state: &Arc>, +) -> Option> { + match state.lock() { + Ok(guard) => Some(guard), + Err(_) => { + crate::log_sink::record("app_core_state", "poisoned handle; recreate the app state"); + None + } + } +} + +fn reduce(state: &mut AppCoreState, action: AppCoreAction) -> bool { match action.action_type.as_str() { "setHomeCategories" => state.home.categories = array_or_empty(action.value), "setHomeLoading" => state.home.is_loading = action.value.as_bool().unwrap_or(false), @@ -347,8 +374,9 @@ fn reduce(state: &mut AppCoreState, action: AppCoreAction) { "setCalendarLoading" => state.calendar.is_loading = action.value.as_bool().unwrap_or(false), "setLibraryUiState" => state.library.ui_state = action.value, "playerResetForEpisode" => reset_player_for_episode(&mut state.player, action.video_id), - _ => {} + _ => return false, } + true } fn array_or_empty(value: Value) -> Value { diff --git a/src/core_contract.rs b/src/core_contract.rs index b9f1e42..32d2aed 100644 --- a/src/core_contract.rs +++ b/src/core_contract.rs @@ -1,4 +1,5 @@ use crate::headless_engine; +use crate::runtime::EffectKind; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -143,6 +144,17 @@ pub fn core_capabilities_json(portable: bool) -> String { serde_json::to_string(&capabilities).unwrap_or_else(|_| "{}".to_string()) } +/// Machine-readable source for generated bindings, docs and contract drift tests. +pub fn core_contract_manifest_json() -> String { + serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), + "methods": ["engine.create", "engine.snapshot", "engine.dispatch", "engine.completeEffect", "engine.destroy", "app.create", "app.state", "app.dispatch", "app.destroy"], + "effectEnvelope": {"required": ["id", "type", "generation", "payload"], "optional": ["groupId", "priority", "dedupeKey", "cachePolicy", "timeoutMs"]}, + "effectTypes": EffectKind::ALL.iter().map(|kind| kind.as_str()).collect::>(), + "capabilities": {"native": CoreCapabilitySet::android_default(), "portable": CoreCapabilitySet::portable_minimum()}, + }).to_string() +} + fn parse_dispatch_result(json: &str) -> Option { let raw = serde_json::from_str::(json).ok()?; let state = raw.get("state").cloned().unwrap_or(Value::Null); @@ -157,7 +169,10 @@ fn parse_dispatch_result(json: &str) -> Option { }) .unwrap_or_default(); Some(CoreDispatchResult { - revision: raw.get("revision").and_then(Value::as_u64).unwrap_or_default(), + revision: raw + .get("revision") + .and_then(Value::as_u64) + .unwrap_or_default(), state: CoreState { value: state }, effects, }) diff --git a/src/ffi.rs b/src/ffi.rs index 2cf7149..bebf55a 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -371,6 +371,15 @@ mod tests { assert_eq!(destroyed["value"], json!(true)); } + #[test] + fn lifecycle_create_rejects_malformed_initial_state() { + for method in ["engine.create", "app.create"] { + let result = parse(&core_invoke(method, "{ malformed")); + assert_eq!(result["ok"], json!(false)); + assert_eq!(result["error"]["kind"], json!("invalid_args")); + } + } + #[test] fn calendar_plan_methods_route_and_compute() { let candidates = parse(&core_invoke( diff --git a/src/ffi/core_addon_store_routes.rs b/src/ffi/core_addon_store_routes.rs index bc26539..e1535a1 100644 --- a/src/ffi/core_addon_store_routes.rs +++ b/src/ffi/core_addon_store_routes.rs @@ -8,6 +8,7 @@ pub(super) fn route_core_contract(method: &str, args_json: &str) -> Outcome { .and_then(|o| o.get("portable").and_then(Value::as_bool)) .unwrap_or(false), )), + "coreContractManifest" => into_json(core_contract::core_contract_manifest_json()), _ => Err(fail( ErrorKind::UnknownMethod, @@ -80,9 +81,9 @@ pub(super) fn route_profile_avatar_pack(method: &str, args_json: &str) -> Outcom // args_json IS the request object for all of these. The platform owns // the HTTP calls between plans; this crate only validates and maps the // GitHub responses into the stable UI contract. - "profileAvatarPackManifestPlan" => { - opt_json(profile_avatar_pack::profile_avatar_pack_manifest_plan_json(args_json)) - } + "profileAvatarPackManifestPlan" => opt_json( + profile_avatar_pack::profile_avatar_pack_manifest_plan_json(args_json), + ), "profileAvatarPackRepositoryPlan" => { opt_json(profile_avatar_pack::profile_avatar_pack_repository_plan_json(args_json)) } diff --git a/src/ffi/engine_routes.rs b/src/ffi/engine_routes.rs index fcb6f21..1d80406 100644 --- a/src/ffi/engine_routes.rs +++ b/src/ffi/engine_routes.rs @@ -2,9 +2,14 @@ use super::*; pub(super) fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome { match method { - "engine.create" => Ok(json!( - headless_engine::create_headless_engine(args_json) as i64 - )), + "engine.create" => { + let handle = headless_engine::create_headless_engine(args_json); + if handle == 0 { + Err(fail(ErrorKind::InvalidArgs, "invalid initial engine state")) + } else { + Ok(json!(handle as i64)) + } + } "engine.snapshot" => result_json( headless_engine::headless_engine_snapshot_json(handle(args_json)?), method, @@ -33,7 +38,14 @@ pub(super) fn route_engine_lifecycle(method: &str, args_json: &str) -> Outcome { args_json )?))), "core.drainErrorLog" => opt_json(Some(crate::log_sink::drain_core_log_json())), - "app.create" => Ok(json!(app_state::create_app_core_state(args_json) as i64)), + "app.create" => { + let handle = app_state::create_app_core_state(args_json); + if handle == 0 { + Err(fail(ErrorKind::InvalidArgs, "invalid initial app state")) + } else { + Ok(json!(handle as i64)) + } + } "app.state" => result_json(app_state::app_core_state_json(handle(args_json)?), method), "app.dispatch" => { let args = object(args_json)?; diff --git a/src/ffi/intro_plugins_routes.rs b/src/ffi/intro_plugins_routes.rs index dbb9c0e..56461e4 100644 --- a/src/ffi/intro_plugins_routes.rs +++ b/src/ffi/intro_plugins_routes.rs @@ -9,9 +9,9 @@ pub(super) fn route_intro_segments(method: &str, args_json: &str) -> Outcome { "skipdbSegmentsPlan" => opt_json(intro_segments::skipdb_segments_plan_json(args_json)), "skipdbSubmitPlan" => opt_json(intro_segments::skipdb_submit_plan_json(args_json)), "parseSkipdbSegments" => opt_json(intro_segments::parse_skipdb_segments_json(args_json)), - "parsePublicmetadbSegments" => opt_json( - intro_segments::parse_publicmetadb_segments_json(args_json), - ), + "parsePublicmetadbSegments" => { + opt_json(intro_segments::parse_publicmetadb_segments_json(args_json)) + } "anilistMalId" => opt_json(intro_segments::anilist_mal_id_json(args_json)), "anilistId" => opt_json(intro_segments::anilist_id_json(args_json)), "anilistMediaIdPlan" => opt_json(intro_segments::anilist_media_id_plan_json(args_json)), @@ -21,12 +21,12 @@ pub(super) fn route_intro_segments(method: &str, args_json: &str) -> Outcome { opt_json(intro_segments::anime_skip_find_show_plan_json(args_json)) } "animeSkipShowId" => opt_json(intro_segments::anime_skip_show_id_json(args_json)), - "animeSkipFindEpisodesPlan" => { - opt_json(intro_segments::anime_skip_find_episodes_plan_json(args_json)) - } - "animeSkipFindTimestampsPlan" => { - opt_json(intro_segments::anime_skip_find_timestamps_plan_json(args_json)) - } + "animeSkipFindEpisodesPlan" => opt_json( + intro_segments::anime_skip_find_episodes_plan_json(args_json), + ), + "animeSkipFindTimestampsPlan" => opt_json( + intro_segments::anime_skip_find_timestamps_plan_json(args_json), + ), "parseAnimeSkipResults" => { opt_json(intro_segments::parse_anime_skip_results_json(args_json)) } @@ -34,9 +34,7 @@ pub(super) fn route_intro_segments(method: &str, args_json: &str) -> Outcome { "parseTheIntroDbSegments" => { opt_json(intro_segments::parse_the_introdb_segments_json(args_json)) } - "theIntroDbSubmitPlan" => { - opt_json(intro_segments::the_introdb_submit_plan_json(args_json)) - } + "theIntroDbSubmitPlan" => opt_json(intro_segments::the_introdb_submit_plan_json(args_json)), "uniqueIntroSegments" => { let args = object(args_json)?; opt_json(intro_segments::unique_intro_segments_json( diff --git a/src/headless_engine/complete_effect.rs b/src/headless_engine/complete_effect.rs index bc3865b..c39896a 100644 --- a/src/headless_engine/complete_effect.rs +++ b/src/headless_engine/complete_effect.rs @@ -8,25 +8,20 @@ use crate::runtime::{EffectEnvelope, EffectKind}; impl HeadlessEngine { pub(super) fn complete_effect(&mut self, result: EffectResultInput) -> Vec { - let Some(effect) = self - .state - .pending_effects - .iter() - .find(|effect| effect.id == result.effect_id) - .cloned() - else { + let Some(effect) = self.take_pending_effect(&result.effect_id) else { return vec![]; }; let generation = effect.generation; - // Unknown effect type (e.g. stale build mismatch between platform and core) — drop silently. + // A stale build mismatch still needs to clear the runtime registry entry. The FFI + // boundary has no structured logging facility yet, so keep this non-fatal while making + // the mismatch observable to hosts that collect stderr. let Some(kind) = EffectKind::from_str(&effect.kind) else { + eprintln!( + "fluxa-core contract mismatch: completion for unknown effect type '{}' (id '{}', generation {})", + effect.kind, effect.id, effect.generation + ); return vec![]; }; - self.state - .pending_effects - .retain(|pending| pending.id != result.effect_id); - self.delivered_effect_ids.remove(&result.effect_id); - self.effect_created_at.remove(&result.effect_id); let effect_type = kind.as_str(); // No wildcard arm: adding an EffectKind variant without handling it here is a compile error. diff --git a/src/headless_engine/contracts.rs b/src/headless_engine/contracts.rs index c69a7ab..6277acd 100644 --- a/src/headless_engine/contracts.rs +++ b/src/headless_engine/contracts.rs @@ -400,6 +400,4 @@ pub(super) struct StatePatch { pub trailer: Option, #[serde(skip_serializing_if = "Option::is_none")] pub plugins: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub pending_effects: Option>, } diff --git a/src/headless_engine/effect_bookkeeping.rs b/src/headless_engine/effect_bookkeeping.rs index e1fc46e..187e2c9 100644 --- a/src/headless_engine/effect_bookkeeping.rs +++ b/src/headless_engine/effect_bookkeeping.rs @@ -13,7 +13,11 @@ impl HeadlessEngine { payload: P, ) -> EffectEnvelope { let payload = serde_json::to_value(&payload).unwrap_or(Value::Null); - self.effect_raw(kind.as_str(), generation, payload) + let id = format!("fx-{}", self.next_effect_id); + self.next_effect_id += 1; + let envelope = EffectEnvelope::new(id.clone(), kind, generation, payload); + self.register_effect(id, envelope.clone()); + envelope } // For pass-through of effects emitted by sub-modules (e.g. player_flow) where @@ -27,17 +31,31 @@ impl HeadlessEngine { let id = format!("fx-{}", self.next_effect_id); self.next_effect_id += 1; let envelope = EffectEnvelope::raw(id.clone(), kind, generation, payload); - self.state.pending_effects.push(envelope.clone()); - self.effect_created_at.insert(id, Instant::now()); + self.register_effect(id, envelope.clone()); envelope } + fn register_effect(&mut self, id: String, envelope: EffectEnvelope) { + self.pending_effects.push(envelope); + self.effect_created_at.insert(id, Instant::now()); + } + + pub(super) fn take_pending_effect(&mut self, id: &str) -> Option { + let index = self + .pending_effects + .iter() + .position(|effect| effect.id == id)?; + let effect = self.pending_effects.remove(index); + self.delivered_effect_ids.remove(id); + self.effect_created_at.remove(id); + Some(effect) + } + // Drops any pending effect old enough that it's almost certainly been abandoned by // the platform rather than genuinely still in flight. Called opportunistically on // every dispatch/complete_effect so no background timer is needed. pub(super) fn expire_stale_pending_effects(&mut self, now: Instant) { let stale_ids: Vec = self - .state .pending_effects .iter() .filter(|effect| { @@ -48,7 +66,7 @@ impl HeadlessEngine { .map(|effect| effect.id.clone()) .collect(); for id in &stale_ids { - self.state.pending_effects.retain(|effect| &effect.id != id); + self.pending_effects.retain(|effect| &effect.id != id); self.delivered_effect_ids.remove(id); self.effect_created_at.remove(id); } @@ -80,11 +98,52 @@ impl HeadlessEngine { } pub(super) fn undelivered_pending_effects(&self) -> Vec { - self.state - .pending_effects + self.pending_effects .iter() .filter(|effect| !self.delivered_effect_ids.contains(&effect.id)) .cloned() .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::headless_engine::contracts::{EffectResultInput, EffectStatus}; + use serde_json::json; + + #[test] + fn typed_effects_use_their_schedule_metadata() { + let mut engine = HeadlessEngine::default(); + + let effect = engine.effect( + EffectKind::FetchCatalogPage, + 3, + json!({ "url": "https://example.com/catalog" }), + ); + + assert_eq!(effect.group_id.as_deref(), Some("addon")); + assert_eq!(effect.priority, 50); + assert_eq!(effect.cache_policy.as_deref(), Some("default")); + assert_eq!(effect.timeout_ms, Some(15_000)); + assert!(effect.dedupe_key.is_some()); + } + + #[test] + fn unknown_effect_completion_removes_the_runtime_entry() { + let mut engine = HeadlessEngine::default(); + let effect = engine.effect_raw("effectFromNewerCore", 1, json!({})); + + let effects = engine.complete_effect(EffectResultInput { + effect_id: effect.id, + status: EffectStatus::Ok, + value: serde_json::Value::Null, + error: serde_json::Value::Null, + }); + + assert!(effects.is_empty()); + assert!(engine.pending_effects.is_empty()); + assert!(engine.delivered_effect_ids.is_empty()); + assert!(engine.effect_created_at.is_empty()); + } +} diff --git a/src/headless_engine/mod.rs b/src/headless_engine/mod.rs index 8876599..9b9ae6e 100644 --- a/src/headless_engine/mod.rs +++ b/src/headless_engine/mod.rs @@ -26,11 +26,10 @@ mod youtube_cipher; use crate::core_error::{CoreError, LogAndDiscard}; use crate::runtime::EffectEnvelope; use contracts::{AppAction, DispatchResult, StatePatch}; -use serde::{Deserialize, Serialize}; use state::EngineState; use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use web_time::Instant; @@ -42,46 +41,44 @@ pub(crate) use contracts::EffectResultInput; // Anything genuinely still in flight completes well within this window. const EFFECT_EXPIRY: Duration = Duration::from_secs(300); -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "camelCase")] +#[derive(Debug, Default)] struct HeadlessEngine { - #[serde(default)] state: EngineState, - #[serde(default = "first_effect_id")] next_effect_id: u64, - #[serde(default)] revision: u64, // Ids handed to the platform at least once, awaiting their complete_effect call. // Never serialized — purely tracks delivery so the "drain the queue" fallback in // resolve_visible_effects doesn't hand out an effect that's already in flight as // if it were fresh work (which used to make an unrelated dispatch while a slow // effect was still running re-trigger a full duplicate execution of it). - #[serde(skip)] delivered_effect_ids: HashSet, // When each pending effect was created, for expire_stale_pending_effects. Never // serialized — Instant isn't a portable wall-clock value, just an internal timer. - #[serde(skip)] effect_created_at: HashMap, -} - -fn first_effect_id() -> u64 { - 1 + // Runtime-only effect registry. Effect payloads can contain credentials and must never + // be included in a UI state snapshot or StatePatch. + pending_effects: Vec, } static ENGINE_COUNTER: AtomicU64 = AtomicU64::new(1); -static ENGINES: OnceLock>> = OnceLock::new(); +static ENGINES: OnceLock>>>> = OnceLock::new(); pub fn create_headless_engine(initial_json: &str) -> u64 { let mut engine = HeadlessEngine { next_effect_id: 1, ..HeadlessEngine::default() }; - if let Ok(initial_state) = serde_json::from_str::(initial_json) { - engine.state = initial_state; - } + let initial_state = match serde_json::from_str::(initial_json) { + Ok(state) => state, + Err(error) => { + crate::log_sink::record("create_headless_engine", &error.to_string()); + return 0; + } + }; + engine.state = initial_state; let mut map = lock_engines(); let handle = ENGINE_COUNTER.fetch_add(1, Ordering::Relaxed); - map.insert(handle, engine); + map.insert(handle, Arc::new(Mutex::new(engine))); handle } @@ -92,8 +89,9 @@ pub fn destroy_headless_engine(handle: u64) -> bool { pub fn headless_engine_snapshot_json(handle: u64) -> Option { let state = { let map = lock_engines(); - map.get(&handle)?.state.clone() + map.get(&handle)?.clone() }; + let state = lock_engine(&state)?.state.clone(); serde_json::to_string(&state).ok() } @@ -105,9 +103,8 @@ pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option engine, + let engine = match lock_engines().get(&handle) { + Some(engine) => Arc::clone(engine), None => { return CoreError::NotFound { context: "headless_engine_dispatch_json", @@ -115,6 +112,12 @@ pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option O }) .log_discard()?; let (revision, patch, visible_effects) = { - let mut map = lock_engines(); - let engine = match map.get_mut(&handle) { - Some(engine) => engine, + let engine = match lock_engines().get(&handle) { + Some(engine) => Arc::clone(engine), None => { return CoreError::NotFound { context: "headless_engine_complete_effect_json", @@ -142,6 +144,12 @@ pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> O .log_and_none(); } }; + let mut engine = lock_engine(&engine).or_else(|| { + CoreError::NotFound { + context: "headless_engine_complete_effect_json (poisoned handle)", + } + .log_and_none() + })?; engine.expire_stale_pending_effects(Instant::now()); let effects = engine.complete_effect(result); let visible_effects = engine.resolve_visible_effects(effects); @@ -153,23 +161,41 @@ pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> O // Deliberately takes owned before/after snapshots rather than a reference to the locked // engine: diffing and serializing a large state (e.g. a big discover catalog) can take -// 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(revision: u64, state: StatePatch, effects: Vec) -> Option { - serde_json::to_string(&DispatchResult { revision, state, effects }).ok() +// over a second. Callers clone what they need and drop the per-engine lock before calling +// this, so unrelated engine handles continue to make progress. +fn result_patch_json( + revision: u64, + state: StatePatch, + effects: Vec, +) -> Option { + serde_json::to_string(&DispatchResult { + revision, + state, + effects, + }) + .ok() } -fn engines() -> &'static Mutex> { +fn engines() -> &'static Mutex>>> { ENGINES.get_or_init(|| Mutex::new(HashMap::new())) } -// A panic while a request held this lock poisons it; with catch_unwind now -// guarding the FFI boundary, a single caught panic must not silently make -// every engine handle inaccessible for the rest of the process's life. -// Recovering the guard accepts that one engine's state might be left -// mid-update, which is still far better than every other handle going dark. -fn lock_engines() -> std::sync::MutexGuard<'static, HashMap> { +fn lock_engine( + engine: &Arc>, +) -> Option> { + match engine.lock() { + Ok(guard) => Some(guard), + Err(_) => { + crate::log_sink::record("headless_engine", "poisoned handle; recreate the engine"); + None + } + } +} + +// A panic while a request held the registry lock poisons it; recover so a caught panic +// does not make every handle inaccessible. A poisoned engine itself is isolated to its +// own per-handle lock. +fn lock_engines() -> std::sync::MutexGuard<'static, HashMap>>> { engines() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) diff --git a/src/headless_engine/state.rs b/src/headless_engine/state.rs index da888d0..a5997c3 100644 --- a/src/headless_engine/state.rs +++ b/src/headless_engine/state.rs @@ -14,7 +14,6 @@ use super::search::SearchState; use super::settings::SettingsState; use super::sync::SyncState; use super::trailer::TrailerState; -use crate::runtime::EffectEnvelope; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::ops::{Deref, DerefMut}; @@ -205,7 +204,6 @@ pub(super) struct EngineState { pub(super) offline: Tracked, pub(super) trailer: Tracked, pub(super) plugins: Tracked, - pub(super) pending_effects: Tracked>, #[serde(rename = "_runtime")] pub(super) runtime: RuntimeGenerations, } @@ -230,7 +228,6 @@ impl EngineState { offline: self.offline.take_if_dirty(), trailer: self.trailer.take_if_dirty(), plugins: self.plugins.take_if_dirty(), - pending_effects: self.pending_effects.take_if_dirty(), } } } diff --git a/src/headless_engine/tests/engine.rs b/src/headless_engine/tests/engine.rs index 6f3636c..a32b271 100644 --- a/src/headless_engine/tests/engine.rs +++ b/src/headless_engine/tests/engine.rs @@ -17,3 +17,20 @@ fn engines_lock_survives_a_panic_while_held_by_another_thread() { assert!(headless_engine_snapshot_json(handle).is_some()); assert!(destroy_headless_engine(handle)); } + +#[test] +fn poisoned_engine_handle_is_rejected_without_affecting_other_handles() { + let poisoned_handle = create_headless_engine("{}"); + let healthy_handle = create_headless_engine("{}"); + let poisoned_engine = lock_engines().get(&poisoned_handle).unwrap().clone(); + let poisoner = std::thread::spawn(move || { + let _guard = poisoned_engine.lock().unwrap(); + panic!("simulated engine update panic"); + }); + assert!(poisoner.join().is_err()); + + assert!(headless_engine_snapshot_json(poisoned_handle).is_none()); + assert!(headless_engine_snapshot_json(healthy_handle).is_some()); + assert!(destroy_headless_engine(poisoned_handle)); + assert!(destroy_headless_engine(healthy_handle)); +} diff --git a/src/headless_engine/tests/library.rs b/src/headless_engine/tests/library.rs index b223d08..6302903 100644 --- a/src/headless_engine/tests/library.rs +++ b/src/headless_engine/tests/library.rs @@ -201,13 +201,13 @@ fn expire_stale_pending_effects_drops_old_but_not_recent_effects() { // Still well within the window — nothing genuinely in flight should be dropped. engine.expire_stale_pending_effects(Instant::now()); - assert_eq!(engine.state.pending_effects.len(), 2); + assert_eq!(engine.pending_effects.len(), 2); // Past the expiry window — abandoned effects (platform never called // complete_effect) get swept from all three bookkeeping collections. let far_future = Instant::now() + Duration::from_secs(301); engine.expire_stale_pending_effects(far_future); - assert!(engine.state.pending_effects.is_empty()); + assert!(engine.pending_effects.is_empty()); assert!(engine.delivered_effect_ids.is_empty()); assert!(engine.effect_created_at.is_empty()); } diff --git a/src/intro_segments.rs b/src/intro_segments.rs index 619fca8..830963c 100644 --- a/src/intro_segments.rs +++ b/src/intro_segments.rs @@ -1,7 +1,12 @@ use serde_json::{Value, json}; use std::collections::HashMap; -fn request_plan(url: String, method: &str, headers: &[(&str, String)], body: Option<&Value>) -> Value { +fn request_plan( + url: String, + method: &str, + headers: &[(&str, String)], + body: Option<&Value>, +) -> Value { let headers_obj: serde_json::Map = headers .iter() .map(|(k, v)| (k.to_string(), Value::String(v.clone()))) @@ -19,7 +24,9 @@ pub(crate) fn intro_db_segments_plan_json(args_json: &str) -> Option { let imdb_id = args.get("imdbId").and_then(Value::as_str)?; let season = args.get("season").and_then(Value::as_i64)?; let episode = args.get("episode").and_then(Value::as_i64)?; - let url = format!("https://api.introdb.app/segments?imdb_id={imdb_id}&season={season}&episode={episode}"); + let url = format!( + "https://api.introdb.app/segments?imdb_id={imdb_id}&season={season}&episode={episode}" + ); serde_json::to_string(&request_plan(url, "GET", &[], None)).ok() } @@ -64,7 +71,9 @@ pub(crate) fn skipdb_segments_plan_json(args_json: &str) -> Option { let imdb_id = args.get("imdbId").and_then(Value::as_str)?; let season = args.get("season").and_then(Value::as_i64)?; let episode = args.get("episode").and_then(Value::as_i64)?; - let url = format!("https://api.skipdb.tv/api/segments?imdb_id={imdb_id}&season={season}&episode={episode}"); + let url = format!( + "https://api.skipdb.tv/api/segments?imdb_id={imdb_id}&season={season}&episode={episode}" + ); serde_json::to_string(&request_plan(url, "GET", &[], None)).ok() } @@ -135,7 +144,10 @@ fn clean_anilist_title(title: &str) -> String { let trimmed = title.trim_end(); if let Some(idx) = trimmed.rfind(" (") { let inner = &trimmed[idx + 2..]; - if trimmed.ends_with(')') && inner.len() == 5 && inner[..4].chars().all(|c| c.is_ascii_digit()) { + if trimmed.ends_with(')') + && inner.len() == 5 + && inner[..4].chars().all(|c| c.is_ascii_digit()) + { return trimmed[..idx].trim().to_string(); } } @@ -603,10 +615,22 @@ fn the_introdb_canonical_type(wire: &str) -> &'static str { pub(crate) fn the_introdb_media_plan_json(args_json: &str) -> Option { let args: Value = serde_json::from_str(args_json).ok()?; let tmdb_id = args.get("tmdbId").and_then(Value::as_i64); - let imdb_id = args.get("imdbId").and_then(Value::as_str).filter(|s| s.starts_with("tt")); - let season = args.get("season").and_then(Value::as_i64).filter(|s| *s > 0); - let episode = args.get("episode").and_then(Value::as_i64).filter(|e| *e > 0); - let duration_ms = args.get("durationMs").and_then(Value::as_i64).filter(|d| *d > 0); + let imdb_id = args + .get("imdbId") + .and_then(Value::as_str) + .filter(|s| s.starts_with("tt")); + let season = args + .get("season") + .and_then(Value::as_i64) + .filter(|s| *s > 0); + let episode = args + .get("episode") + .and_then(Value::as_i64) + .filter(|e| *e > 0); + let duration_ms = args + .get("durationMs") + .and_then(Value::as_i64) + .filter(|d| *d > 0); let mut query = match (tmdb_id, imdb_id) { (Some(id), _) => format!("tmdb_id={id}"), @@ -625,12 +649,18 @@ pub(crate) fn the_introdb_media_plan_json(args_json: &str) -> Option { pub(crate) fn parse_the_introdb_segments_json(args_json: &str) -> Option { let args: Value = serde_json::from_str(args_json).ok()?; - let response: Value = serde_json::from_str(args.get("responseJson").and_then(Value::as_str)?).ok()?; - let duration_ms = args.get("durationMs").and_then(Value::as_i64).filter(|d| *d > 0); + let response: Value = + serde_json::from_str(args.get("responseJson").and_then(Value::as_str)?).ok()?; + let duration_ms = args + .get("durationMs") + .and_then(Value::as_i64) + .filter(|d| *d > 0); let mut segments = Vec::new(); for wire_type in &["intro", "recap", "credits", "preview"] { - let Some(items) = response.get(*wire_type).and_then(Value::as_array) else { continue }; + let Some(items) = response.get(*wire_type).and_then(Value::as_array) else { + continue; + }; let canonical = the_introdb_canonical_type(wire_type); for item in items { let start_ms = item.get("start_ms").and_then(Value::as_i64).unwrap_or(0); @@ -660,8 +690,14 @@ pub(crate) fn the_introdb_submit_plan_json(args_json: &str) -> Option { let end_sec = args.get("endSec").and_then(Value::as_f64); let video_duration_ms = args.get("videoDurationMs").and_then(Value::as_i64); let imdb_id = args.get("imdbId").and_then(Value::as_str); - let season = args.get("season").and_then(Value::as_i64).filter(|s| *s > 0); - let episode = args.get("episode").and_then(Value::as_i64).filter(|e| *e > 0); + let season = args + .get("season") + .and_then(Value::as_i64) + .filter(|s| *s > 0); + let episode = args + .get("episode") + .and_then(Value::as_i64) + .filter(|e| *e > 0); let mut body = json!({ "tmdb_id": tmdb_id, @@ -727,8 +763,10 @@ mod tests { .iter() .any(|s| s["type"] == "intro" && s["startTime"] == 0 && s["endTime"] == 62000) ); - assert!(segments.iter().any(|s| s["type"] == "outro" - && s["startTime"] == 3180000 - && s["endTime"] == 3240000)); + assert!( + segments.iter().any(|s| s["type"] == "outro" + && s["startTime"] == 3180000 + && s["endTime"] == 3240000) + ); } } diff --git a/src/plugin_runtime/host_functions.rs b/src/plugin_runtime/host_functions.rs index 6623d9f..acfab0f 100644 --- a/src/plugin_runtime/host_functions.rs +++ b/src/plugin_runtime/host_functions.rs @@ -1,5 +1,8 @@ use super::dom_bridge::DomBridge; -use super::{PluginHttpClient, PluginHttpRequest, crypto_bridge}; +use super::{ + PLUGIN_MAX_RESPONSE_BODY_BYTES, PluginHttpClient, PluginHttpRequest, crypto_bridge, + plugin_http_request_error, +}; use rquickjs::{Ctx, Function}; use std::collections::HashMap; use std::rc::Rc; @@ -14,18 +17,31 @@ fn native_fetch( follow_redirects: bool, ) -> String { let headers: HashMap = serde_json::from_str(&headers_json).unwrap_or_default(); - let response = client.fetch(PluginHttpRequest { + let request = PluginHttpRequest { method, url, headers, body, follow_redirects, - }); + }; + if let Some(error) = plugin_http_request_error(&request) { + return format!( + "{{\"ok\":false,\"status\":0,\"body\":\"\",\"error\":{}}}", + serde_json::to_string(error).unwrap_or_else(|_| "\"plugin request rejected\"".into()) + ); + } + let mut response = client.fetch(request); + if response.body.len() > PLUGIN_MAX_RESPONSE_BODY_BYTES { + response.body.truncate(PLUGIN_MAX_RESPONSE_BODY_BYTES); + response.ok = false; + response.error = Some("plugin response body exceeds limit".to_string()); + } format!( - "{{\"ok\":{},\"status\":{},\"body\":{}}}", + "{{\"ok\":{},\"status\":{},\"body\":{},\"error\":{}}}", response.ok, response.status, - serde_json::to_string(&response.body).unwrap_or_else(|_| "\"\"".into()) + serde_json::to_string(&response.body).unwrap_or_else(|_| "\"\"".into()), + serde_json::to_string(&response.error).unwrap_or_else(|_| "null".into()) ) } diff --git a/src/plugin_runtime/mod.rs b/src/plugin_runtime/mod.rs index d4616fa..781b5b8 100644 --- a/src/plugin_runtime/mod.rs +++ b/src/plugin_runtime/mod.rs @@ -13,6 +13,57 @@ use std::time::Duration; pub(super) const PLUGIN_TIMEOUT_SECS: u64 = 60; pub(super) const PLUGIN_MEMORY_LIMIT: usize = 256 * 1024 * 1024; +pub const PLUGIN_MAX_REQUEST_BODY_BYTES: usize = 1_048_576; +pub const PLUGIN_MAX_RESPONSE_BODY_BYTES: usize = 8 * 1_048_576; + +pub fn plugin_http_request_error(request: &PluginHttpRequest) -> Option<&'static str> { + let lower = request.url.to_ascii_lowercase(); + if !(lower.starts_with("http://") || lower.starts_with("https://")) { + return Some("only http and https plugin URLs are allowed"); + } + let authority = lower + .split("//") + .nth(1)? + .split('/') + .next()? + .split('@') + .next_back()?; + let host = authority.split(':').next().unwrap_or(authority); + let private_172 = host + .strip_prefix("172.") + .and_then(|rest| rest.split('.').next()) + .and_then(|part| part.parse::().ok()) + .is_some_and(|second| (16..=31).contains(&second)); + if host == "localhost" + || host.ends_with(".localhost") + || host == "::1" + || host.starts_with("127.") + || host.starts_with("10.") + || host.starts_with("192.168.") + || host.starts_with("169.254.") + || host.starts_with("0.") + || private_172 + || host.starts_with("fc") + || host.starts_with("fd") + || host.starts_with("fe80:") + { + return Some("private or loopback plugin URL is not allowed"); + } + if let Some(body) = &request.body + && body.len() > PLUGIN_MAX_REQUEST_BODY_BYTES + { + return Some("plugin request body exceeds limit"); + } + if request.headers.keys().any(|key| { + !matches!( + key.to_ascii_lowercase().as_str(), + "accept" | "accept-language" | "content-type" | "origin" | "referer" | "user-agent" + ) + }) { + return Some("plugin request contains a disallowed header"); + } + None +} #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[cfg_attr(feature = "uniffi-bindings", derive(uniffi::Record))] @@ -115,6 +166,20 @@ mod tests { Arc::new(MockHttpClient) } + #[test] + fn plugin_request_policy_rejects_private_hosts_and_credentials() { + let mut request = PluginHttpRequest { + url: "http://127.0.0.1/admin".to_string(), + ..PluginHttpRequest::default() + }; + assert!(plugin_http_request_error(&request).is_some()); + request.url = "https://example.com/".to_string(); + request + .headers + .insert("Authorization".to_string(), "secret".to_string()); + assert!(plugin_http_request_error(&request).is_some()); + } + fn run_scraper(code: &str, tmdb_id: &str, media_type: &str) -> String { execute_scraper( mock_client(), diff --git a/src/profile_avatar_pack.rs b/src/profile_avatar_pack.rs index c5358c0..a28057b 100644 --- a/src/profile_avatar_pack.rs +++ b/src/profile_avatar_pack.rs @@ -213,7 +213,10 @@ fn direct_manifest_url(input: &str) -> Option { fn is_manifest_filename(url: &str) -> bool { let path = url.split('?').next().unwrap_or(url); let filename = path.rsplit('/').next().unwrap_or(""); - matches!(filename.to_ascii_lowercase().as_str(), "pack.json" | "json.pack") + matches!( + filename.to_ascii_lowercase().as_str(), + "pack.json" | "json.pack" + ) } fn parse_repository_url(input: &str) -> Option { @@ -250,8 +253,10 @@ fn extract_target_path(rest: &str) -> Option { } let path = percent_decode(path); let (directory, filename) = path.rsplit_once('/').unwrap_or(("", &path)); - let directory = if matches!(filename.to_ascii_lowercase().as_str(), "pack.json" | "json.pack") - { + let directory = if matches!( + filename.to_ascii_lowercase().as_str(), + "pack.json" | "json.pack" + ) { directory } else { path.as_str() @@ -465,7 +470,10 @@ mod tests { .unwrap(), ) .unwrap(); - assert_eq!(output["manifestUrl"], "https://example.com/packs/solo-leveling/pack.json"); + assert_eq!( + output["manifestUrl"], + "https://example.com/packs/solo-leveling/pack.json" + ); } #[test] diff --git a/src/runtime/effects.rs b/src/runtime/effects.rs index ff9c393..d6ddd8d 100644 --- a/src/runtime/effects.rs +++ b/src/runtime/effects.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; /// Exhaustive catalog of all effect types the headless engine can emit. /// @@ -60,6 +61,57 @@ pub enum EffectKind { } impl EffectKind { + pub const ALL: &[Self] = &[ + Self::ClearPlaybackProgress, + Self::EnqueueOfflineDownload, + Self::EnqueueTraktScrobble, + Self::ExchangeAuthCode, + Self::ExecutePlugin, + Self::FetchAddonManifest, + Self::FetchAddonResource, + Self::FetchCatalogPage, + Self::FetchDiscoverPage, + Self::FetchDetailSecondary, + Self::FetchDetailStreams, + Self::FetchIntroSegments, + Self::FetchMetaDetail, + Self::FetchMetaDetailLookup, + Self::FetchPluginManifest, + Self::FetchSeasonEpisodes, + Self::FetchSubtitles, + Self::FetchYoutubeTrailerPlayer, + Self::FetchYoutubeTrailerPlayerScript, + Self::FetchYoutubeTrailerWatchConfig, + Self::LoadStreams, + Self::NotifyReleasedEpisodes, + Self::PrefetchDetailStreams, + Self::PrefetchNextEpisodeStreams, + Self::PrepareDirectPlayback, + Self::ReadCalendarMonth, + Self::ReadDetailLocalState, + Self::ReadDiscoverCatalogFilters, + Self::ReadHomeBootstrap, + Self::RefreshContinueWatching, + Self::ReadLibraryState, + Self::ReadPlaybackProgress, + Self::RefreshAuthToken, + Self::RefreshInstalledAddons, + Self::ReplaceExternalContinueWatching, + Self::ResolveIntroImdbId, + Self::RunAuthFlow, + Self::RunDiscover, + Self::RunExternalSync, + Self::RunSearch, + Self::StartTorrentStream, + Self::StopTorrent, + Self::SyncExternalIntegration, + Self::SyncWatchedState, + Self::UpdateCalendarWidget, + Self::WriteFeedback, + Self::WriteLibraryCommand, + Self::WritePlaybackProgress, + Self::WriteSettings, + ]; pub fn as_str(self) -> &'static str { match self { EffectKind::ClearPlaybackProgress => "clearPlaybackProgress", @@ -196,7 +248,10 @@ pub struct EffectEnvelope { pub payload: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] pub group_id: Option, - #[serde(default = "default_priority", skip_serializing_if = "is_default_priority")] + #[serde( + default = "default_priority", + skip_serializing_if = "is_default_priority" + )] pub priority: u8, #[serde(skip_serializing_if = "Option::is_none")] pub dedupe_key: Option, @@ -210,11 +265,9 @@ impl EffectEnvelope { pub fn new(id: String, kind: EffectKind, generation: u64, payload: serde_json::Value) -> Self { let (group_id, priority, cache_policy, timeout_ms) = effect_schedule(kind); Self { - dedupe_key: group_id.as_ref().and_then(|_| { - serde_json::to_string(&payload) - .ok() - .map(|payload_key| format!("{}:{generation}:{payload_key}", kind.as_str())) - }), + dedupe_key: group_id + .as_ref() + .and_then(|_| payload_dedupe_key(kind, generation, &payload)), id, kind: kind.as_str().to_owned(), generation, @@ -241,6 +294,20 @@ impl EffectEnvelope { } } +fn payload_dedupe_key( + kind: EffectKind, + generation: u64, + payload: &serde_json::Value, +) -> Option { + let payload = serde_json::to_string(payload).ok()?; + let digest = Sha256::digest(payload.as_bytes()); + let hash = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Some(format!("{}:{generation}:{hash}", kind.as_str())) +} + fn default_priority() -> u8 { 100 } @@ -256,16 +323,32 @@ fn effect_schedule(kind: EffectKind) -> (Option, u8, Option, Opt | EffectKind::FetchDetailStreams | EffectKind::PrefetchDetailStreams | EffectKind::PrefetchNextEpisodeStreams - | EffectKind::RefreshInstalledAddons => (Some("addon".to_string()), 50, Some("default".to_string()), Some(15_000)), - EffectKind::RunSearch | EffectKind::RunDiscover => (Some("addon".to_string()), 40, Some("default".to_string()), Some(15_000)), - EffectKind::ExecutePlugin => (Some("plugin".to_string()), 60, Some("no-store".to_string()), Some(10_000)), + | EffectKind::RefreshInstalledAddons => ( + Some("addon".to_string()), + 50, + Some("default".to_string()), + Some(15_000), + ), + EffectKind::RunSearch | EffectKind::RunDiscover => ( + Some("addon".to_string()), + 40, + Some("default".to_string()), + Some(15_000), + ), + EffectKind::ExecutePlugin => ( + Some("plugin".to_string()), + 60, + Some("no-store".to_string()), + Some(10_000), + ), _ => (None, 100, None, None), } } #[cfg(test)] mod tests { - use super::EffectKind; + use super::{EffectEnvelope, EffectKind}; + use serde_json::json; #[test] fn as_str_and_from_str_roundtrip_for_every_variant() { @@ -328,4 +411,36 @@ mod tests { fn from_str_rejects_unknown_value() { assert_eq!(EffectKind::from_str("notAnEffect"), None); } + + #[test] + fn scheduled_effect_dedupe_keys_hash_the_payload() { + let first = EffectEnvelope::new( + "fx-1".to_string(), + EffectKind::FetchAddonResource, + 7, + json!({ "url": "https://example.com/one", "token": "secret" }), + ); + let duplicate = EffectEnvelope::new( + "fx-2".to_string(), + EffectKind::FetchAddonResource, + 7, + json!({ "url": "https://example.com/one", "token": "secret" }), + ); + let different = EffectEnvelope::new( + "fx-3".to_string(), + EffectKind::FetchAddonResource, + 7, + json!({ "url": "https://example.com/two", "token": "secret" }), + ); + + assert_eq!(first.dedupe_key, duplicate.dedupe_key); + assert_ne!(first.dedupe_key, different.dedupe_key); + assert!( + !first + .dedupe_key + .as_deref() + .unwrap_or_default() + .contains("secret") + ); + } } diff --git a/src/stream_policy/torrent_runtime.rs b/src/stream_policy/torrent_runtime.rs index 69a2990..d97a084 100644 --- a/src/stream_policy/torrent_runtime.rs +++ b/src/stream_policy/torrent_runtime.rs @@ -180,10 +180,22 @@ mod tests { #[test] fn stream_url_omits_missing_or_invalid_duration() { let missing = build_torrent_stream_url( - "http://127.0.0.1:8090", "magnet:?xt=urn:btih:abc", "Example", None, true, false, None, + "http://127.0.0.1:8090", + "magnet:?xt=urn:btih:abc", + "Example", + None, + true, + false, + None, ); let zero = build_torrent_stream_url( - "http://127.0.0.1:8090", "magnet:?xt=urn:btih:abc", "Example", None, true, false, Some(0), + "http://127.0.0.1:8090", + "magnet:?xt=urn:btih:abc", + "Example", + None, + true, + false, + Some(0), ); assert!(!missing.contains("durationMs=")); diff --git a/tests/wire/expected/detail_load_requested.json b/tests/wire/expected/detail_load_requested.json index c4d290a..9226fc6 100644 --- a/tests/wire/expected/detail_load_requested.json +++ b/tests/wire/expected/detail_load_requested.json @@ -58,29 +58,6 @@ "userAddons": [], "visibleStreams": [], "watchedVideoIds": [] - }, - "pendingEffects": [ - { - "generation": 1, - "id": "fx-1", - "payload": { - "contentType": "movie", - "id": "tt1", - "language": "en", - "profile": null, - "sourceAddonCatalogType": "", - "sourceAddonTransportUrl": "" - }, - "type": "fetchMetaDetail" - }, - { - "generation": 1, - "id": "fx-2", - "payload": { - "id": "tt1" - }, - "type": "readPlaybackProgress" - } - ] + } } } diff --git a/tests/wire/expected/scrobble_requested.json b/tests/wire/expected/scrobble_requested.json index 61050e7..5cd58da 100644 --- a/tests/wire/expected/scrobble_requested.json +++ b/tests/wire/expected/scrobble_requested.json @@ -15,21 +15,5 @@ } ], "revision": 1, - "state": { - "pendingEffects": [ - { - "generation": 1, - "id": "fx-1", - "payload": { - "actionName": "pause", - "itemId": "tt1", - "metaType": "movie", - "profile": null, - "progress": 42.5, - "token": "trakt-token" - }, - "type": "enqueueTraktScrobble" - } - ] - } + "state": {} } diff --git a/tests/wire/expected/settings_changed.json b/tests/wire/expected/settings_changed.json index e27b2e0..6fac98b 100644 --- a/tests/wire/expected/settings_changed.json +++ b/tests/wire/expected/settings_changed.json @@ -12,17 +12,6 @@ ], "revision": 1, "state": { - "pendingEffects": [ - { - "generation": 1, - "id": "fx-1", - "payload": { - "key": "subtitleLanguage", - "value": "en" - }, - "type": "writeSettings" - } - ], "settings": { "lastWriteError": null, "values": { diff --git a/tests/wire/expected/toggle_watchlist_requested.json b/tests/wire/expected/toggle_watchlist_requested.json index 7aef510..13fe1e2 100644 --- a/tests/wire/expected/toggle_watchlist_requested.json +++ b/tests/wire/expected/toggle_watchlist_requested.json @@ -44,24 +44,6 @@ "savedPlaybackProgress": null, "watched": null, "watchlist": null - }, - "pendingEffects": [ - { - "generation": 1, - "id": "fx-1", - "payload": { - "command": { - "item": { - "id": "tt1", - "name": "Movie", - "type": "movie" - }, - "type": "toggleWatchlist" - }, - "profileId": "guest" - }, - "type": "writeLibraryCommand" - } - ] + } } }