use super::probe::{LockProbe, Probe, WriterProbe};
use std::path::Path;
struct Sightings {
any_holder: bool,
any_writer: bool,
}
fn parse(output: &[u8], target: &Path) -> Option<Sightings> {
let text = std::str::from_utf8(output).ok()?;
let mut saw_process = false;
let mut writer = false; let mut seen = Sightings {
any_holder: false,
any_writer: false,
};
for line in text.lines() {
let mut chars = line.chars();
let Some(tag) = chars.next() else { continue };
let value = chars.as_str();
match tag {
'p' => {
saw_process = true;
writer = false;
}
'f' => writer = false,
'a' => writer = matches!(value.chars().next(), Some('w' | 'u')),
'n' => {
if Path::new(value) == target {
seen.any_holder = true;
seen.any_writer |= writer;
}
writer = false;
}
_ => {}
}
}
if !saw_process && !text.trim().is_empty() {
return None;
}
Some(seen)
}
pub(super) trait LsofRunner {
fn run(&self, target: &Path) -> Option<Vec<u8>>;
}
pub(super) struct LsofProbe<R: LsofRunner> {
runner: R,
}
impl<R: LsofRunner> LsofProbe<R> {
pub(super) fn new(runner: R) -> Self {
Self { runner }
}
fn observe(&self, target: &Path, held: impl FnOnce(&Sightings) -> bool) -> Probe {
let out = self.runner.run(target);
match out.as_deref().and_then(|o| parse(o, target)) {
Some(seen) if held(&seen) => Probe::Held,
Some(_) => Probe::Free,
None => Probe::Unknown,
}
}
}
impl<R: LsofRunner> LockProbe for LsofProbe<R> {
fn lock_state(&self, inbox_dir: &Path) -> Probe {
self.observe(inbox_dir, |s| s.any_holder)
}
}
impl<R: LsofRunner> WriterProbe for LsofProbe<R> {
fn writer_state(&self, path: &Path) -> Probe {
self.observe(path, |s| s.any_writer)
}
}
#[cfg(target_os = "macos")]
pub(super) struct SystemLsof;
#[cfg(target_os = "macos")]
impl LsofRunner for SystemLsof {
fn run(&self, target: &Path) -> Option<Vec<u8>> {
let out = std::process::Command::new("lsof")
.args(["-F", "pan", "--"])
.arg(target)
.output()
.ok()?;
if !out.status.success() && !out.stderr.is_empty() {
return None;
}
Some(out.stdout)
}
}
#[cfg(target_os = "macos")]
pub(super) fn system_probe()
-> super::probe_cache::TtlCache<LsofProbe<SystemLsof>, crate::ui_state::SystemClock> {
super::probe_cache::TtlCache::new(LsofProbe::new(SystemLsof), crate::ui_state::SystemClock)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::path::PathBuf;
const INBOX: &str = "/ws/inbox/20260427T140000Z-aaaa";
const RESPONSE: &str = "/ws/steps/20260427T140000Z-aaaa/003/response.json";
struct FakeLsof {
output: Option<Vec<u8>>,
calls: Cell<usize>,
}
fn probe(out: Option<Vec<u8>>) -> LsofProbe<FakeLsof> {
LsofProbe::new(FakeLsof {
output: out,
calls: Cell::new(0),
})
}
impl LsofRunner for FakeLsof {
fn run(&self, _target: &Path) -> Option<Vec<u8>> {
self.calls.set(self.calls.get() + 1);
self.output.clone()
}
}
fn target(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn writer_open_for_write_is_holder_and_writer() {
let out = format!("p4321\nf3\ntREG\nau\nn{RESPONSE}\n");
let seen = parse(out.as_bytes(), &target(RESPONSE)).unwrap();
assert!(seen.any_holder && seen.any_writer);
}
#[test]
fn reader_only_is_a_holder_but_not_a_writer() {
let out = format!("p4321\nf3\nar\nn{INBOX}\n");
let seen = parse(out.as_bytes(), &target(INBOX)).unwrap();
assert!(seen.any_holder && !seen.any_writer);
}
#[test]
fn write_access_w_counts_as_a_writer() {
let out = format!("p9\naw\nn{RESPONSE}\n");
let seen = parse(out.as_bytes(), &target(RESPONSE)).unwrap();
assert!(seen.any_writer);
}
#[test]
fn a_non_matching_name_is_no_holder() {
let out = "p4321\nf3\naw\nn/some/other/file\n\nf4\nn/and/another\n";
let seen = parse(out.as_bytes(), &target(RESPONSE)).unwrap();
assert!(!seen.any_holder && !seen.any_writer);
}
#[test]
fn access_does_not_leak_across_files_without_an_f_delimiter() {
let out = format!("p7\naw\nn/other\nn{RESPONSE}\n");
let seen = parse(out.as_bytes(), &target(RESPONSE)).unwrap();
assert!(seen.any_holder && !seen.any_writer);
}
#[test]
fn empty_output_is_a_definite_no_holder() {
let seen = parse(b"", &target(RESPONSE)).unwrap();
assert!(!seen.any_holder && !seen.any_writer);
}
#[test]
fn non_field_output_is_unparseable() {
assert!(parse(b"lsof: WARNING: bad argument\n", &target(RESPONSE)).is_none());
}
#[test]
fn invalid_utf8_is_unparseable() {
assert!(parse(&[0xff, 0xfe, 0x00], &target(RESPONSE)).is_none());
}
#[test]
fn lock_state_reports_held_free_and_unknown() {
let held = probe(Some(format!("p1\nf3\nar\nn{INBOX}\n").into_bytes()));
assert_eq!(held.lock_state(&target(INBOX)), Probe::Held);
let free = probe(Some(Vec::new()));
assert_eq!(free.lock_state(&target(INBOX)), Probe::Free);
let absent = probe(None);
assert_eq!(absent.lock_state(&target(INBOX)), Probe::Unknown);
}
#[test]
fn writer_state_reports_held_free_and_unknown() {
let held = probe(Some(format!("p1\nf3\naw\nn{RESPONSE}\n").into_bytes()));
assert_eq!(held.writer_state(&target(RESPONSE)), Probe::Held);
let reader = probe(Some(format!("p1\nf3\nar\nn{RESPONSE}\n").into_bytes()));
assert_eq!(reader.writer_state(&target(RESPONSE)), Probe::Free);
let garbled = probe(Some(b"garbage\n".to_vec()));
assert_eq!(garbled.writer_state(&target(RESPONSE)), Probe::Unknown);
assert_eq!(garbled.runner.calls.get(), 1);
}
}