// Runtime bootstrap. Called once from Kotlin's StrawApp.onCreate via // init_logging(), and again before every strawcore call. Wires the // strawcore-core Downloader + Localization singleton so the extractor // has an HTTP client to use. // // the prior shape used `Once::call_once` and // silently swallowed errors. If the FIRST call ran while the network // stack wasn't ready (cold boot in airplane mode, SELinux denial on // first TLS init, transient resolver failure), the Once slot was // consumed, NewPipe::init_full never ran, and every subsequent // search/streamInfo/channelInfo returned DownloaderMissing for the // rest of the process lifetime. // // New shape: use an AtomicBool to track success. Only "success" closes // the door. On failure we retry — rate-limited so a persistently-broken // network doesn't hammer reqwest::Client::new() on every call. use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use strawcore_core::downloader::ReqwestDownloader; use strawcore_core::localization::{ContentCountry, Localization}; use strawcore_core::newpipe::NewPipe; use crate::error::StrawcoreError; static INITIALIZED: AtomicBool = AtomicBool::new(false); static LAST_ATTEMPT_MS: AtomicU64 = AtomicU64::new(0); // Guards the actual init attempt so concurrent calls don't all try // to build the downloader in parallel; serial retry is the goal. static INIT_LOCK: Mutex<()> = Mutex::new(()); /// Min ms between retries when init has failed. 5s — enough that a /// hot loop of failed searches doesn't pin a CPU on reqwest setup, /// short enough that a user who toggled airplane mode off recovers /// within one tap. const RETRY_BACKOFF_MS: u64 = 5_000; fn now_ms() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0) } pub fn ensure_initialized() { // Fast path: already initialized. Single Acquire load. if INITIALIZED.load(Ordering::Acquire) { return; } // Backoff check BEFORE the lock — a recent failure shouldn't // make N concurrent callers queue on a mutex they'll all skip // out of anyway. let last = LAST_ATTEMPT_MS.load(Ordering::Acquire); let now = now_ms(); if last != 0 && now.saturating_sub(last) < RETRY_BACKOFF_MS { return; } // try_lock — if another thread is already mid-init, return // immediately rather than block. The caller will get // DownloaderMissing once from the extractor and recover on // the next user action; the alternative (blocking N tokio // workers for the full duration of a slow init) freezes the // UI. was the regression on round-5's // mutex-first ordering. let _guard = match INIT_LOCK.try_lock() { Ok(g) => g, Err(_) => return, }; // Re-check under the lock — another thread may have just succeeded. if INITIALIZED.load(Ordering::Acquire) { return; } match ReqwestDownloader::new() { Ok(dl) => { NewPipe::init_full( Arc::new(dl), Localization::default(), ContentCountry::default(), ); INITIALIZED.store(true, Ordering::Release); // Clear LAST_ATTEMPT_MS so a future hypothetical // re-init path (none today) wouldn't see cooldown // bleed from this success. LAST_ATTEMPT_MS.store(0, Ordering::Release); log::info!("strawcore-core: downloader + localization initialized"); } Err(e) => { // Stamp the timestamp on FAILURE only, so the next // caller within RETRY_BACKOFF_MS skips, but a successful // attempt isn't reflected in the backoff state. LAST_ATTEMPT_MS.store(now, Ordering::Release); log::error!("strawcore-core: downloader init failed (will retry on next call)"); let _ = e; } } } /// Wall-clock ceiling on a single blocking extractor call. Core bounds each /// HTTP request to 30s and (since the 2026-07-04 reliability fix) the JS /// deobfuscation runtime to a 5s interrupt, so a legitimate multi-round-trip /// extraction on a slow connection stays comfortably under this; anything /// past it is a wedge (a pathological player.js, a black-holed CDN) and we'd /// rather surface a timeout to the Kotlin caller than spin forever. const EXTRACT_TIMEOUT: Duration = Duration::from_secs(60); /// Run a blocking strawcore-core call on the tokio blocking pool, bounded by /// [`EXTRACT_TIMEOUT`]. Replaces the raw `spawn_blocking(..).await??` pattern /// at every FFI entry point so a hung extraction unblocks the Kotlin suspend /// instead of (a) spinning the spinner forever and (b) piling detached /// blocking threads toward tokio's 512-thread cap, after which *all* /// spawn_blocking-based FFI calls would queue behind the wedged ones for the /// process lifetime. /// /// Caveat (documented, not a bug): `spawn_blocking` tasks are not abortable, /// so on timeout the detached thread keeps running until the core call /// returns on its own — core's per-request (30s) + JS-interrupt (5s) bounds /// cap how long that is. The win here is unblocking the *caller* and bounding /// retry pileup, not reclaiming the thread instantly. pub(crate) async fn run_extract(what: &'static str, f: F) -> Result where F: FnOnce() -> Result + Send + 'static, T: Send + 'static, E: Send + 'static, StrawcoreError: From, { match tokio::time::timeout(EXTRACT_TIMEOUT, tokio::task::spawn_blocking(f)).await { // Timed out waiting on the blocking task. Err(_elapsed) => Err(StrawcoreError::Extractor { msg: format!("timeout: {what} exceeded {}s", EXTRACT_TIMEOUT.as_secs()), }), // Blocking task finished within the deadline but the join failed — // panic (only under a non-abort profile) or cancellation. Ok(Err(join)) => Err(StrawcoreError::Extractor { msg: format!("join: {join}"), }), // Blocking task returned; propagate its own error via the existing // `From` mapping. Ok(Ok(inner)) => inner.map_err(StrawcoreError::from), } }