Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/genie-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub async fn get_status(_config: &Config) -> Response {
let governor_status = query_governor(r#"{"cmd":"status"}"#).await;

// Augment with live memory reading.
let mem_avail = tegrastats::mem_available_mb().unwrap_or(0);
let mem_avail = tegrastats::mem_available_mb_async().await.unwrap_or(0);

let body = if let Some(mut status) = governor_status {
// Merge live mem_available into the governor's response.
Expand Down
31 changes: 31 additions & 0 deletions crates/genie-common/src/tegrastats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,13 @@ fn parse_power(line: &str) -> Option<u32> {
}

/// Read `/proc/meminfo` and return MemAvailable in MB.
///
/// Synchronous filesystem I/O — callers on a shared async executor (every
/// caller in this workspace: `genie-core`, `genie-api`, `genie-governor` are
/// all `tokio::main(flavor = "current_thread")`) must go through
/// [`mem_available_mb_async`] instead of calling this directly, so a slow
/// `/proc` read (e.g. under cgroup throttling or heavy memory pressure)
/// can't stall every other concurrent session on the same thread.
pub fn mem_available_mb() -> Result<u64> {
let contents = std::fs::read_to_string("/proc/meminfo")?;
for line in contents.lines() {
Expand All @@ -165,12 +172,36 @@ pub fn mem_available_mb() -> Result<u64> {
anyhow::bail!("MemAvailable not found in /proc/meminfo")
}

/// Async wrapper around [`mem_available_mb`] for callers running on a shared
/// `current_thread` executor. Moves the synchronous `/proc/meminfo` read to
/// Tokio's blocking thread pool instead of running it directly on the
/// caller's task — the same pattern `memory::with_shared_memory` already
/// established for the same class of bug.
pub async fn mem_available_mb_async() -> Result<u64> {
tokio::task::spawn_blocking(mem_available_mb)
.await
.unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()))
}

#[cfg(test)]
mod tests {
use super::*;

const SAMPLE: &str = "RAM 2345/7620MB (lfb 234x4MB) SWAP 0/3810MB (cached 0MB) CPU [20%@1510,15%@1510,10%@1510,8%@1510,off,off] EMC_FREQ 0% GR3D_FREQ 50% VIC_FREQ 0% APE 174 gpu@42C cpu@38.5C iwlwifi@37C CV0@-256C CV1@-256C CV2@-256C SOC2@38.5C SOC0@40C SOC1@37.5C tj@42C VDD_IN 4500mW/4500mW VDD_CPU_GPU_CV 799mW/799mW VDD_SOC 1598mW/1598mW";

/// `mem_available_mb_async` must return the same result as the
/// synchronous version — the only difference is running on Tokio's
/// blocking pool instead of the calling task.
#[tokio::test]
async fn mem_available_mb_async_matches_sync_version() {
let sync_result = mem_available_mb();
let async_result = mem_available_mb_async().await;
assert_eq!(sync_result.is_ok(), async_result.is_ok());
if let (Ok(sync_val), Ok(async_val)) = (sync_result, async_result) {
assert_eq!(sync_val, async_val);
}
}

#[test]
fn parse_ram_values() {
let snap = parse_line(SAMPLE, 0).unwrap();
Expand Down
20 changes: 17 additions & 3 deletions crates/genie-core/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,19 @@ async fn main() -> Result<()> {
);
}

// Security audit on startup.
// Security audit on startup. `run_audit` does synchronous filesystem
// I/O (permission checks, config-secret scanning); move it to
// spawn_blocking so it doesn't run directly on the shared executor
// thread, matching the pattern established for `memory::with_shared_memory`.
let config_path = std::env::var("GENIEPOD_CONFIG")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/etc/geniepod/geniepod.toml"));
genie_core::security::audit::run_audit(&config_path, &config.data_dir);
let data_dir = config.data_dir.clone();
tokio::task::spawn_blocking(move || {
genie_core::security::audit::run_audit(&config_path, &data_dir)
})
.await
.unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()));

let blocked_env = genie_core::security::env_sanitize::count_blocked();
if blocked_env > 0 {
Expand Down Expand Up @@ -88,8 +96,14 @@ async fn main() -> Result<()> {
})
.await?;

// `load_all_with_policy` scans the skills directory and reads/verifies
// every `.so` + manifest on disk synchronously — move it to
// spawn_blocking rather than running it directly on the shared executor.
let skill_policy = skills::SkillLoadPolicy::from(&config.core.skill_policy);
let skill_loader =
skills::load_all_with_policy(skills::SkillLoadPolicy::from(&config.core.skill_policy));
tokio::task::spawn_blocking(move || skills::load_all_with_policy(skill_policy))
.await
.unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()));
let connectivity = Arc::new(connectivity::NullConnectivityController::from_config(
&config.connectivity,
));
Expand Down
4 changes: 3 additions & 1 deletion crates/genie-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1811,6 +1811,9 @@ async fn handle_health(
) -> (u16, &'static str, String) {
let llm_ok = llm.health().await;
let connectivity_health = connectivity.health().await;
let mem_avail = genie_common::tegrastats::mem_available_mb_async()
.await
.unwrap_or(0);
let (mem_count, mem_promoted_count, memory_health, memory_db_bytes) =
with_shared_memory(memory, |memory| {
(
Expand All @@ -1823,7 +1826,6 @@ async fn handle_health(
.await;
let conv_count = conversations.list().await.map(|l| l.len()).unwrap_or(0);
let conversation_db_bytes = conversations.db_size_bytes().await.unwrap_or(0);
let mem_avail = genie_common::tegrastats::mem_available_mb().unwrap_or(0);
let chat = chat_gate.snapshot();
let runtime_contract = build_runtime_contract_snapshot(
tools,
Expand Down
11 changes: 6 additions & 5 deletions crates/genie-core/src/tools/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ pub async fn system_info(ha: Option<&dyn HomeAutomationProvider>) -> Result<Stri
info.push(home_assistant_status(ha).await);

// Prefer the governor's latest reading when available.
if let Some(avail) = governor_status
.as_ref()
.and_then(governor_mem_available_mb)
.or_else(|| tegrastats::mem_available_mb().ok())
{
let governor_avail = governor_status.as_ref().and_then(governor_mem_available_mb);
let avail = match governor_avail {
Some(avail) => Some(avail),
None => tegrastats::mem_available_mb_async().await.ok(),
};
if let Some(avail) = avail {
info.push(format!("Memory available: {} MB", avail));
}

Expand Down
58 changes: 40 additions & 18 deletions crates/genie-core/src/voice/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,23 +107,41 @@ impl SpeakerIdentityProvider {
}
}

pub fn identify(&self, request: &SpeakerIdentityRequest<'_>) -> SpeakerIdentity {
/// Resolve a speaker identity for `request`.
///
/// `LocalBiometric` scans every enrolled profile file on disk to find a
/// match — synchronous filesystem I/O that must never run directly on
/// genie-core's shared `current_thread` executor (the caller, the voice
/// loop's per-utterance turn handler, is not the only thing running on
/// that thread). `LocalBiometricRecognizer::identify` moves that work to
/// `spawn_blocking`, mirroring the pattern `memory::with_shared_memory`
/// already established for the same class of bug.
pub async fn identify(&self, request: &SpeakerIdentityRequest<'_>) -> SpeakerIdentity {
match self {
Self::None => SpeakerIdentity::default(),
Self::Fixed(identity) => identity.clone(),
Self::LocalBiometric(recognizer) => recognizer.identify(request),
Self::LocalBiometric(recognizer) => recognizer.identify(request).await,
}
}
}

impl LocalBiometricRecognizer {
pub fn identify(&self, request: &SpeakerIdentityRequest<'_>) -> SpeakerIdentity {
pub async fn identify(&self, request: &SpeakerIdentityRequest<'_>) -> SpeakerIdentity {
let _ = (request.transcript, request.detected_language);
let Some(wav_path) = request.wav_path else {
return SpeakerIdentity::default();
};

match identify_speaker_file(&self.profile_dir, wav_path, self.min_score) {
let profile_dir = self.profile_dir.clone();
let wav_path = wav_path.to_string();
let min_score = self.min_score;
let result = tokio::task::spawn_blocking(move || {
identify_speaker_file(&profile_dir, &wav_path, min_score)
})
.await
.unwrap_or_else(|e| std::panic::resume_unwind(e.into_panic()));

match result {
Ok(Some(result)) => {
let confidence = if result.score >= 0.92 {
IdentityConfidence::High
Expand Down Expand Up @@ -649,8 +667,8 @@ mod tests {
assert!(ctx.explicit_private_intent);
}

#[test]
fn fixed_provider_returns_configured_identity() {
#[tokio::test]
async fn fixed_provider_returns_configured_identity() {
let provider = SpeakerIdentityProvider::from_config(&SpeakerIdentityConfig {
enabled: true,
provider: SpeakerIdentityProviderKind::Fixed,
Expand All @@ -659,11 +677,13 @@ mod tests {
local_profile_dir: PathBuf::from("/opt/geniepod/data/speakers"),
local_min_score: 0.82,
});
let identity = provider.identify(&SpeakerIdentityRequest {
wav_path: None,
transcript: "what do you remember about me",
detected_language: Some("en"),
});
let identity = provider
.identify(&SpeakerIdentityRequest {
wav_path: None,
transcript: "what do you remember about me",
detected_language: Some("en"),
})
.await;
assert_eq!(identity.name.as_deref(), Some("Jared"));
assert_eq!(identity.confidence, IdentityConfidence::High);
}
Expand Down Expand Up @@ -731,8 +751,8 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn local_biometric_provider_returns_identity_when_profile_matches() {
#[tokio::test]
async fn local_biometric_provider_returns_identity_when_profile_matches() {
let dir = std::env::temp_dir().join(format!(
"geniepod-speaker-provider-test-{}",
std::process::id()
Expand All @@ -750,11 +770,13 @@ mod tests {
profile_dir: dir.clone(),
min_score: 0.82,
});
let identity = provider.identify(&SpeakerIdentityRequest {
wav_path: Some(test.to_str().unwrap()),
transcript: "what do you remember about me",
detected_language: Some("en"),
});
let identity = provider
.identify(&SpeakerIdentityRequest {
wav_path: Some(test.to_str().unwrap()),
transcript: "what do you remember about me",
detected_language: Some("en"),
})
.await;

assert_eq!(identity.name.as_deref(), Some("Jared"));
assert!(identity.confidence >= IdentityConfidence::Medium);
Expand Down
12 changes: 7 additions & 5 deletions crates/genie-core/src/voice_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -356,13 +356,14 @@ async fn run_with_wakeword(
let response_language = transcript.language.clone().or_else(|| {
crate::voice::language::detect_language_from_text(&text)
});
let speaker = voice_cfg.speaker_identity.identify(
&identity::SpeakerIdentityRequest {
let speaker = voice_cfg
.speaker_identity
.identify(&identity::SpeakerIdentityRequest {
wav_path: Some(&followup_path),
transcript: &text,
detected_language: response_language.as_deref(),
},
);
})
.await;
let read_context = identity::build_memory_read_context(&text, &speaker);
crate::security::audit::log_speaker_resolved(
speaker.name.as_deref().unwrap_or("unknown"),
Expand Down Expand Up @@ -1099,7 +1100,8 @@ pub async fn process_transcript(
wav_path,
transcript: &text,
detected_language: response_language.as_deref(),
});
})
.await;
let read_context = identity::build_memory_read_context(&text, &speaker);
crate::security::audit::log_speaker_resolved(
speaker.name.as_deref().unwrap_or("unknown"),
Expand Down
6 changes: 3 additions & 3 deletions crates/genie-governor/src/governor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ impl Governor {
}
Command::MediaStop => {
let ts = store::now_ms();
let mem_avail = tegrastats::mem_available_mb().unwrap_or(4096);
let mem_avail = tegrastats::mem_available_mb_async().await.unwrap_or(4096);
let target = self.determine_mode(mem_avail);
let result: Result<Mode, anyhow::Error> = async {
if let Err(error) = tokio::fs::remove_file("/run/geniepod/media_mode").await
Expand All @@ -115,7 +115,7 @@ impl Governor {
}
}
Command::Status => {
let mem_avail = tegrastats::mem_available_mb().unwrap_or(0);
let mem_avail = tegrastats::mem_available_mb_async().await.unwrap_or(0);
let resp = StatusResponse {
mode: self.current_mode,
mem_available_mb: mem_avail,
Expand All @@ -130,7 +130,7 @@ impl Governor {
let ts = store::now_ms();

// 1. Read memory from /proc/meminfo (always available).
let mem_avail = tegrastats::mem_available_mb().unwrap_or(0);
let mem_avail = tegrastats::mem_available_mb_async().await.unwrap_or(0);

// 2. If tegrastats is running, log the latest snapshot.
if let Some(ref rx) = self.tegra_rx {
Expand Down
Loading