use crate::config::Config;
use crate::error::PidError;
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
pub fn pid_path() -> PathBuf {
Config::minutes_dir().join("recording.pid")
}
pub fn dictation_pid_path() -> PathBuf {
Config::minutes_dir().join("dictation.pid")
}
pub fn live_transcript_pid_path() -> PathBuf {
Config::minutes_dir().join("live-transcript.pid")
}
pub fn live_transcript_jsonl_path() -> PathBuf {
Config::minutes_dir().join("live-transcript.jsonl")
}
pub fn live_transcript_wav_path() -> PathBuf {
Config::minutes_dir().join("live-transcript.wav")
}
pub fn live_transcript_status_path() -> PathBuf {
Config::minutes_dir().join("live-transcript-status.json")
}
pub fn recording_meta_path() -> PathBuf {
Config::minutes_dir().join("recording-meta.json")
}
pub fn current_wav_path() -> PathBuf {
Config::minutes_dir().join("current.wav")
}
pub fn last_result_path() -> PathBuf {
Config::minutes_dir().join("last-result.json")
}
pub fn processing_status_path() -> PathBuf {
Config::minutes_dir().join("processing-status.json")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CaptureMode {
Meeting,
QuickThought,
Dictation,
LiveTranscript,
}
impl CaptureMode {
pub fn content_type(self) -> crate::markdown::ContentType {
match self {
Self::Meeting | Self::LiveTranscript => crate::markdown::ContentType::Meeting,
Self::QuickThought => crate::markdown::ContentType::Memo,
Self::Dictation => crate::markdown::ContentType::Dictation,
}
}
pub fn noun(self) -> &'static str {
match self {
Self::Meeting => "meeting",
Self::QuickThought => "quick thought",
Self::Dictation => "dictation",
Self::LiveTranscript => "live transcript",
}
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RecordingMetadata {
pub mode: CaptureMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_session_id: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProcessingStatus {
pub processing: bool,
pub stage: Option<String>,
pub owner_pid: u32,
pub mode: Option<CaptureMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_id: Option<String>,
#[serde(default)]
pub job_count: usize,
}
pub fn write_recording_metadata(mode: CaptureMode) -> std::io::Result<()> {
write_recording_metadata_with_context(mode, None)
}
pub fn write_recording_metadata_with_context(
mode: CaptureMode,
context_session_id: Option<&str>,
) -> std::io::Result<()> {
let path = recording_meta_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let metadata = RecordingMetadata {
mode,
context_session_id: context_session_id.map(str::to_string),
};
let json = serde_json::to_string(&metadata)?;
fs::write(path, json)
}
pub fn read_recording_metadata() -> Option<RecordingMetadata> {
let path = recording_meta_path();
if !path.exists() {
return None;
}
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<RecordingMetadata>(&s).ok())
}
pub fn clear_recording_metadata() -> std::io::Result<()> {
let path = recording_meta_path();
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn set_processing_status(
stage: Option<&str>,
mode: Option<CaptureMode>,
title: Option<&str>,
job_id: Option<&str>,
job_count: usize,
) -> std::io::Result<()> {
let path = processing_status_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let status = ProcessingStatus {
processing: true,
stage: stage.map(String::from),
owner_pid: std::process::id(),
mode,
title: title.map(String::from),
job_id: job_id.map(String::from),
job_count,
};
let json = serde_json::to_string(&status)?;
fs::write(path, json)
}
pub fn clear_processing_status() -> std::io::Result<()> {
let path = processing_status_path();
if path.exists() {
fs::remove_file(path)?;
}
Ok(())
}
pub fn read_processing_status() -> ProcessingStatus {
let path = processing_status_path();
if !path.exists() {
return ProcessingStatus {
processing: false,
stage: None,
owner_pid: 0,
mode: None,
title: None,
job_id: None,
job_count: 0,
};
}
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<ProcessingStatus>(&s).ok())
.and_then(|status| {
if status.owner_pid != 0 && is_process_alive(status.owner_pid) {
Some(status)
} else {
clear_processing_status().ok();
None
}
})
.unwrap_or(ProcessingStatus {
processing: false,
stage: None,
owner_pid: 0,
mode: None,
title: None,
job_id: None,
job_count: 0,
})
}
pub fn check_pid_file(path: &Path) -> Result<Option<u32>, PidError> {
if !path.exists() {
return Ok(None);
}
let pid_str = fs::read_to_string(path)?;
let pid: u32 = pid_str.trim().parse().map_err(|_| PidError::StalePid(0))?;
if is_process_alive(pid) {
Ok(Some(pid))
} else {
tracing::warn!(
"stale PID file found at {} (PID {pid} is dead). Cleaning up.",
path.display()
);
fs::remove_file(path).ok();
Ok(None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PidFileState {
Active(u32),
LockedAlive,
Inactive,
}
impl PidFileState {
pub fn is_active(self) -> bool {
matches!(self, PidFileState::Active(_) | PidFileState::LockedAlive)
}
pub fn pid(self) -> Option<u32> {
match self {
PidFileState::Active(pid) => Some(pid),
_ => None,
}
}
}
pub fn inspect_pid_file(path: &Path) -> PidFileState {
if !path.exists() {
return PidFileState::Inactive;
}
match fs::read_to_string(path) {
Ok(contents) => match contents.trim().parse::<u32>() {
Ok(pid) if is_process_alive(pid) => PidFileState::Active(pid),
Ok(pid) => {
tracing::warn!(
"stale PID file found at {} (PID {pid} is dead). Cleaning up.",
path.display()
);
fs::remove_file(path).ok();
PidFileState::Inactive
}
Err(_) => PidFileState::Inactive,
},
Err(err) if is_lock_violation(&err) => PidFileState::LockedAlive,
Err(_) => PidFileState::Inactive,
}
}
fn is_lock_violation(err: &std::io::Error) -> bool {
#[cfg(windows)]
{
err.raw_os_error() == Some(33)
}
#[cfg(not(windows))]
{
let _ = err;
false
}
}
fn read_locked_pid(file: &mut fs::File) -> Result<Option<u32>, PidError> {
file.seek(SeekFrom::Start(0))?;
let mut pid_str = String::new();
file.read_to_string(&mut pid_str)?;
let trimmed = pid_str.trim();
if trimmed.is_empty() {
return Ok(None);
}
let pid = trimmed.parse().map_err(|_| PidError::StalePid(0))?;
Ok(Some(pid))
}
fn write_locked_pid(file: &mut fs::File, pid: u32) -> Result<(), PidError> {
file.seek(SeekFrom::Start(0))?;
file.set_len(0)?;
write!(file, "{}", pid)?;
file.flush()?;
Ok(())
}
pub fn create_pid_file(path: &Path) -> Result<(), PidError> {
use fs2::FileExt;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)?;
if file.try_lock_exclusive().is_err() {
let existing_pid = fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
return Err(PidError::AlreadyRecording(existing_pid));
}
if let Some(old_pid) = read_locked_pid(&mut file)? {
if old_pid != 0 && is_process_alive(old_pid) {
file.unlock().ok();
return Err(PidError::AlreadyRecording(old_pid));
}
}
let pid = std::process::id();
write_locked_pid(&mut file, pid)?;
tracing::debug!("PID file created: {} (PID {})", path.display(), pid);
Ok(())
}
pub struct PidGuard {
file: Option<fs::File>,
path: PathBuf,
}
impl Drop for PidGuard {
fn drop(&mut self) {
#[cfg(unix)]
{
fs::remove_file(&self.path).ok();
self.file.take(); }
#[cfg(not(unix))]
{
self.file.take(); fs::remove_file(&self.path).ok();
}
tracing::debug!("PID guard dropped: {}", self.path.display());
}
}
pub fn create_pid_guard(path: &Path) -> Result<PidGuard, PidError> {
use fs2::FileExt;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(path)?;
if file.try_lock_exclusive().is_err() {
let existing_pid = fs::read_to_string(path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
return Err(PidError::AlreadyRecording(existing_pid));
}
if let Some(old_pid) = read_locked_pid(&mut file)? {
if old_pid != 0 && is_process_alive(old_pid) {
file.unlock().ok();
return Err(PidError::AlreadyRecording(old_pid));
}
}
let pid = std::process::id();
write_locked_pid(&mut file, pid)?;
tracing::debug!("PID guard created: {} (PID {})", path.display(), pid);
Ok(PidGuard {
file: Some(file),
path: path.to_path_buf(),
})
}
pub fn remove_pid_file(path: &Path) -> Result<(), PidError> {
if path.exists() {
fs::remove_file(path)?;
tracing::debug!("PID file removed: {}", path.display());
}
Ok(())
}
pub fn check_recording() -> Result<Option<u32>, PidError> {
let path = pid_path();
if !path.exists() {
return Ok(None);
}
let pid_str = fs::read_to_string(&path)?;
let pid: u32 = pid_str.trim().parse().map_err(|_| PidError::StalePid(0))?;
if is_process_alive(pid) {
Ok(Some(pid))
} else {
tracing::warn!("stale PID file found (PID {pid} is dead). Cleaning up.");
cleanup_stale()?;
Ok(None)
}
}
pub fn create() -> Result<(), PidError> {
use fs2::FileExt;
check_and_clear_sentinel();
let path = pid_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&path)?;
if file.try_lock_exclusive().is_err() {
let existing_pid = fs::read_to_string(&path)
.ok()
.and_then(|s| s.trim().parse::<u32>().ok())
.unwrap_or(0);
return Err(PidError::AlreadyRecording(existing_pid));
}
if let Some(old_pid) = read_locked_pid(&mut file)? {
if old_pid != 0 && is_process_alive(old_pid) {
file.unlock().ok();
return Err(PidError::AlreadyRecording(old_pid));
}
}
let pid = std::process::id();
write_locked_pid(&mut file, pid)?;
tracing::debug!("PID file created: {} (PID {})", path.display(), pid);
Ok(())
}
pub fn remove() -> Result<(), PidError> {
let path = pid_path();
if path.exists() {
fs::remove_file(&path)?;
tracing::debug!("PID file removed: {}", path.display());
}
Ok(())
}
fn cleanup_stale() -> Result<(), PidError> {
let path = pid_path();
if path.exists() {
fs::remove_file(&path)?;
}
clear_recording_metadata().ok();
Ok(())
}
pub fn is_process_alive(pid: u32) -> bool {
#[cfg(unix)]
{
unsafe { libc::kill(pid as i32, 0) == 0 }
}
#[cfg(windows)]
{
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE};
unsafe {
let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
if handle.is_null() {
false
} else {
CloseHandle(handle);
true
}
}
}
}
pub fn stop_sentinel_path() -> PathBuf {
Config::minutes_dir().join("recording.stop")
}
pub fn write_stop_sentinel() -> std::io::Result<()> {
let path = stop_sentinel_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, "stop")
}
pub fn check_and_clear_sentinel() -> bool {
let path = stop_sentinel_path();
if path.exists() {
fs::remove_file(&path).ok();
true
} else {
false
}
}
pub fn spawn_sentinel_watcher(
stop_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
loop {
if stop_flag.load(std::sync::atomic::Ordering::Relaxed) {
check_and_clear_sentinel();
break;
}
if check_and_clear_sentinel() {
tracing::info!("stop sentinel detected — stopping recording");
stop_flag.store(true, std::sync::atomic::Ordering::Relaxed);
break;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
})
}
#[derive(Debug, serde::Serialize)]
pub struct RecordingStatus {
pub recording: bool,
pub processing: bool,
pub processing_stage: Option<String>,
pub recording_mode: Option<CaptureMode>,
pub processing_title: Option<String>,
pub processing_job_id: Option<String>,
pub processing_job_count: usize,
pub pid: Option<u32>,
pub duration_secs: Option<f64>,
pub wav_path: Option<String>,
}
pub fn status() -> RecordingStatus {
let active_jobs = crate::jobs::active_jobs();
status_with_active_jobs(&active_jobs)
}
pub fn status_with_active_jobs(active_jobs: &[crate::jobs::ProcessingJob]) -> RecordingStatus {
let jobs_summary = active_jobs.first().cloned();
let job_count = active_jobs.len();
let processing = jobs_summary
.as_ref()
.map(|job| ProcessingStatus {
processing: true,
stage: job.stage.clone().or_else(|| job.state.default_stage()),
owner_pid: job.owner_pid.unwrap_or(0),
mode: Some(job.mode),
title: job
.title
.clone()
.or_else(|| job.output_path.as_ref().map(|path| path.to_string())),
job_id: Some(job.id.clone()),
job_count,
})
.unwrap_or_else(read_processing_status);
match check_recording() {
Ok(Some(pid)) => {
let wav = current_wav_path();
let duration = wav
.metadata()
.ok()
.and_then(|m| m.modified().ok())
.and_then(|modified| {
std::time::SystemTime::now()
.duration_since(modified)
.ok()
.map(|d| d.as_secs_f64())
});
RecordingStatus {
recording: true,
processing: processing.processing,
processing_stage: processing.stage,
recording_mode: read_recording_metadata().map(|meta| meta.mode),
processing_title: processing.title,
processing_job_id: processing.job_id,
processing_job_count: processing.job_count,
pid: Some(pid),
duration_secs: duration,
wav_path: Some(wav.display().to_string()),
}
}
_ => RecordingStatus {
recording: false,
processing: processing.processing,
processing_stage: processing.stage,
recording_mode: processing.mode,
processing_title: processing.title,
processing_job_id: processing.job_id,
processing_job_count: processing.job_count,
pid: None,
duration_secs: None,
wav_path: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_process_alive_detects_current_process() {
let _guard = crate::test_home_env_lock();
assert!(is_process_alive(std::process::id()));
}
#[test]
fn is_process_alive_returns_false_for_dead_pid() {
let _guard = crate::test_home_env_lock();
assert!(!is_process_alive(99_999_999));
}
#[test]
fn processing_status_round_trip() {
let _guard = crate::test_home_env_lock();
set_processing_status(
Some("Transcribing audio"),
Some(CaptureMode::QuickThought),
None,
None,
0,
)
.unwrap();
let status = read_processing_status();
assert!(status.processing);
assert_eq!(status.stage.as_deref(), Some("Transcribing audio"));
assert_eq!(status.owner_pid, std::process::id());
assert_eq!(status.mode, Some(CaptureMode::QuickThought));
assert_eq!(status.title, None);
assert_eq!(status.job_id, None);
assert_eq!(status.job_count, 0);
clear_processing_status().unwrap();
}
#[test]
fn recording_metadata_round_trip() {
let _guard = crate::test_home_env_lock();
write_recording_metadata(CaptureMode::QuickThought).unwrap();
let metadata = read_recording_metadata().unwrap();
assert_eq!(metadata.mode, CaptureMode::QuickThought);
clear_recording_metadata().unwrap();
}
#[test]
fn status_with_active_jobs_uses_slice_as_source_of_truth() {
let _guard = crate::test_home_env_lock();
let job = crate::jobs::ProcessingJob {
id: "job-status-check".into(),
mode: CaptureMode::Meeting,
content_type: crate::markdown::ContentType::Meeting,
title: Some("Status check job".into()),
audio_path: "/tmp/status.wav".into(),
output_path: None,
state: crate::jobs::JobState::Transcribing,
stage: Some("Transcribing meeting".into()),
created_at: chrono::Local::now(),
started_at: Some(chrono::Local::now()),
finished_at: None,
notice_dismissed_at: None,
recording_started_at: None,
recording_finished_at: None,
context_session_id: None,
user_notes: None,
pre_context: None,
consent: None,
consent_notice: None,
calendar_event: None,
template_slug: None,
recording_health: None,
word_count: None,
error: None,
owner_pid: Some(4242),
retry_count: 0,
};
let jobs = vec![job];
let status = status_with_active_jobs(&jobs);
assert!(status.processing);
assert_eq!(
status.processing_job_id.as_deref(),
Some("job-status-check")
);
assert_eq!(status.processing_job_count, 1);
assert_eq!(status.processing_title.as_deref(), Some("Status check job"));
assert_eq!(
status.processing_stage.as_deref(),
Some("Transcribing meeting")
);
let empty: Vec<crate::jobs::ProcessingJob> = Vec::new();
let empty_status = status_with_active_jobs(&empty);
assert_eq!(empty_status.processing_job_count, 0);
assert_eq!(empty_status.processing_job_id, None);
}
#[test]
fn status_with_active_jobs_takes_first_element_as_summary() {
let _guard = crate::test_home_env_lock();
let mk = |id: &str, state: crate::jobs::JobState, title: &str| crate::jobs::ProcessingJob {
id: id.into(),
mode: CaptureMode::Meeting,
content_type: crate::markdown::ContentType::Meeting,
title: Some(title.into()),
audio_path: format!("/tmp/{id}.wav"),
output_path: None,
state,
stage: state.default_stage(),
created_at: chrono::Local::now(),
started_at: None,
finished_at: None,
notice_dismissed_at: None,
recording_started_at: None,
recording_finished_at: None,
context_session_id: None,
user_notes: None,
pre_context: None,
consent: None,
consent_notice: None,
calendar_event: None,
template_slug: None,
recording_health: None,
word_count: None,
error: None,
owner_pid: None,
retry_count: 0,
};
let active = mk("job-a", crate::jobs::JobState::Transcribing, "Active job");
let queued = mk("job-q", crate::jobs::JobState::Queued, "Queued job");
let jobs = vec![active, queued];
let status = status_with_active_jobs(&jobs);
assert_eq!(status.processing_job_id.as_deref(), Some("job-a"));
assert_eq!(status.processing_title.as_deref(), Some("Active job"));
assert_eq!(status.processing_job_count, 2);
}
#[test]
fn sentinel_lifecycle() {
let _guard = crate::test_home_env_lock();
let _ = std::fs::remove_file(stop_sentinel_path());
assert!(!stop_sentinel_path().exists());
write_stop_sentinel().unwrap();
assert!(stop_sentinel_path().exists());
assert!(check_and_clear_sentinel());
assert!(!stop_sentinel_path().exists());
assert!(!check_and_clear_sentinel());
}
#[test]
fn sentinel_write_and_clear() {
let _guard = crate::test_home_env_lock();
write_stop_sentinel().unwrap();
assert!(stop_sentinel_path().exists());
assert!(check_and_clear_sentinel());
assert!(!stop_sentinel_path().exists());
assert!(!check_and_clear_sentinel());
}
#[test]
fn check_and_clear_sentinel_returns_false_when_absent() {
let _guard = crate::test_home_env_lock();
let _ = std::fs::remove_file(stop_sentinel_path());
assert!(!check_and_clear_sentinel());
}
#[test]
fn create_pid_file_writes_using_locked_handle_without_reopen() {
let _guard = crate::test_home_env_lock();
let tempdir = tempfile::tempdir().unwrap();
let pid_path = tempdir.path().join("recording.pid");
create_pid_file(&pid_path).unwrap();
let pid = check_pid_file(&pid_path).unwrap().unwrap();
assert_eq!(pid, std::process::id());
remove_pid_file(&pid_path).unwrap();
assert!(!pid_path.exists());
}
#[test]
fn inspect_pid_file_absent_is_inactive() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("live-transcript.pid");
assert_eq!(inspect_pid_file(&path), PidFileState::Inactive);
assert!(!inspect_pid_file(&path).is_active());
assert_eq!(inspect_pid_file(&path).pid(), None);
}
#[test]
fn inspect_pid_file_live_self_is_active() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("live-transcript.pid");
std::fs::write(&path, std::process::id().to_string()).unwrap();
let state = inspect_pid_file(&path);
assert!(state.is_active());
assert_eq!(state, PidFileState::Active(std::process::id()));
assert_eq!(state.pid(), Some(std::process::id()));
}
#[test]
fn inspect_pid_file_stale_dead_pid_is_inactive_and_cleaned() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("live-transcript.pid");
std::fs::write(&path, "99999999").unwrap();
assert_eq!(inspect_pid_file(&path), PidFileState::Inactive);
assert!(!path.exists(), "stale PID file should be cleaned up");
}
#[test]
fn inspect_pid_file_corrupt_is_inactive() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("live-transcript.pid");
std::fs::write(&path, "not-a-pid").unwrap();
assert_eq!(inspect_pid_file(&path), PidFileState::Inactive);
}
#[test]
fn inspect_pid_file_reports_active_while_guard_is_held() {
let tempdir = tempfile::tempdir().unwrap();
let path = tempdir.path().join("live-transcript.pid");
let guard = create_pid_guard(&path).unwrap();
let state = inspect_pid_file(&path);
assert!(
state.is_active(),
"a held guard must read as active, got {state:?}"
);
#[cfg(windows)]
assert_eq!(state, PidFileState::LockedAlive);
#[cfg(not(windows))]
assert_eq!(state, PidFileState::Active(std::process::id()));
drop(guard);
assert_eq!(inspect_pid_file(&path), PidFileState::Inactive);
}
}