mirror of
https://github.com/FluxaMedia/fluxa-core.git
synced 2026-08-09 00:17:34 +00:00
205 lines
7.1 KiB
Rust
205 lines
7.1 KiB
Rust
mod addons;
|
|
mod auth;
|
|
mod calendar;
|
|
mod complete_effect;
|
|
mod contracts;
|
|
mod detail;
|
|
mod discover;
|
|
mod dispatch;
|
|
mod effect_bookkeeping;
|
|
mod helpers;
|
|
mod home;
|
|
mod library;
|
|
mod navigation;
|
|
mod offline;
|
|
mod player;
|
|
mod plugins;
|
|
mod profile;
|
|
mod search;
|
|
mod settings;
|
|
mod state;
|
|
mod sync;
|
|
mod trailer;
|
|
#[cfg(feature = "plugin-js-engine")]
|
|
mod youtube_cipher;
|
|
|
|
use crate::core_error::{CoreError, LogAndDiscard};
|
|
use crate::runtime::EffectEnvelope;
|
|
use contracts::{AppAction, DispatchResult, StatePatch};
|
|
use state::EngineState;
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use std::time::Duration;
|
|
use web_time::Instant;
|
|
|
|
pub(crate) use contracts::EffectResultInput;
|
|
|
|
// If the platform never calls complete_effect for an effect (a transient IPC failure on
|
|
// the completion call, a swallowed exception, etc.), it would otherwise sit in
|
|
// pending_effects/delivered_effect_ids forever for the life of the engine instance.
|
|
// Anything genuinely still in flight completes well within this window.
|
|
const EFFECT_EXPIRY: Duration = Duration::from_secs(300);
|
|
|
|
#[derive(Debug, Default)]
|
|
struct HeadlessEngine {
|
|
state: EngineState,
|
|
next_effect_id: u64,
|
|
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).
|
|
delivered_effect_ids: HashSet<String>,
|
|
// 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.
|
|
effect_created_at: HashMap<String, Instant>,
|
|
// Runtime-only effect registry. Effect payloads can contain credentials and must never
|
|
// be included in a UI state snapshot or StatePatch.
|
|
pending_effects: Vec<EffectEnvelope>,
|
|
}
|
|
|
|
static ENGINE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
static ENGINES: OnceLock<Mutex<HashMap<u64, Arc<Mutex<HeadlessEngine>>>>> = OnceLock::new();
|
|
|
|
pub fn create_headless_engine(initial_json: &str) -> u64 {
|
|
let mut engine = HeadlessEngine {
|
|
next_effect_id: 1,
|
|
..HeadlessEngine::default()
|
|
};
|
|
let initial_state = match serde_json::from_str::<EngineState>(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, Arc::new(Mutex::new(engine)));
|
|
handle
|
|
}
|
|
|
|
pub fn destroy_headless_engine(handle: u64) -> bool {
|
|
lock_engines().remove(&handle).is_some()
|
|
}
|
|
|
|
pub fn headless_engine_snapshot_json(handle: u64) -> Option<String> {
|
|
let state = {
|
|
let map = lock_engines();
|
|
map.get(&handle)?.clone()
|
|
};
|
|
let state = lock_engine(&state)?.state.clone();
|
|
serde_json::to_string(&state).ok()
|
|
}
|
|
|
|
pub fn headless_engine_dispatch_json(handle: u64, action_json: &str) -> Option<String> {
|
|
let action: AppAction = serde_json::from_str(action_json)
|
|
.map_err(|e| CoreError::BadInput {
|
|
context: "headless_engine_dispatch_json",
|
|
detail: e.to_string(),
|
|
})
|
|
.log_discard()?;
|
|
let (revision, patch, visible_effects) = {
|
|
let engine = match lock_engines().get(&handle) {
|
|
Some(engine) => Arc::clone(engine),
|
|
None => {
|
|
return CoreError::NotFound {
|
|
context: "headless_engine_dispatch_json",
|
|
}
|
|
.log_and_none();
|
|
}
|
|
};
|
|
let mut engine = lock_engine(&engine).or_else(|| {
|
|
CoreError::NotFound {
|
|
context: "headless_engine_dispatch_json (poisoned handle)",
|
|
}
|
|
.log_and_none()
|
|
})?;
|
|
engine.expire_stale_pending_effects(Instant::now());
|
|
let effects = engine.dispatch(action);
|
|
let visible_effects = engine.resolve_visible_effects(effects);
|
|
engine.revision = engine.revision.saturating_add(1);
|
|
(engine.revision, engine.state.diff_dirty(), visible_effects)
|
|
};
|
|
result_patch_json(revision, patch, visible_effects)
|
|
}
|
|
|
|
pub fn headless_engine_complete_effect_json(handle: u64, result_json: &str) -> Option<String> {
|
|
let result: EffectResultInput = serde_json::from_str(result_json)
|
|
.map_err(|e| CoreError::BadInput {
|
|
context: "headless_engine_complete_effect_json",
|
|
detail: e.to_string(),
|
|
})
|
|
.log_discard()?;
|
|
let (revision, patch, visible_effects) = {
|
|
let engine = match lock_engines().get(&handle) {
|
|
Some(engine) => Arc::clone(engine),
|
|
None => {
|
|
return CoreError::NotFound {
|
|
context: "headless_engine_complete_effect_json",
|
|
}
|
|
.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);
|
|
engine.revision = engine.revision.saturating_add(1);
|
|
(engine.revision, engine.state.diff_dirty(), visible_effects)
|
|
};
|
|
result_patch_json(revision, patch, visible_effects)
|
|
}
|
|
|
|
// 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. 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<EffectEnvelope>,
|
|
) -> Option<String> {
|
|
serde_json::to_string(&DispatchResult {
|
|
revision,
|
|
state,
|
|
effects,
|
|
})
|
|
.ok()
|
|
}
|
|
|
|
fn engines() -> &'static Mutex<HashMap<u64, Arc<Mutex<HeadlessEngine>>>> {
|
|
ENGINES.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
fn lock_engine(
|
|
engine: &Arc<Mutex<HeadlessEngine>>,
|
|
) -> Option<std::sync::MutexGuard<'_, HeadlessEngine>> {
|
|
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<u64, Arc<Mutex<HeadlessEngine>>>> {
|
|
engines()
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|