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(),
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