Probes wsl.exe -d <distro> -- pgrep -x claude before flagging a WSL pane idle, with a 3s per-distro cache on the Rust side. If claude is running anywhere in the distro, all panes in that distro stay out of the idle set (per-pane granularity is out of scope — PIDs aren't observable from Windows). PowerShell + SSH panes skip the probe and keep the legacy always-notify behaviour.
172 lines
6.8 KiB
Rust
172 lines
6.8 KiB
Rust
//! "Is a watched process running in distro X?" probe for the idle-detection
|
|
//! filter.
|
|
//!
|
|
//! Background: tiletopia's idle indicator fires whenever a pane goes 5s
|
|
//! without PTY output. When the user is reading a long `claude` response,
|
|
//! the pane is silent but there's nothing actionable to surface — the
|
|
//! indicator becomes noise. This module lets the frontend ask the backend
|
|
//! "is `claude` (or any other watched process) running in this distro?"
|
|
//! before flagging a pane idle, and suppresses the indicator if so.
|
|
//!
|
|
//! Granularity is per-distro, not per-pane. Identifying which Windows pane
|
|
//! corresponds to which Linux-side shell inside the distro is too complex
|
|
//! (PIDs aren't visible from Windows; ProcMon-style probes are fragile). If
|
|
//! `claude` is running anywhere in distro X, idle is suppressed for ALL
|
|
//! panes in distro X. Over-suppression for multi-pane-same-distro users is
|
|
//! the agreed trade-off; the previous bug (always notify) was worse.
|
|
//!
|
|
//! PowerShell + SSH panes don't go through this probe — the frontend short-
|
|
//! circuits to "always idle" for them. (PowerShell has no portable `ps`
|
|
//! equivalent; SSH processes live on a remote box and would need a separate
|
|
//! transport.)
|
|
//!
|
|
//! The probe shells out (`wsl.exe -d <distro> -- pgrep -x ...`), which costs
|
|
//! ~100-300ms per call. We cache the answer per-distro for a few seconds so
|
|
//! the frontend can poll on every idle tick without storming `wsl.exe`.
|
|
|
|
use std::collections::HashMap;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use parking_lot::Mutex;
|
|
|
|
/// Built-in list of process names that suppress idle when running. v1 ships
|
|
/// with just `claude`; the user can extend it via the workspace config later.
|
|
///
|
|
/// [[user-watch-list]] TODO: surface this as a user-editable list (workspace
|
|
/// config field or dedicated `watch.json`). For now the constant covers the
|
|
/// only real-world use case (Anthropic's `claude` CLI taking its time on a
|
|
/// long response). Adding entries to the constant is the only knob.
|
|
pub const DEFAULT_WATCH_PROCESSES: &[&str] = &["claude"];
|
|
|
|
/// How long a per-distro probe result is reused before we re-shell. Sized
|
|
/// against the frontend's 1s idle-tick interval — 3s means roughly one
|
|
/// probe per distro per 3 ticks even with many panes polling, while still
|
|
/// reacting to "claude just finished" within a few seconds. Trade-off: too
|
|
/// short = wsl.exe spam, too long = stale "claude is running" once the
|
|
/// process actually exits.
|
|
const CACHE_TTL: Duration = Duration::from_secs(3);
|
|
|
|
/// Cache entry: timestamp the probe ran + whether any watched process was
|
|
/// found in the distro.
|
|
#[derive(Clone, Copy)]
|
|
struct CacheEntry {
|
|
at: Instant,
|
|
running: bool,
|
|
}
|
|
|
|
/// Per-distro probe cache. Keyed by distro name (the same string the user
|
|
/// sees in the shell picker; the same string we pass as `wsl.exe -d`).
|
|
pub struct ProbeCache {
|
|
cache: Mutex<HashMap<String, CacheEntry>>,
|
|
}
|
|
|
|
impl ProbeCache {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
cache: Mutex::new(HashMap::new()),
|
|
}
|
|
}
|
|
|
|
/// Returns true iff one of the watched processes is running in the
|
|
/// distro. Cached for {@link CACHE_TTL}; cache misses (or stale entries)
|
|
/// trigger a fresh probe. On probe failure the result is `true` —
|
|
/// **fail-safe is to suppress** the idle indicator, matching the
|
|
/// agreed trade-off ("over-suppression beats the previous always-notify
|
|
/// behaviour").
|
|
pub fn is_watch_process_running(&self, distro: &str) -> bool {
|
|
// Fast path: fresh cached answer.
|
|
{
|
|
let guard = self.cache.lock();
|
|
if let Some(entry) = guard.get(distro) {
|
|
if entry.at.elapsed() < CACHE_TTL {
|
|
return entry.running;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Slow path: re-probe. Drop the lock before shelling out so other
|
|
// distros' probes aren't blocked.
|
|
let running = probe_distro(distro, DEFAULT_WATCH_PROCESSES);
|
|
|
|
let mut guard = self.cache.lock();
|
|
guard.insert(
|
|
distro.to_string(),
|
|
CacheEntry {
|
|
at: Instant::now(),
|
|
running,
|
|
},
|
|
);
|
|
running
|
|
}
|
|
}
|
|
|
|
impl Default for ProbeCache {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Run `wsl.exe -d <distro> -- pgrep -x <name>` for each watched name.
|
|
/// Returns true on the first hit. On any failure (wsl.exe missing, distro
|
|
/// not running, pgrep not installed, timeout) returns true — fail-safe is
|
|
/// suppression.
|
|
fn probe_distro(distro: &str, watched: &[&str]) -> bool {
|
|
if !cfg!(windows) {
|
|
// Non-Windows builds don't actually ship the app; pretend no watched
|
|
// process so the idle indicator works for developer test runs.
|
|
return false;
|
|
}
|
|
if distro.is_empty() {
|
|
// We can't probe an empty distro name; treat as "no info" → fail-safe.
|
|
tracing::debug!("probe: empty distro name; defaulting to suppression");
|
|
return true;
|
|
}
|
|
|
|
for name in watched {
|
|
match probe_one(distro, name) {
|
|
Ok(true) => return true,
|
|
Ok(false) => continue,
|
|
Err(e) => {
|
|
tracing::debug!(
|
|
"probe: wsl pgrep for {name:?} in {distro:?} failed: {e} — suppressing idle"
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Single `pgrep -x <name>` invocation. Ok(true) on a match, Ok(false) on
|
|
/// exit code 1 (no match), Err on anything else. Wrapped in our standard
|
|
/// `quiet_command` so the console window doesn't flash on the Windows
|
|
/// desktop every probe.
|
|
fn probe_one(distro: &str, name: &str) -> std::io::Result<bool> {
|
|
// `pgrep -x` matches the exact comm (no substring), which avoids
|
|
// `claude-something-else` false-positives. Stdout/stderr are silenced
|
|
// — exit code carries the answer.
|
|
//
|
|
// Note: `name` is a compile-time string literal in DEFAULT_WATCH_PROCESSES
|
|
// (no user input), so shell-quoting concerns don't apply. If we ever
|
|
// wire user-supplied process names through here we MUST validate / shell-
|
|
// quote them before this point.
|
|
let out = crate::pty::quiet_command_pub("wsl.exe")
|
|
.args(["-d", distro, "--", "pgrep", "-x", name])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.output()?;
|
|
|
|
match out.status.code() {
|
|
Some(0) => Ok(true), // pgrep found at least one match
|
|
Some(1) => Ok(false), // pgrep ran but found nothing
|
|
Some(other) => {
|
|
// 2 = syntax error in pgrep itself; 3 = fatal error; 127 = command
|
|
// not found. None of these mean "definitively no claude running",
|
|
// so treat as a probe failure (caller fails-safe to true).
|
|
Err(std::io::Error::other(format!(
|
|
"pgrep exit code {other}"
|
|
)))
|
|
}
|
|
None => Err(std::io::Error::other("pgrep killed by signal")),
|
|
}
|
|
}
|