fix: harden timer lifecycle
This commit is contained in:
+71
-6
@@ -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 mut config: Config = serde_yaml::from_str(&data).context("parse config")?;
|
||||
normalize_config(&mut config);
|
||||
validate_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -521,14 +522,31 @@ fn run_timer(
|
||||
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() {
|
||||
write_timer_line(output, "finished");
|
||||
}
|
||||
let mut failures = Vec::new();
|
||||
for command in build_action_commands(config) {
|
||||
if let Some(output) = output.as_deref_mut() {
|
||||
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(())
|
||||
}
|
||||
@@ -562,16 +580,19 @@ fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
|
||||
|
||||
fn run_tui(path: PathBuf, config: Config) -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let result = (|| {
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
let mut app = App::new(path, config);
|
||||
let result = app.run(&mut terminal);
|
||||
disable_raw_mode().ok();
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
|
||||
terminal.show_cursor().ok();
|
||||
result
|
||||
})();
|
||||
disable_raw_mode().ok();
|
||||
execute!(io::stdout(), LeaveAlternateScreen).ok();
|
||||
result
|
||||
}
|
||||
|
||||
struct App {
|
||||
@@ -915,9 +936,11 @@ impl App {
|
||||
fn stop_timer(&mut self) {
|
||||
if let Some(timer) = self.timer.take() {
|
||||
timer.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
self.apply_timer_result(timer.handle.join());
|
||||
} else {
|
||||
self.status = "timer stopped".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
fn reap_timer(&mut self) {
|
||||
if self
|
||||
@@ -926,13 +949,20 @@ impl App {
|
||||
.is_some_and(|timer| timer.handle.is_finished())
|
||||
{
|
||||
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(Err(err)) if err.to_string() == "timer stopped" => {
|
||||
self.status = "timer stopped".to_string()
|
||||
}
|
||||
Ok(Err(err)) => self.status = format!("timer failed: {err}"),
|
||||
Err(_) => self.status = "timer failed: thread panicked".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timer_snapshot(&self) -> (Duration, Duration, f64) {
|
||||
let Some(timer) = &self.timer else {
|
||||
@@ -1297,6 +1327,41 @@ mod tests {
|
||||
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]
|
||||
fn timer_header_text_compacts_for_narrow_widths() {
|
||||
let remaining = Duration::from_secs(65);
|
||||
|
||||
Reference in New Issue
Block a user