use crate::rules::{Confirmed, Context, Evidence, Finding, Rule, Stance, Trend};
use crate::shell::{Parsed, Simple};
pub const RULE: Rule = Rule {
id: "foreground-poll",
default_stance: Stance::Advise,
evidence: Evidence {
per_1000: 13.6,
measured: "2026-09-05",
trend: Trend::Flat(8),
},
examine,
confirm: Some(confirm),
};
const LOOPS: &[&str] = &["until", "while", "for"];
fn is_sleep(cmd: &Simple) -> bool {
let mut words = cmd
.words
.iter()
.filter(|w| !w.quoted)
.map(|w| w.text.as_str());
match words.next() {
Some("sleep") => true,
Some("do") => words.next() == Some("sleep"),
_ => false,
}
}
fn examine(parsed: &Parsed) -> Option<Finding> {
let clauses = parsed.clauses();
for cmd in parsed.judgeable() {
if cmd.program() == Some("gh")
&& cmd.subcommand() == Some("run")
&& cmd.operands().get(1).is_some_and(|w| w.text == "watch")
{
return Some(finding(cmd.at, cmd.end));
}
}
let head = clauses
.iter()
.position(|c| c.opaque.is_none() && c.program().is_some_and(|p| LOOPS.contains(&p)))?;
let sleep = clauses.iter().skip(head + 1).find(|c| is_sleep(c))?;
let end = clauses
.iter()
.skip(head + 1)
.find(|c| {
c.words
.first()
.is_some_and(|w| !w.quoted && w.text == "done")
})
.map(|c| c.end)
.unwrap_or(sleep.end);
Some(finding(clauses[head].at, end))
}
fn finding(at: usize, end: usize) -> Finding {
Finding {
reason: "the Bash tool kills a foreground command at its timeout (ten minutes by \
default) and reports only the kill; a polling loop or `gh run watch` \
written to outlast a CI run pays the whole wait and is cut off one poll \
short of the answer — measured: 96 commands killed at the cap."
.to_string(),
remedy: "Run the same wait with `run_in_background: true` (one notification when \
it exits, no clock), or use the harness's own completion notice for work \
it started. Keep a foreground command well inside the timeout."
.to_string(),
span: at..end,
}
}
fn seconds(t: &str) -> Option<u64> {
let (body, unit) = match t.chars().last() {
Some(u @ ('s' | 'm' | 'h' | 'd')) => (&t[..t.len() - 1], u),
_ => (t, 's'),
};
let n: f64 = body.parse().ok()?;
let mult = match unit {
'm' => 60.0,
'h' => 3600.0,
'd' => 86400.0,
_ => 1.0,
};
Some((n * mult).ceil() as u64)
}
pub fn budget(parsed: &Parsed) -> Option<u64> {
let clauses = parsed.clauses();
let raw: String = clauses
.iter()
.flat_map(|c| c.words.iter().map(|w| w.raw.as_str()))
.collect::<Vec<_>>()
.join(" ");
if let Some(i) = raw.find("SECONDS+") {
let digits: String = raw[i + 8..]
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if let Ok(n) = digits.parse::<u64>() {
return Some(n);
}
}
if let Some(i) = raw.find("--timeout=") {
let t: String = raw[i + 10..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '.')
.collect();
if let Some(n) = seconds(&t) {
return Some(n);
}
}
let sleep = clauses.iter().find(|c| is_sleep(c)).and_then(|c| {
c.words
.iter()
.filter(|w| !w.quoted)
.map(|w| w.text.as_str())
.skip_while(|t| *t != "sleep")
.nth(1)
.and_then(seconds)
})?;
let iterations = count(&raw)?;
Some(iterations.saturating_mul(sleep))
}
fn count(raw: &str) -> Option<u64> {
let mut words = raw.split_whitespace().peekable();
while let Some(w) = words.next() {
let number = |s: &str| s.trim_end_matches([')', ']', ';']).parse::<u64>().ok();
match w.trim_start_matches("$(") {
"seq" => {
let a = words.next().and_then(number)?;
return match words.peek().and_then(|b| number(b)) {
Some(b) => Some(b.saturating_sub(a) + 1),
None => Some(a),
};
}
"-lt" | "-le" | "-ge" | "-gt" => {
let n = words.next().and_then(number)?;
return Some(if w == "-le" || w == "-gt" { n + 1 } else { n });
}
t if t.starts_with('{') && t.contains("..") => {
let inner = t.trim_matches(['{', '}']);
let (a, b) = inner.split_once("..")?;
return Some(b.parse::<u64>().ok()?.saturating_sub(a.parse().ok()?) + 1);
}
_ => {}
}
}
None
}
fn confirm(ctx: &Context, _f: &Finding) -> Confirmed {
if ctx.background {
return Confirmed::No("the call already runs in the background");
}
match budget(ctx.parsed) {
Some(secs) if secs * 1000 <= ctx.timeout_ms() => {
Confirmed::No("the loop's own budget fits inside the call's timeout")
}
_ => Confirmed::Yes,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shell::lex;
fn fires(command: &str) -> bool {
examine(&lex(command)).is_some()
}
#[test]
fn a_polling_loop_has_the_shape() {
assert!(fires(
"until [ \"$(gh pr checks 247 2>/dev/null | grep -c pending)\" = \"0\" ]; do sleep 30; done; echo done"
));
assert!(fires("while true; do sleep 5; done"));
assert!(fires(
"for i in $(seq 1 18); do n=$(gh pr checks 247 | grep -c pending); if [ \"$n\" = \"0\" ]; then break; fi; sleep 30; done"
));
assert!(fires(
"cd /repo && until grep -q '^exit=' push.log; do sleep 10; done; tail -3 push.log"
));
}
#[test]
fn gh_run_watch_is_a_poll_too() {
assert!(fires("gh run watch 33107191361 --exit-status"));
assert!(fires(
"cd ~/x && gh run watch 1 > /dev/null 2>&1; gh run view 1 --json conclusion"
));
}
#[test]
fn a_loop_that_does_not_sleep_is_a_loop() {
assert!(!fires("for f in a b c; do echo $f; done"));
assert!(!fires("for w in x y; do kubectl get workflow $w; done"));
}
#[test]
fn a_single_sleep_is_not_a_poll() {
assert!(!fires("sleep 2; gh pr checks 229"));
assert!(!fires("sleep 20 && gh run list --limit 1"));
}
#[test]
fn a_background_call_is_not_confirmed() {
let parsed = lex("while true; do sleep 5; done");
let f = examine(&parsed).expect("fires");
let ctx = Context {
cwd: std::path::Path::new("/"),
parsed: &parsed,
background: true,
timeout_ms: None,
};
assert!(matches!(confirm(&ctx, &f), Confirmed::No(_)));
let ctx = Context {
background: false,
timeout_ms: None,
..ctx
};
assert!(matches!(confirm(&ctx, &f), Confirmed::Yes));
}
#[test]
fn a_loop_that_fits_its_timeout_is_not_confirmed() {
let src =
"for i in $(seq 1 6); do gh pr checks 1 | grep -q pending || break; sleep 10; done";
let parsed = lex(src);
assert_eq!(budget(&parsed), Some(60));
let f = examine(&parsed).expect("fires");
let ctx = Context {
cwd: std::path::Path::new("/"),
parsed: &parsed,
background: false,
timeout_ms: None,
};
assert!(matches!(confirm(&ctx, &f), Confirmed::No(_)));
let parsed = lex("for i in $(seq 1 36); do sleep 15; done");
assert_eq!(budget(&parsed), Some(540));
let f = examine(&parsed).unwrap();
let ctx = Context {
parsed: &parsed,
..ctx
};
assert!(matches!(confirm(&ctx, &f), Confirmed::Yes));
let ctx = Context {
timeout_ms: Some(600_000),
..ctx
};
assert!(matches!(confirm(&ctx, &f), Confirmed::No(_)));
}
#[test]
fn the_budget_reads_counters_deadlines_and_nothing() {
assert_eq!(
budget(&lex(
"i=0; until [ $i -ge 16 ]; do sleep 30; i=$((i+1)); done"
)),
Some(480)
);
assert_eq!(
budget(&lex("while [ $i -lt 40 ]; do sleep 20; done")),
Some(800)
);
assert_eq!(budget(&lex("for i in {1..20}; do sleep 5; done")), None);
assert_eq!(
budget(&lex("end=$((SECONDS+540)); until x; do sleep 5; done")),
Some(540)
);
assert_eq!(
budget(&lex(
"kubectl wait --for=condition=Ready pod/x --timeout=180s"
)),
Some(180)
);
assert_eq!(budget(&lex("while true; do sleep 5; done")), None);
assert_eq!(
budget(&lex("until grep -q done f; do sleep 30; done")),
None
);
assert_eq!(budget(&lex("gh run watch 1 --exit-status")), None);
}
#[test]
fn the_span_covers_the_loop() {
let src = "git fetch -q; until [ -f done ]; do sleep 1; done; echo ok";
let f = examine(&lex(src)).expect("fires");
assert_eq!(
src[f.span.clone()].trim().trim_end_matches(';'),
"until [ -f done ]; do sleep 1; done"
);
}
}