1use crate::{
3 CtlError as Error,
4 state::{SignalPaths, unix_ms},
5};
6use serde::{Deserialize, Serialize};
7use std::{
8 fs,
9 io::{ErrorKind, Write},
10};
11
12#[derive(Debug, Serialize, Deserialize)]
13pub struct StopRecord {
14 pub version: u32,
15 pub requested_ms: u64,
16 pub source: String,
17 pub pid: u32,
18 pub display_epoch: Option<String>,
19}
20
21impl SignalPaths {
22 pub fn stop_requested(&self) -> bool {
23 match fs::symlink_metadata(self.stop_file()) {
24 Ok(_) => true,
25 Err(e) => e.kind() != ErrorKind::NotFound,
26 }
27 }
28
29 pub fn request_stop(&self) -> Result<(), Error> {
30 self.request_stop_from("explicit_request", None)
31 }
32
33 pub fn request_stop_from(&self, source: &str, epoch: Option<&str>) -> Result<(), Error> {
34 fs::create_dir_all(&self.dir)
35 .map_err(|e| Error::internal(format!("create stop directory: {e}")))?;
36 let mut file = match fs::OpenOptions::new()
39 .write(true)
40 .create_new(true)
41 .open(self.stop_file())
42 {
43 Ok(file) => file,
44 Err(e) if e.kind() == ErrorKind::AlreadyExists => return Ok(()),
45 Err(e) => return Err(Error::internal(format!("create emergency stop: {e}"))),
46 };
47 let record = StopRecord {
48 version: 1,
49 requested_ms: unix_ms(),
50 source: source.into(),
51 pid: std::process::id(),
52 display_epoch: epoch.map(str::to_owned),
53 };
54 let bytes = serde_json::to_vec(&record).map_err(|e| Error::internal(e.to_string()))?;
55 file.write_all(&bytes)
56 .and_then(|_| file.sync_all())
57 .map_err(|e| Error::internal(format!("persist emergency stop: {e}")))
58 }
59
60 pub fn stop_record(&self) -> Option<StopRecord> {
61 let record: StopRecord = serde_json::from_slice(&fs::read(self.stop_file()).ok()?).ok()?;
62 (record.version == 1).then_some(record)
63 }
64
65 pub fn stop_timestamp(&self) -> Option<u64> {
66 self.stop_requested().then(|| {
67 self.stop_record()
68 .map(|r| r.requested_ms)
69 .or_else(|| {
70 fs::read_to_string(self.stop_file())
71 .ok()?
72 .trim()
73 .parse()
74 .ok()
75 })
76 .unwrap_or(u64::MAX)
77 })
78 }
79
80 pub fn stop_details(&self) -> String {
81 if !self.stop_requested() {
82 return String::new();
83 }
84 let origin = match self.stop_record() {
85 Some(r) => format!(
86 "来源:{};{} 秒前触发;进程:{};会话:{}",
87 match r.source.as_str() {
88 "hotkey" => "急停快捷键",
89 "menu" => "右键菜单",
90 "details_button" => "详情窗口急停按钮",
91 _ => &r.source,
92 },
93 unix_ms().saturating_sub(r.requested_ms) / 1000,
94 r.pid,
95 r.display_epoch.as_deref().unwrap_or("未知")
96 ),
97 None => "旧标记或记录不可读,来源未知;停止状态仍保留".into(),
98 };
99 format!("\n急停跨重启保留。{origin}\n请点击“解除急停”;解除后旧任务仍需核验并显式继续。")
100 }
101
102 pub fn clear_stop(&self) -> Result<(), Error> {
104 match fs::remove_file(self.stop_file()) {
105 Ok(()) => {}
106 Err(e) if e.kind() == ErrorKind::NotFound => {}
107 Err(e) => return Err(Error::internal(format!("clear emergency stop: {e}"))),
108 }
109 if self.stop_requested() {
110 return Err(Error::internal(
111 "stop remains active or could not be verified",
112 ));
113 }
114 Ok(())
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 fn paths() -> SignalPaths {
122 SignalPaths::at(std::env::temp_dir().join(crate::snapshot::new_snapshot_id()))
123 }
124 #[test]
125 fn record_preserves_first_origin_and_restart_does_not_clear() {
126 let p = paths();
127 p.request_stop_from("hotkey", Some("session-a")).unwrap();
128 let before = fs::read(p.stop_file()).unwrap();
129 let restarted = SignalPaths::at(&p.dir);
130 restarted
131 .request_stop_from("menu", Some("session-b"))
132 .unwrap();
133 assert_eq!(before, fs::read(p.stop_file()).unwrap());
134 let mut value = serde_json::to_value(p.stop_record().unwrap()).unwrap();
135 value["requested_ms"] = 123.into();
136 value["pid"] = 42.into();
137 assert_eq!(
138 value,
139 serde_json::from_str::<serde_json::Value>(include_str!(
140 "../tests/golden/stop_record.json"
141 ))
142 .unwrap()
143 );
144 p.clear_stop().unwrap();
145 assert!(!p.stop_requested());
146 fs::remove_dir_all(p.dir).unwrap();
147 }
148 #[test]
149 fn malformed_and_legacy_markers_remain_stopped() {
150 let p = paths();
151 fs::create_dir_all(&p.dir).unwrap();
152 for text in ["123", "", "corrupt"] {
153 fs::write(p.stop_file(), text).unwrap();
154 assert!(p.stop_timestamp().is_some());
155 assert!(p.stop_details().contains("来源未知"));
156 p.request_stop().unwrap();
157 assert_eq!(fs::read_to_string(p.stop_file()).unwrap(), text);
158 }
159 p.clear_stop().unwrap();
160 fs::remove_dir_all(p.dir).unwrap();
161 }
162 #[test]
163 fn clear_failure_is_visible_and_unknown_path_blocks() {
164 let p = paths();
165 fs::create_dir_all(p.stop_file()).unwrap();
166 assert!(p.stop_requested());
167 assert_eq!(p.clear_stop().unwrap_err().code, crate::ErrorCode::Internal);
168 assert!(p.stop_requested());
169 fs::remove_dir_all(p.dir).unwrap();
170 }
171
172 #[test]
173 fn stop_persistence_failure_is_not_reported_as_success() {
174 let p = paths();
175 fs::write(&p.dir, "not a directory").unwrap();
176 assert_eq!(
177 p.request_stop().unwrap_err().code,
178 crate::ErrorCode::Internal
179 );
180 fs::remove_file(p.dir).unwrap();
181 }
182}