perf(meeting): cache sysinfo System for the meeting-detection poller

`detect_meeting_processes` is called every 15 s when meeting-auto-capture
is enabled. The previous `list_running_process_names` allocated a fresh
`sysinfo::System` per call and walked /proc cold; on a busy host
(~300 processes) that's ~50–100 ms of work, every poll, forever.

Add `kon_core::process_watch::ProcessLister`, a thin wrapper around a
long-lived `System` whose process table is refreshed in place. The Tauri
host holds one behind a `Mutex<ProcessLister>` in a new `MeetingState`
managed at app setup. The free `list_running_process_names` is kept as a
convenience that constructs a fresh `ProcessLister` per call — its only
remaining caller is the existing smoke test.

- ProcessLister + Default in crates/core/src/process_watch.rs.
- MeetingState in src-tauri/src/commands/meeting.rs; the command takes
  it via `tauri::State` and locks for the duration of the snapshot.
- src-tauri/src/lib.rs registers MeetingState alongside the other
  managed states.

https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
This commit is contained in:
Claude
2026-04-25 08:46:57 +00:00
parent f3fd86185e
commit 3d568148b8
3 changed files with 84 additions and 12 deletions

View File

@@ -8,18 +8,56 @@
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System};
/// Reusable wrapper around a `sysinfo::System` whose process table is
/// refreshed in place on every poll, instead of allocating a fresh one.
///
/// On a busy host (~300 processes), `System::new_with_specifics` followed by
/// `refresh_processes` walks `/proc` cold and costs ~50100 ms; reusing the
/// same instance reuses sysinfo's per-process bookkeeping so subsequent
/// refreshes are dominated by diffing rather than allocation. The Tauri
/// host holds one of these behind a `Mutex` for the meeting-detection
/// command to call every 15 s.
pub struct ProcessLister {
system: System,
}
impl Default for ProcessLister {
fn default() -> Self {
Self::new()
}
}
impl ProcessLister {
pub fn new() -> Self {
Self {
system: System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
),
}
}
/// Refresh the process table in place and return the current
/// lowercased executable names.
pub fn snapshot(&mut self) -> Vec<String> {
self.system
.refresh_processes(ProcessesToUpdate::All, true);
self.system
.processes()
.values()
.map(|process| process.name().to_string_lossy().to_lowercase())
.collect()
}
}
/// Snapshot the current process list's executable/command names. Lowercased
/// for case-insensitive pattern matching.
///
/// Convenience wrapper that allocates a fresh `ProcessLister` per call.
/// Hot paths (the meeting-detection poller) should hold a long-lived
/// `ProcessLister` and call `snapshot()` directly to avoid the per-call
/// allocation of `System`'s internal bookkeeping.
pub fn list_running_process_names() -> Vec<String> {
let mut system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::nothing()),
);
system.refresh_processes(ProcessesToUpdate::All, true);
system
.processes()
.values()
.map(|process| process.name().to_string_lossy().to_lowercase())
.collect()
ProcessLister::new().snapshot()
}
/// Match a snapshot of process names against case-insensitive substring

View File

@@ -5,13 +5,46 @@
//! that reminds the user to start recording with their hotkey. We do not
//! start recording from this signal — the user decides.
use kon_core::process_watch;
use std::sync::Mutex;
use kon_core::process_watch::{self, ProcessLister};
/// Tauri-managed state for the meeting poller. Holds a long-lived
/// `ProcessLister` so each poll refreshes the existing `sysinfo::System`
/// in place instead of allocating a fresh one — the previous implementation
/// rebuilt the process table from scratch every 15 s.
pub struct MeetingState {
lister: Mutex<ProcessLister>,
}
impl MeetingState {
pub fn new() -> Self {
Self {
lister: Mutex::new(ProcessLister::new()),
}
}
}
impl Default for MeetingState {
fn default() -> Self {
Self::new()
}
}
#[tauri::command]
pub fn detect_meeting_processes(patterns: Vec<String>) -> Result<Vec<String>, String> {
pub fn detect_meeting_processes(
state: tauri::State<'_, MeetingState>,
patterns: Vec<String>,
) -> Result<Vec<String>, String> {
if patterns.is_empty() {
return Ok(Vec::new());
}
let processes = process_watch::list_running_process_names();
let processes = {
let mut lister = state
.lister
.lock()
.map_err(|e| format!("MeetingState lister poisoned: {e}"))?;
lister.snapshot()
};
Ok(process_watch::match_meeting_patterns(&processes, &patterns))
}

View File

@@ -250,6 +250,7 @@ pub fn run() {
app.manage(commands::audio::NativeCaptureState::new());
app.manage(commands::live::LiveTranscriptionState::default());
app.manage(commands::tts::TtsState::new());
app.manage(commands::meeting::MeetingState::new());
app.manage(AppState {
whisper_engine: Arc::new(LocalEngine::new(EngineName::new("whisper"))),