Skip to content
Open
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
105 changes: 103 additions & 2 deletions src-tauri/src/app_exit_monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub enum PreviousRunClassification {
ActivePreviousInstance,
ConfirmedCrash,
PlannedRestartOrUpdate,
CleanExit,
UncleanExit,
}

Expand Down Expand Up @@ -250,16 +251,22 @@ fn classify_previous_run(
.filter(|event| event.pid == marker.pid && event.timestamp >= marker.started_at);
let mut confirmed_crash = crash_log_modified_after_marker;
let mut planned_restart = false;
let mut clean_exit = false;
for event in same_pid_events {
if matches!(event.kind.as_str(), "panic" | "forced_exit") {
confirmed_crash = true;
}
if event.kind == "clean_exit" && is_planned_exit_reason(&event.reason) {
planned_restart = true;
if event.kind == "clean_exit" {
clean_exit = true;
if is_planned_exit_reason(&event.reason) {
planned_restart = true;
}
}
}
if confirmed_crash {
PreviousRunClassification::ConfirmedCrash
} else if clean_exit && !planned_restart {
PreviousRunClassification::CleanExit
} else if planned_restart {
PreviousRunClassification::PlannedRestartOrUpdate
} else {
Expand Down Expand Up @@ -509,6 +516,100 @@ mod tests {
);
}

#[test]
fn clean_exit_without_restart_is_clean_not_unclean_or_crash() {
let marker = marker_with_identity();
let events = vec![ExitEvent {
timestamp: "2026-08-25 10:01:00.000".to_string(),
kind: "clean_exit".to_string(),
reason: "user_requested_exit".to_string(),
exit_code: Some(0),
version: "test".to_string(),
os: "test".to_string(),
arch: "test".to_string(),
pid: marker.pid,
details: None,
}];

assert_eq!(
classify_previous_run(
Some(&marker),
&events,
false,
None,
marker.config_scope.as_deref().expect("marker scope"),
),
PreviousRunClassification::CleanExit
);
}

#[test]
fn clean_exit_with_restart_reason_remains_a_planned_restart() {
let marker = marker_with_identity();
let events = vec![ExitEvent {
timestamp: "2026-08-25 10:01:00.000".to_string(),
kind: "clean_exit".to_string(),
reason: "process_restart".to_string(),
exit_code: Some(0),
version: "test".to_string(),
os: "test".to_string(),
arch: "test".to_string(),
pid: marker.pid,
details: None,
}];

assert_eq!(
classify_previous_run(
Some(&marker),
&events,
false,
None,
marker.config_scope.as_deref().expect("marker scope"),
),
PreviousRunClassification::PlannedRestartOrUpdate
);
}

#[test]
fn panic_after_clean_exit_is_still_a_confirmed_crash() {
let marker = marker_with_identity();
let events = vec![
ExitEvent {
timestamp: "2026-08-25 10:01:00.000".to_string(),
kind: "clean_exit".to_string(),
reason: "user_requested_exit".to_string(),
exit_code: Some(0),
version: "test".to_string(),
os: "test".to_string(),
arch: "test".to_string(),
pid: marker.pid,
details: None,
},
ExitEvent {
timestamp: "2026-08-25 10:05:00.000".to_string(),
kind: "panic".to_string(),
reason: "test panic".to_string(),
exit_code: None,
version: "test".to_string(),
os: "test".to_string(),
arch: "test".to_string(),
pid: marker.pid,
details: None,
},
];

assert_eq!(
classify_previous_run(
Some(&marker),
&events,
false,
None,
marker.config_scope.as_deref().expect("marker scope"),
),
PreviousRunClassification::ConfirmedCrash
);
}

#[test]
fn marker_ownership_requires_the_complete_current_process_identity() {
let current = crate::process_identity::current_process_identity()
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,7 @@ pub fn run() {
RecoverySeverity::Info,
"none",
)),
app_exit_monitor::PreviousRunClassification::CleanExit => None,
app_exit_monitor::PreviousRunClassification::NoPreviousRun => None,
};
if let Some((kind, severity, next_step)) = mapped {
Expand Down
54 changes: 52 additions & 2 deletions src-tauri/src/process_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,57 @@ pub(crate) fn tcp_listener_owner_pid(port: u16) -> Option<u32> {
None
}

#[cfg(all(unix, not(target_os = "windows")))]
#[cfg(target_os = "macos")]
pub(crate) fn process_identity(pid: u32) -> Option<ProcessIdentity> {
use libc::{proc_pidinfo, proc_pidpath, PROC_PIDTBSDINFO};

if pid == 0 {
return None;
}

let mut path_buf = [0u8; 4096];
let path_len = unsafe {
proc_pidpath(
pid as libc::pid_t,
path_buf.as_mut_ptr().cast(),
path_buf.len() as u32,
)
};
if path_len <= 0 {
return None;
}
let executable_path = String::from_utf8_lossy(&path_buf[..path_len as usize]).into_owned();

let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::uninit();
let size = std::mem::size_of::<libc::proc_bsdinfo>();
if unsafe {
proc_pidinfo(
pid as libc::c_int,
PROC_PIDTBSDINFO,
0,
info.as_mut_ptr().cast(),
size as libc::c_int,
)
} != size as libc::c_int
{
return None;
}
let info = unsafe { info.assume_init() };
let started_at_ticks = info.pbi_start_tvsec.saturating_mul(1_000_000) + info.pbi_start_tvusec;

Some(ProcessIdentity {
pid,
executable_path,
started_at_ticks,
})
}

#[cfg(target_os = "macos")]
pub(crate) fn tcp_listener_owner_pid(_port: u16) -> Option<u32> {
None
}

#[cfg(all(unix, not(any(target_os = "windows", target_os = "macos"))))]
pub(crate) fn process_identity(pid: u32) -> Option<ProcessIdentity> {
let proc_dir = PathBuf::from("/proc").join(pid.to_string());
let executable_path = std::fs::read_link(proc_dir.join("exe"))
Expand All @@ -189,7 +239,7 @@ pub(crate) fn process_identity(pid: u32) -> Option<ProcessIdentity> {
})
}

#[cfg(all(unix, not(target_os = "windows")))]
#[cfg(all(unix, not(any(target_os = "windows", target_os = "macos"))))]
pub(crate) fn tcp_listener_owner_pid(_port: u16) -> Option<u32> {
None
}
Expand Down
Loading
Loading