fix: harden timer lifecycle

This commit is contained in:
2026-07-05 02:19:46 +02:00
parent af95742f69
commit b067cf572e
+71 -6
View File
@@ -242,6 +242,7 @@ fn ensure_config(path: &PathBuf) -> Result<Config> {
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut config: Config = serde_yaml::from_str(&data).context("parse config")?; let mut config: Config = serde_yaml::from_str(&data).context("parse config")?;
normalize_config(&mut config); normalize_config(&mut config);
validate_config(&config)?;
Ok(config) Ok(config)
} }
@@ -521,14 +522,31 @@ fn run_timer(
thread::sleep(Duration::from_millis(250)); thread::sleep(Duration::from_millis(250));
} }
if stop.load(Ordering::Relaxed) {
if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped");
}
bail!("timer stopped");
}
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "finished"); write_timer_line(output, "finished");
} }
let mut failures = Vec::new();
for command in build_action_commands(config) { for command in build_action_commands(config) {
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, &format!("run: {}", command.display())); write_timer_line(output, &format!("run: {}", command.display()));
} }
run_command(&command, mode).with_context(|| format!("{} failed", command.label))?; if let Err(err) = run_command(&command, mode) {
failures.push(format!("{} failed: {err}", command.label));
}
}
if !failures.is_empty() {
bail!(
"{} action(s) failed: {}",
failures.len(),
failures.join("; ")
);
} }
Ok(()) Ok(())
} }
@@ -562,16 +580,19 @@ fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
fn run_tui(path: PathBuf, config: Config) -> Result<()> { fn run_tui(path: PathBuf, config: Config) -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let result = (|| {
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
let mut app = App::new(path, config); let mut app = App::new(path, config);
let result = app.run(&mut terminal); let result = app.run(&mut terminal);
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok(); terminal.show_cursor().ok();
result result
})();
disable_raw_mode().ok();
execute!(io::stdout(), LeaveAlternateScreen).ok();
result
} }
struct App { struct App {
@@ -915,9 +936,11 @@ impl App {
fn stop_timer(&mut self) { fn stop_timer(&mut self) {
if let Some(timer) = self.timer.take() { if let Some(timer) = self.timer.take() {
timer.stop.store(true, Ordering::Relaxed); timer.stop.store(true, Ordering::Relaxed);
} self.apply_timer_result(timer.handle.join());
} else {
self.status = "timer stopped".to_string(); self.status = "timer stopped".to_string();
} }
}
fn reap_timer(&mut self) { fn reap_timer(&mut self) {
if self if self
@@ -926,13 +949,20 @@ impl App {
.is_some_and(|timer| timer.handle.is_finished()) .is_some_and(|timer| timer.handle.is_finished())
{ {
let timer = self.timer.take().expect("timer exists"); let timer = self.timer.take().expect("timer exists");
match timer.handle.join() { self.apply_timer_result(timer.handle.join());
}
}
fn apply_timer_result(&mut self, result: thread::Result<Result<()>>) {
match result {
Ok(Ok(())) => self.status = "timer finished".to_string(), Ok(Ok(())) => self.status = "timer finished".to_string(),
Ok(Err(err)) if err.to_string() == "timer stopped" => {
self.status = "timer stopped".to_string()
}
Ok(Err(err)) => self.status = format!("timer failed: {err}"), Ok(Err(err)) => self.status = format!("timer failed: {err}"),
Err(_) => self.status = "timer failed: thread panicked".to_string(), Err(_) => self.status = "timer failed: thread panicked".to_string(),
} }
} }
}
fn timer_snapshot(&self) -> (Duration, Duration, f64) { fn timer_snapshot(&self) -> (Duration, Duration, f64) {
let Some(timer) = &self.timer else { let Some(timer) = &self.timer else {
@@ -1297,6 +1327,41 @@ mod tests {
assert!(text.contains("finished")); assert!(text.contains("finished"));
} }
#[test]
fn run_timer_attempts_all_actions_before_returning_failures() {
let stop = Arc::new(AtomicBool::new(false));
let mut config = quiet_config("1ns");
config.actions.custom.enabled = true;
config.actions.custom.commands = vec!["false".to_string(), "true".to_string()];
let mut output = Vec::new();
let error = run_timer(&config, &stop, RunMode::Cli, Some(&mut output)).unwrap_err();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("run: false"));
assert!(text.contains("run: true"));
assert!(error.to_string().contains("1 action(s) failed"));
}
#[test]
fn ensure_config_validates_loaded_config() {
let path = std::env::temp_dir().join(format!(
"gosleep-timer-invalid-{}-{}.yaml",
std::process::id(),
"power"
));
fs::write(
&path,
"duration: 25m\nactions:\n power:\n mode: suspend\n",
)
.unwrap();
let error = ensure_config(&path).unwrap_err();
fs::remove_file(path).ok();
assert!(error.to_string().contains("invalid power mode"));
}
#[test] #[test]
fn timer_header_text_compacts_for_narrow_widths() { fn timer_header_text_compacts_for_narrow_widths() {
let remaining = Duration::from_secs(65); let remaining = Duration::from_secs(65);