Skip to main content

actl_core/
stop.rs

1//! Sticky emergency stop, independent of display lifetime. Unknown state blocks input.
2use 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        // Preserve the first request and any unrecognized old marker. An incomplete
37        // write still leaves a stop marker, never an apparent permission to execute.
38        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    /// Explicit user action only; verify absence after removal and report failures.
103    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}