Replaces 22 production eprintln! sites with structured tracing events across 8 files. Closes Area B of the post-prognosis residuals plan (docs/superpowers/plans/2026-05-12-engine-slop-residuals.md). Files touched (22 sites): - crates/hotkey/src/linux.rs (2) — hotplug watcher degraded-mode warnings - crates/ai-formatting/src/pipeline.rs (1) — LLM cleanup fallback warning - src-tauri/src/commands/transcription.rs (1) — chunking dispatch info - src-tauri/src/commands/diagnostics.rs (1) — crashes-dir setup warning - src-tauri/src/commands/tasks.rs (1) — malformed feedback row warning - src-tauri/src/commands/power.rs (3) — App Nap acquire/release/fail - src-tauri/src/commands/models.rs (5) — Whisper warmup lifecycle - src-tauri/src/commands/live.rs (8) — session start, chunk dispatch, per-chunk delivery, inference errors, worker disconnects, listener loss, status-channel cascade Levels: error for unrecoverable failures (inference disconnect, panic, status cascade), warn for recoverable degradation (LLM fallback, malformed rows, App Nap fail, hotplug watcher fail), info for lifecycle (session start, chunk processed, App Nap acquire/release, warmup complete, chunking dispatch), debug for per-chunk noise (speech-gate skip, chunk dispatch). Two new dependencies and two new filter targets: - tracing = "0.1" added to crates/hotkey and crates/ai-formatting - Default EnvFilter in src-tauri/src/lib.rs::init_tracing extended with magnotia_hotkey=info,magnotia_ai_formatting=info so the new targets emit at the default level Out of scope (intentional, left as-is): - crates/mcp/src/main.rs — CLI binary, stderr is the log contract (module docstring) so the JSON-RPC stdout stream stays clean - crates/*/tests/*.rs and crates/core/examples/tuning_log_demo.rs — test/example diagnostic output relies on --nocapture stdio semantics Discovery during sweep (not fixed — separate follow-up): hotkey crate has 6 existing log:: calls (log::error/warn/info/debug) but the workspace builds tracing-subscriber without the tracing-log feature, so those events are currently silent. Worth a follow-up to either add the tracing-log bridge or migrate hotkey's existing log:: calls to tracing::. Verification: - cargo fmt --all - cargo check --workspace --all-targets — clean - cargo test --workspace — 330+ tests, zero failures - rg eprintln! src-tauri/src/commands/ crates/hotkey/src/ crates/ai-formatting/src/ — zero hits Pre-existing working-tree churn in crates/llm/, src/lib/pages/, src/lib/utils/saveMarkdown.ts and the untracked phase10a dogfood notes deliberately left unstaged per Jake's instruction. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
219 lines
7.3 KiB
Rust
219 lines
7.3 KiB
Rust
//! Power-assertion helpers for long-running work (recording + LLM).
|
|
//!
|
|
//! Item #9 in docs/whisper-ecosystem/brief.md: macOS App Nap silently
|
|
//! throttles apps that look idle from the OS's perspective — even when
|
|
//! they are actively capturing audio in the background — which causes
|
|
//! the kind of "my recording stopped halfway through" failure surfaced
|
|
//! in Whispering #549 / #559.
|
|
//!
|
|
//! On macOS we use `NSProcessInfo.beginActivityWithOptions:reason:` to
|
|
//! pin the process into a "latency-critical, user-initiated" state for
|
|
//! the duration of a live session or an LLM generation. The returned
|
|
//! activity object must be retained; dropping it ends the assertion.
|
|
//! Runtime verification on Apple Silicon against actual idle-throttling
|
|
//! is still pending. See `KNOWN-ISSUES.md` (KI-01).
|
|
//!
|
|
//! On Linux and Windows, `PowerAssertion::begin` is currently a no-op
|
|
//! that registers a snapshot in the process-wide registry for diagnostics
|
|
//! but does not inhibit OS-level idle throttling. The planned
|
|
//! implementations are:
|
|
//!
|
|
//! - Linux: systemd-logind / GNOME session idle inhibit via
|
|
//! `org.freedesktop.login1.Inhibit` where available.
|
|
//! - Windows: `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED
|
|
//! | ES_AWAYMODE_REQUIRED)` on begin and `ES_CONTINUOUS` alone on end.
|
|
//!
|
|
//! Until those land, long sessions on Linux and Windows can still be
|
|
//! idled by the OS. See `KNOWN-ISSUES.md` (KI-02, KI-03) for workarounds.
|
|
//!
|
|
//! All paths return a guard so the caller's code is unchanged. Failures
|
|
//! to acquire a real assertion are logged so the diagnostics bundle has
|
|
//! a breadcrumb.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Mutex, OnceLock};
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PowerAssertionSnapshot {
|
|
pub id: usize,
|
|
pub reason: &'static str,
|
|
pub backend: &'static str,
|
|
pub acquired: bool,
|
|
}
|
|
|
|
/// Handle for a single power assertion. Dropping it releases the
|
|
/// assertion. Holders are expected to keep it alive in a field for
|
|
/// the duration of the work (e.g., live session state, LLM generation
|
|
/// guard).
|
|
#[must_use = "dropping the guard ends the power assertion"]
|
|
pub struct PowerAssertion {
|
|
#[allow(dead_code)]
|
|
id: usize,
|
|
reason: &'static str,
|
|
backend: &'static str,
|
|
acquired: bool,
|
|
#[cfg(target_os = "macos")]
|
|
activity: Option<objc_bridge::ActivityHandle>,
|
|
}
|
|
|
|
static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
|
|
|
|
fn assertion_registry() -> &'static Mutex<HashMap<usize, PowerAssertionSnapshot>> {
|
|
static REGISTRY: OnceLock<Mutex<HashMap<usize, PowerAssertionSnapshot>>> = OnceLock::new();
|
|
REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
|
|
}
|
|
|
|
pub fn active_assertions_snapshot() -> Vec<PowerAssertionSnapshot> {
|
|
let mut snapshots = assertion_registry()
|
|
.lock()
|
|
.unwrap()
|
|
.values()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
snapshots.sort_by_key(|snapshot| snapshot.id);
|
|
snapshots
|
|
}
|
|
|
|
impl PowerAssertion {
|
|
/// Begin a power assertion for the given reason. On macOS this
|
|
/// pins beginActivityWithOptions; on Linux/Windows it logs only
|
|
/// today (stub).
|
|
pub fn begin(reason: &'static str) -> Self {
|
|
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
|
|
|
|
#[cfg(target_os = "macos")]
|
|
let activity = objc_bridge::begin_activity(reason).ok();
|
|
#[cfg(target_os = "macos")]
|
|
let backend = "macos";
|
|
#[cfg(target_os = "macos")]
|
|
let acquired = activity.is_some();
|
|
|
|
#[cfg(target_os = "macos")]
|
|
if acquired {
|
|
tracing::info!(assertion_id = id, reason, "began macOS App Nap guard");
|
|
} else {
|
|
tracing::warn!(reason, "macOS App Nap guard could not begin activity");
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
{
|
|
// No-op on non-macOS today; #9 acceptance text only cites
|
|
// macOS App Nap. Linux/Windows placeholder handled if
|
|
// future feedback requires it.
|
|
let _ = reason;
|
|
}
|
|
|
|
#[cfg(not(target_os = "macos"))]
|
|
let backend = "noop";
|
|
#[cfg(not(target_os = "macos"))]
|
|
let acquired = false;
|
|
|
|
assertion_registry().lock().unwrap().insert(
|
|
id,
|
|
PowerAssertionSnapshot {
|
|
id,
|
|
reason,
|
|
backend,
|
|
acquired,
|
|
},
|
|
);
|
|
|
|
Self {
|
|
id,
|
|
reason,
|
|
backend,
|
|
acquired,
|
|
#[cfg(target_os = "macos")]
|
|
activity,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for PowerAssertion {
|
|
fn drop(&mut self) {
|
|
#[cfg(target_os = "macos")]
|
|
if let Some(handle) = self.activity.take() {
|
|
objc_bridge::end_activity(handle);
|
|
tracing::info!(
|
|
assertion_id = self.id,
|
|
reason = self.reason,
|
|
"ended macOS App Nap guard"
|
|
);
|
|
}
|
|
assertion_registry().lock().unwrap().remove(&self.id);
|
|
let _ = (self.reason, self.id);
|
|
let _ = (self.backend, self.acquired);
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod objc_bridge {
|
|
use objc2::rc::Retained;
|
|
use objc2::runtime::ProtocolObject;
|
|
use objc2_foundation::{NSActivityOptions, NSObjectProtocol, NSProcessInfo, NSString};
|
|
|
|
pub struct ActivityHandle {
|
|
activity: Retained<ProtocolObject<dyn NSObjectProtocol>>,
|
|
}
|
|
|
|
unsafe impl Send for ActivityHandle {}
|
|
|
|
pub fn begin_activity(reason: &str) -> Result<ActivityHandle, String> {
|
|
let process_info = NSProcessInfo::processInfo();
|
|
let reason = NSString::from_str(reason);
|
|
let options = NSActivityOptions::UserInitiated | NSActivityOptions::LatencyCritical;
|
|
let activity = process_info.beginActivityWithOptions_reason(options, &reason);
|
|
Ok(ActivityHandle { activity })
|
|
}
|
|
|
|
pub fn end_activity(handle: ActivityHandle) {
|
|
let process_info = NSProcessInfo::processInfo();
|
|
unsafe {
|
|
process_info.endActivity(&handle.activity);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::sync::{Mutex, MutexGuard, OnceLock};
|
|
|
|
fn power_test_guard() -> MutexGuard<'static, ()> {
|
|
static TEST_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
|
TEST_GUARD.get_or_init(|| Mutex::new(())).lock().unwrap()
|
|
}
|
|
|
|
fn clear_assertion_registry() {
|
|
assertion_registry().lock().unwrap().clear();
|
|
}
|
|
|
|
#[test]
|
|
fn power_assertion_is_a_no_op_drop() {
|
|
let _guard = power_test_guard();
|
|
clear_assertion_registry();
|
|
let guard = PowerAssertion::begin("test-reason");
|
|
let snapshots = active_assertions_snapshot();
|
|
assert!(snapshots.iter().any(|snapshot| snapshot.id == guard.id));
|
|
drop(guard);
|
|
assert!(active_assertions_snapshot().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_assertions_get_unique_ids() {
|
|
let _guard = power_test_guard();
|
|
clear_assertion_registry();
|
|
let a = PowerAssertion::begin("a");
|
|
let b = PowerAssertion::begin("b");
|
|
assert_ne!(a.id, b.id);
|
|
let snapshots = active_assertions_snapshot();
|
|
assert_eq!(snapshots.len(), 2);
|
|
assert_eq!(snapshots[0].reason, "a");
|
|
assert_eq!(snapshots[1].reason, "b");
|
|
drop(a);
|
|
drop(b);
|
|
assert!(active_assertions_snapshot().is_empty());
|
|
}
|
|
}
|