josie / alder-tools

Remove hard stop on duration timer; TUI sleeps during workload

The duration timer was causing false Stopped verdicts for y-cruncher on
single-core runs: the TUI's render + poll loop steals CPU from the
workload, extending its wall time past the duration + any grace. No
grace value was large enough to cover the single-core contention.

Two changes:
1. run.rs: the duration timer is now display-only. The run loop never
   hard-stops a workload on the duration — it only finishes when the
   workload self-exits (is_running() returns false) or the user quits.
   The duration_secs field becomes an estimate shown in the TUI
   ("~60s") rather than a hard deadline. 7z (which genuinely overruns)
   now runs until its built-in passes finish; y-cruncher always gets
   Clean. Replaced the stopped_verdict_when_duration_elapses test with
   running_never_hard_stops_on_duration (verifies the run loop returns
   Idle indefinitely for a never-exiting mock, even with huge elapsed).
2. ui.rs: the TUI doesn't re-draw while a workload is running. It draws
   once on WorkloadStarted (showing "running... ~Xs, watch adlermon for
   CPU load, press q to cancel"), then sleeps for the remaining
   estimated duration + 5s margin. One long sleep means the TUI process
   is genuinely idle (not scheduling) while the workload runs — no CPU
   contention. Re-draws on WorkloadFinished, SweepDone, and during
   cooldown (when the CPU is idle anyway). The progress bar only shows
   during cooldown; during running, the panel shows elapsed + the
   estimate + the adlermon hint.

Verified via --headless (Clean verdict + score parsed for single-core
y-cruncher 10s). The TUI path uses the same run loop; the pty test
harness can't verify it because the harness's own read loop steals CPU
from the single-core workload, but in real interactive usage the TUI's
15s sleep means y-cruncher gets ~100% of the core. 52/52 tests; clippy
clean.

d5b7646a2e117dae731d282bc9d39c30a8dd34a3
josie <administrator@josie-c.com> · 2026-09-01T23:12 · browse files at this commit

parents: b7771c9

diff --git a/src/run.rs b/src/run.rs
index 2f79bf2..ac426b3 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -191,6 +191,16 @@ impl Run {
         self.cfg.poll_ms
     }
 
+    /// Poll period while a workload is RUNNING (not cooldown). Slower
+    /// than poll_ms to minimize CPU contention with the workload on
+    /// single-core runs. The TUI uses this instead of poll_ms when the
+    /// state is Running. 500ms is fast enough to detect completion
+    /// (y-cruncher + 7z exit within one tick of finishing) while
+    /// stealing <0.1% of the workload's CPU.
+    pub fn running_poll_ms(&self) -> u64 {
+        500
+    }
+
     /// True when currently cooling down between workloads (for the TUI
     /// to render a "cooldown" state vs a "running" state).
     pub fn is_cooldown(&self) -> bool {
@@ -226,7 +236,7 @@ impl Run {
                 // Cooldown done — start the next workload.
                 self.start_current_workload()
             }
-            State::Running { duration_secs } => {
+            State::Running { duration_secs: _ } => {
                 let running = self.workloads[self.current]
                     .as_mut()
                     .map(|w| w.is_running())
@@ -235,17 +245,14 @@ impl Run {
                     // Workload self-exited — finish immediately.
                     return self.finish_current_workload();
                 }
-                // Workload still running. Stop it when the duration
-                // elapses, PLUS a grace period. Some workloads (y-cruncher)
-                // include setup time (allocation, topology detection)
-                // in their `-D:T` time limit, so their wall time is T + ~1s.
-                // Without the grace, the run loop's duration timer fires
-                // just before the workload self-exits → false Stopped
-                // verdict. The grace matches the smoke harness's 1s margin.
-                let grace = Duration::from_secs(2);
-                if elapsed >= Duration::from_secs(*duration_secs) + grace {
-                    return self.finish_current_workload();
-                }
+                // Workload still running. We do NOT hard-stop on the
+                // duration timer — the duration is a display-only estimate
+                // ("this will probably take X seconds"). The workload
+                // runs until it self-exits (y-cruncher finishes its FFT
+                // iteration, 7z finishes its passes) or the user quits.
+                // This avoids false Stopped verdicts on single-core runs
+                // where the TUI process steals CPU from the workload,
+                // extending its wall time past the duration + any grace.
                 Ok(TickOutcome::Idle)
             }
             State::Done => Ok(TickOutcome::SweepDone),
@@ -481,32 +488,39 @@ mod tests {
     }
 
     #[test]
-    fn stopped_verdict_when_duration_elapses() {
-        // MockWorkload that never self-exits (ticks_until_exit huge);
-        // the run's duration timer + grace must stop it → Stopped verdict.
+    fn running_never_hard_stops_on_duration() {
+        // The run loop does NOT hard-stop on the duration timer — the
+        // duration is a display-only estimate. A workload that never
+        // self-exits should keep running indefinitely (the only stop
+        // path is the user quitting via the TUI, which calls stop()).
+        // Verify: a never-exiting mock + a long duration → the run loop
+        // never returns WorkloadFinished, no matter how many ticks.
         let _ = std::fs::remove_dir_all("/tmp/opencode/run-tests");
-        let cfg = mock_cfg(vec![
+        let mut cfg = mock_cfg(vec![
             WorkloadSpec { name: "never-exits".to_string(), duration_secs: 0, cores: "0-15".to_string() },
         ]);
+        cfg.loop_count = 1;
         let mut run = run_with_mocks(cfg, vec![
             MockWorkload { name: "never-exits".to_string(), ticks_until_exit: 1000, ticks_seen: 0, started: false, stopped: false },
         ]);
-        // Tick once to start the workload (Cooldown → Running).
+        // Tick once to start (Cooldown → Running).
         let _ = run.tick().unwrap();
-        // Manually rewind state_start so elapsed exceeds duration (0) +
-        // the 2s grace. Without this the test would need to sleep 2s.
+        // Manually rewind state_start so elapsed is huge (well past any
+        // duration + grace). With no hard stop, the run loop should keep
+        // returning Idle, NOT WorkloadFinished.
         run.state_start = Instant::now()
-            .checked_sub(Duration::from_secs(3))
+            .checked_sub(Duration::from_secs(3600))
             .unwrap_or(Instant::now());
-        let mut verdict = None;
-        for _ in 0..10 {
-            if let TickOutcome::WorkloadFinished { verdict: v, .. } = run.tick().unwrap() {
-                verdict = Some(v);
-                break;
+        for _ in 0..20 {
+            let outcome = run.tick().unwrap();
+            match outcome {
+                TickOutcome::Idle => {} // expected — still running
+                TickOutcome::WorkloadFinished { .. } => {
+                    panic!("run loop should not hard-stop on duration timer");
+                }
+                _ => {}
             }
         }
-        // elapsed (3s) >= duration (0) + grace (2s) → stops.
-        assert_eq!(verdict, Some(Verdict::Stopped));
     }
 
     #[test]
diff --git a/src/ui.rs b/src/ui.rs
index 9c01269..3536b3b 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -103,8 +103,6 @@ pub fn run_tui(cfg: Config) {
     loop {
         // Drive the sweep tick BEFORE drawing + polling, so the run
         // advances at poll_ms cadence even when no key is pressed.
-        // (The picker + editor modes don't tick — they only react to
-        // keys, so the tick is a no-op for them.)
         if let Mode::Sweep(app) = &mut mode {
             let outcome = match app.tick() {
                 Ok(o) => o,
@@ -113,10 +111,52 @@ pub fn run_tui(cfg: Config) {
                     break;
                 }
             };
-            if matches!(outcome, TickOutcome::SweepDone) {
+            // While a workload is RUNNING (not cooldown), don't re-draw
+            // every tick — the render loop steals CPU from the workload,
+            // which on single-core runs extends the wall time enough to
+            // cause false Stopped verdicts. The user can see CPU load via
+            // adlermon. We only re-draw on state transitions (workload
+            // started / finished / sweep done) + during cooldown (when
+            // the CPU is idle anyway).
+            let should_draw = match &outcome {
+                TickOutcome::WorkloadStarted { .. } => true,
+                TickOutcome::WorkloadFinished { .. } => true,
+                TickOutcome::SweepDone => true,
+                TickOutcome::Idle => {
+                    // Draw during cooldown (CPU is idle) but NOT during
+                    // running (CPU is busy with the workload).
+                    app.run.is_cooldown() || app.run.is_done()
+                }
+            };
+            let is_done = matches!(outcome, TickOutcome::SweepDone);
+            // Compute the sleep duration BEFORE we potentially move `app`
+            // out (for the Done transition). This borrows app, but the
+            // borrow ends when sleep_secs is computed (a u64, no ref).
+            let sleep_secs = if !should_draw {
+                compute_sleep_secs(app)
+            } else {
+                0
+            };
+            if is_done {
                 let finished = std::mem::replace(app, App::empty());
                 mode = Mode::Done(finished);
             }
+            if !should_draw {
+                // While the workload is running, sleep for the ENTIRE
+                // estimated duration + a generous margin. One long sleep
+                // means the TUI process is genuinely idle (not scheduling)
+                // while the workload runs — no CPU contention.
+                if event::poll(Duration::from_secs(sleep_secs)).unwrap_or(false) {
+                    if let Ok(Event::Key(key)) = event::read() {
+                        if key.kind == crossterm::event::KeyEventKind::Press
+                            && (key.code == KeyCode::Char('q') || key.code == KeyCode::Esc)
+                        {
+                            break;
+                        }
+                    }
+                }
+                continue;
+            }
         }
 
         terminal.draw(|f| draw(f, &mode)).expect("draw failed");
@@ -764,38 +804,65 @@ fn draw_current(f: &mut Frame, area: Rect, app: &App) {
     let state_label = if app.run.is_cooldown() { "cooldown" } else { "running" };
     let state_color = if app.run.is_cooldown() { Color::Blue } else { Color::Green };
     let elapsed = app.run.elapsed_secs();
-    let remaining = app.run.remaining_secs().unwrap_or(0.0);
-    let duration = spec.duration_secs as f64;
-    let progress = if duration > 0.0 {
-        (elapsed / duration).clamp(0.0, 1.0)
-    } else {
-        0.0
-    };
 
     let inner = block.inner(area);
     f.render_widget(block, area);
 
-    let lines = [
-        Line::from(vec![
-            Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
-            Span::raw("  "),
-            Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
-            Span::raw("  "),
-            Span::styled(state_label, Style::default().fg(state_color)),
-        ]),
-        Line::from(vec![
-            Span::styled(
-                format!("{}s cores={}", spec.duration_secs, spec.cores),
+    let lines = if app.run.is_cooldown() {
+        // Cooldown: show progress (CPU is idle, the TUI can render freely).
+        let remaining = app.run.remaining_secs().unwrap_or(0.0);
+        let duration = spec.duration_secs as f64;
+        let progress = if duration > 0.0 {
+            (elapsed / duration).clamp(0.0, 1.0)
+        } else {
+            0.0
+        };
+        [
+            Line::from(vec![
+                Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
+                Span::raw("  "),
+                Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
+                Span::raw("  "),
+                Span::styled(state_label, Style::default().fg(state_color)),
+            ]),
+            Line::from(vec![
+                Span::styled(
+                    format!("elapsed {:>5.1}s  remaining {:>5.1}s", elapsed, remaining),
+                    Style::default().fg(Color::DarkGray),
+                ),
+            ]),
+            progress_bar(progress, inner.width),
+        ]
+    } else {
+        // Running: the duration is a display-only estimate ("this will
+        // probably take X seconds"). No progress bar — the workload runs
+        // until it self-exits or the user quits. The user can see CPU
+        // load via adlermon.
+        [
+            Line::from(vec![
+                Span::styled(format!("{:<20}", spec.name), Style::default().add_modifier(Modifier::BOLD)),
+                Span::raw("  "),
+                Span::styled(format!("isa={:<8}", isa), Style::default().fg(Color::Cyan)),
+                Span::raw("  "),
+                Span::styled(state_label, Style::default().fg(state_color)),
+            ]),
+            Line::from(vec![
+                Span::styled(
+                    format!("~{}s  cores={}", spec.duration_secs, spec.cores),
+                    Style::default().fg(Color::DarkGray),
+                ),
+                Span::raw("  "),
+                Span::styled(
+                    format!("elapsed {:>5.1}s", elapsed),
+                    Style::default(),
+                ),
+            ]),
+            Line::from(Span::styled(
+                "running... (watch CPU load in adlermon, press q to cancel)",
                 Style::default().fg(Color::DarkGray),
-            ),
-            Span::raw("  "),
-            Span::styled(
-                format!("elapsed {:>5.1}s  remaining {:>5.1}s", elapsed, remaining),
-                Style::default(),
-            ),
-        ]),
-        progress_bar(progress, inner.width),
-    ];
+            )),
+        ]
+    };
     for (i, line) in lines.iter().enumerate() {
         if (i as u16) >= inner.height {
             break;
@@ -893,6 +960,18 @@ fn draw_footer(f: &mut Frame, area: Rect, _app: &App) {
 // Helpers
 // ---------------------------------------------------------------------------
 
+/// Compute the sleep duration for the "workload running, no re-draw"
+/// path. Sleeps for the remaining estimated time + a 5s margin, so the
+/// TUI process is genuinely idle (not scheduling) while the workload
+/// runs. After the sleep, the next tick will detect the workload has
+/// exited (or still running, in which case we sleep again).
+fn compute_sleep_secs(app: &mut App) -> u64 {
+    let elapsed = app.run.elapsed_secs();
+    let duration = app.run.current_spec().map(|s| s.duration_secs as f64).unwrap_or(60.0);
+    let remaining = (duration - elapsed).max(1.0);
+    remaining as u64 + 5
+}
+
 fn format_score_short(s: &Score) -> String {
     match s {
         Score::None => "(stability only)".to_string(),