ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_runner_clock.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Gate: no CI runner may move its wall clock underneath a job (#509).
5
6GitHub records every step's ``started_at`` and ``completed_at`` from the
7*runner's own clock*. So a runner whose clock steps backward writes the
8evidence into the API itself: a step that finished before it started, or a step
9that started before the previous one finished. Neither is physically possible;
10both are cheap to look for; and looking turns "the fleet's clocks are fine"
11from an assumption into an observation.
12
13It is not a cosmetic complaint about log timestamps. Every gate whose contract
14is a *duration* rather than a *result* is measured on that clock:
15
16* ``fuzz-sweep`` -- libFuzzer derives ``-max_total_time`` from
17 ``system_clock`` (compiler-rt ``FuzzerInternal.h``), as an unsigned second
18 count. Step the clock back and the budget looks spent, the sweep stops after
19 seconds, and it exits 0. A 60 s sweep was observed reporting
20 ``Done 7363327 runs in 251 second(s)`` and, twice, a NEGATIVE duration --
21 while the job was green. ``run_fuzz.sh`` now measures itself on
22 ``CLOCK_MONOTONIC`` and refuses to certify such a run, but that protects one
23 gate; this checker is what notices the *host* is broken.
24* ``timeout-minutes`` on any job -- the runner enforces it.
25* Any benchmark or latency gate.
26
27The signature was first found on the ``win-ci-*`` WSL2 runners, where the VM's
28RTC ran ~4 minutes fast and ``timesyncd`` stepped it back rather than slewing
29it. A build farm wants slew.
30
31Run::
32
33 check_runner_clock.py # scan recent completed runs
34 check_runner_clock.py --hours 6 # only runs that started recently
35 check_runner_clock.py --runs 60 # widen the scan
36 check_runner_clock.py --selftest # prove the detector still detects
37
38Exit 0 when every step on every runner is time-ordered, 1 when any is not, and
392 when the scan could not be performed at all -- which is a failure, not a
40pass. A clock checker that quietly reports "clean" because it had no token is
41worse than no checker: it also removes the reason to look.
42"""
43
44from __future__ import annotations
45
46import argparse
47import datetime as dt
48import json
49import os
50import shutil
51import subprocess
52import sys
53import urllib.error
54import urllib.request
55
56# The API is spoken directly over HTTPS rather than through `gh api`, and that
57# is a deployment fact rather than a taste: the ra8-ci runner image does not
58# ship the GitHub CLI, so a gate built on it would fail nightly with a
59# provisioning error instead of a verdict. urllib is in the standard library
60# and is on every host this could ever run on.
61K_API_BASE = "https://api.github.com"
62
63# Marker for the line after a _fail() call. _fail() exits, so these raises are
64# unreachable -- they exist so a reader (and a type checker) can see that the
65# function does not fall through to an implicit None.
66K_UNREACHABLE = "unreachable: _fail() exits"
67
68# Whole-second timestamps and ordinary scheduling jitter mean adjacent steps can
69# appear to touch. A real clock step on the observed hosts is ~240 s wide, so a
70# few seconds of slack costs nothing in detection and removes every false
71# positive from rounding. Zero tolerance is reserved for the one comparison that
72# needs none: a step cannot finish before it starts, at any tolerance.
73K_OVERLAP_TOLERANCE_S = 5
74
75# How many completed runs to look back over when --hours is not given. Each run
76# costs one extra API call for its jobs, so this bounds the scan's request
77# count at roughly K_DEFAULT_RUNS + 1.
78K_DEFAULT_RUNS = 40
79
80
81def _fail(message: str) -> None:
82 """Print a fatal message and exit 2 (scan impossible, not scan clean)."""
83 print(f"ERROR: {message}", file=sys.stderr)
84 sys.exit(2)
85
86
87def _token() -> str:
88 """Return an API token, or exit 2 naming every way one could have been given.
89
90 In CI it is the workflow's own ``GITHUB_TOKEN``, whose rate budget is
91 per-repository and separate from the shared user quota. On a developer box
92 it falls back to whatever ``gh`` is already logged in as, so the gate runs
93 locally with nothing exported. No token at all is a failure, never a clean
94 scan.
95 """
96 for name in ("GH_TOKEN", "GITHUB_TOKEN"):
97 value = os.environ.get(name, "").strip()
98 if value:
99 return value
100 exe = shutil.which("gh")
101 if exe is not None:
102 proc = subprocess.run( # noqa: S603 # fixed argv, no shell; gh via shutil.which
103 [str(exe), "auth", "token"],
104 capture_output=True,
105 text=True,
106 check=False,
107 )
108 if proc.returncode == 0 and proc.stdout.strip():
109 return proc.stdout.strip()
110 _fail(
111 "no GitHub API token. Set GH_TOKEN or GITHUB_TOKEN (in a workflow, "
112 "`env: GH_TOKEN: ${{ github.token }}`), or log in with `gh auth login`. "
113 "This gate reads step timestamps from the Actions API; without a token "
114 "there is no scan to report on."
115 )
116 raise AssertionError(K_UNREACHABLE)
117
118
119def _api(path: str, token: str) -> object:
120 """GET one Actions API path and return the decoded JSON, or exit 2 saying why."""
121 url = f"{K_API_BASE}/{path.lstrip('/')}"
122 if not url.startswith(f"{K_API_BASE}/"):
123 _fail(f"refusing to fetch a URL outside {K_API_BASE}: {url}")
124 request = urllib.request.Request( # noqa: S310 # scheme pinned to K_API_BASE above
125 url,
126 headers={
127 "Accept": "application/vnd.github+json",
128 "Authorization": f"Bearer {token}",
129 "User-Agent": "ra8-firmware-runner-clock",
130 "X-GitHub-Api-Version": "2022-11-28",
131 },
132 )
133 try:
134 with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 -- HTTPS URL pinned above
135 return json.loads(response.read().decode("utf-8"))
136 except urllib.error.HTTPError as exc:
137 _fail(f"GET {url} failed: HTTP {exc.code} {exc.reason}")
138 except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc:
139 _fail(f"GET {url} failed: {exc}")
140 raise AssertionError(K_UNREACHABLE)
141
142
143def _parse_ts(value: str | None) -> dt.datetime | None:
144 """Parse a GitHub ISO-8601 timestamp, or None when the field is absent."""
145 if not value:
146 return None
147 try:
148 return dt.datetime.fromisoformat(value)
149 except ValueError:
150 return None
151
152
153def scan_job(job: dict) -> list[dict]:
154 """Return every time-ordering violation in one job's step list.
155
156 Pure: takes the job dictionary the API returns and reads nothing else, so
157 ``--selftest`` can drive both directions without touching the network.
158
159 Two rules, both physically impossible on a sane clock:
160
161 * a step whose ``completed_at`` precedes its own ``started_at``;
162 * a step whose ``started_at`` precedes the previous step's ``completed_at``
163 by more than ``K_OVERLAP_TOLERANCE_S`` (steps within a job are strictly
164 sequential).
165 """
166 findings: list[dict] = []
167 previous_end: dt.datetime | None = None
168 previous_name = ""
169 for step in job.get("steps") or []:
170 start = _parse_ts(step.get("started_at"))
171 end = _parse_ts(step.get("completed_at"))
172 name = step.get("name", "?")
173 if start is not None and end is not None and end < start:
174 findings.append(
175 {
176 "kind": "finished-before-it-started",
177 "step": name,
178 "detail": f"{start:%Y-%m-%dT%H:%M:%SZ} -> {end:%Y-%m-%dT%H:%M:%SZ}",
179 "seconds": (end - start).total_seconds(),
180 "at": start,
181 }
182 )
183 if start is not None and previous_end is not None:
184 gap = (start - previous_end).total_seconds()
185 if gap < -K_OVERLAP_TOLERANCE_S:
186 findings.append(
187 {
188 "kind": "started-before-the-previous-step-finished",
189 "step": name,
190 "detail": (
191 f"'{previous_name}' ended {previous_end:%Y-%m-%dT%H:%M:%SZ}, "
192 f"'{name}' began {start:%Y-%m-%dT%H:%M:%SZ}"
193 ),
194 "seconds": gap,
195 "at": start,
196 }
197 )
198 if end is not None:
199 previous_end = end
200 previous_name = name
201 return findings
202
203
204def _runs(repo: str, token: str, limit: int, hours: int | None) -> list[dict]:
205 """Fetch the most recent completed workflow runs, newest first."""
206 payload = _api(f"repos/{repo}/actions/runs?status=completed&per_page={min(limit, 100)}", token)
207 runs = payload.get("workflow_runs", []) if isinstance(payload, dict) else []
208 if hours is None:
209 return runs[:limit]
210 cutoff = dt.datetime.now(dt.UTC) - dt.timedelta(hours=hours)
211 kept = []
212 for run in runs:
213 started = _parse_ts(run.get("run_started_at") or run.get("created_at"))
214 if started is not None and started >= cutoff:
215 kept.append(run)
216 return kept[:limit]
217
218
219def _report(findings: list[dict], scanned: dict) -> int:
220 """Print the scan result and return the process exit status."""
221 print(
222 f"runner clock scan: {scanned['runs']} completed runs, "
223 f"{scanned['jobs']} jobs, {scanned['steps']} steps"
224 )
225 if not findings:
226 print("every step on every runner is time-ordered.")
227 return 0
228 for item in sorted(findings, key=lambda f: f["at"], reverse=True):
229 print()
230 print(f"SKEW runner={item['runner']} {item['workflow']} / {item['job']}")
231 print(f" step '{item['step']}' {item['kind']}")
232 print(f" {item['detail']} ({item['seconds']:+.0f}s)")
233 per_runner: dict[str, int] = {}
234 for item in findings:
235 per_runner[item["runner"]] = per_runner.get(item["runner"], 0) + 1
236 print()
237 print("per runner:")
238 for runner, count in sorted(per_runner.items(), key=lambda kv: -kv[1]):
239 print(f" {runner}: {count} skewed step(s)")
240 print()
241 print(
242 f"FAIL: {len(findings)} step(s) on {len(per_runner)} runner(s) are not "
243 f"time-ordered. Those runners moved their wall clock underneath a "
244 f"running job, so every time-budgeted gate that lands on them measures "
245 f"the wrong thing (#509). Fix the host's clock discipline -- slew, not "
246 f"step."
247 )
248 return 1
249
250
251def scan(repo: str, limit: int, hours: int | None) -> int:
252 """Scan recent runs of ``repo`` and report every clock-skew finding."""
253 token = _token()
254 findings: list[dict] = []
255 scanned = {"runs": 0, "jobs": 0, "steps": 0}
256 for run in _runs(repo, token, limit, hours):
257 scanned["runs"] += 1
258 payload = _api(f"repos/{repo}/actions/runs/{run['id']}/jobs?per_page=100", token)
259 jobs = payload.get("jobs", []) if isinstance(payload, dict) else []
260 for job in jobs:
261 scanned["jobs"] += 1
262 scanned["steps"] += len(job.get("steps") or [])
263 for finding in scan_job(job):
264 finding["runner"] = job.get("runner_name") or "(unknown runner)"
265 finding["job"] = job.get("name", "?")
266 finding["workflow"] = run.get("name", "?")
267 findings.append(finding)
268 if scanned["steps"] == 0:
269 _fail(
270 "the scan saw zero steps. That is not a clean fleet, it is a scan "
271 "that did not happen -- widen --hours/--runs or check gh's auth."
272 )
273 return _report(findings, scanned)
274
275
276def _case(name: str, job: dict, expected: int) -> bool:
277 """Assert ``scan_job`` returns ``expected`` findings for ``job``."""
278 got = len(scan_job(job))
279 ok = got == expected
280 print(f" {'ok ' if ok else 'FAIL'} {name}: expected {expected}, got {got}")
281 return ok
282
283
284def _step(name: str, start: str | None, end: str | None) -> dict:
285 """Build one API-shaped step record for the selftest."""
286 return {"name": name, "started_at": start, "completed_at": end}
287
288
289def selftest() -> int:
290 """Drive the detector in both directions with synthetic API records."""
291 print("check_runner_clock.py selftest:")
292 healthy = {
293 "steps": [
294 _step("Set up job", "2026-07-28T06:00:00Z", "2026-07-28T06:00:04Z"),
295 _step("checkout", "2026-07-28T06:00:04Z", "2026-07-28T06:00:09Z"),
296 ]
297 }
298 # The real win-ci-3 record from #509: checkout began the instant "Set up
299 # job" ended and then finished four minutes earlier. Only the first rule
300 # fires -- the step boundaries themselves are in order, which is exactly
301 # why the second rule is not sufficient on its own.
302 observed = {
303 "steps": [
304 _step("Set up job", "2026-07-28T06:38:16Z", "2026-07-28T06:38:18Z"),
305 _step("checkout", "2026-07-28T06:38:18Z", "2026-07-28T06:34:12Z"),
306 ]
307 }
308 backward_only = {"steps": [_step("gate", "2026-07-28T06:23:40Z", "2026-07-28T06:21:12Z")]}
309 overlap_only = {
310 "steps": [
311 _step("a", "2026-07-28T06:00:00Z", "2026-07-28T06:05:00Z"),
312 _step("b", "2026-07-28T06:01:00Z", "2026-07-28T06:06:00Z"),
313 ]
314 }
315 rounding = {
316 "steps": [
317 _step("a", "2026-07-28T06:00:00Z", "2026-07-28T06:00:05Z"),
318 _step("b", "2026-07-28T06:00:04Z", "2026-07-28T06:00:09Z"),
319 ]
320 }
321 skipped = {"steps": [_step("never ran", None, None)]}
322 results = [
323 _case("a time-ordered job is clean", healthy, 0),
324 _case("the observed win-ci-3 checkout (#509)", observed, 1),
325 _case("a step that finished before it started", backward_only, 1),
326 _case("a step that started before the previous finished", overlap_only, 1),
327 _case("one second of rounding is not a fault", rounding, 0),
328 _case("a skipped step has no timestamps to judge", skipped, 0),
329 _case("a job with no steps at all", {}, 0),
330 ]
331 if not all(results):
332 print("SELFTEST FAILED: the clock detector no longer behaves as documented.")
333 return 1
334 print(f" {len(results)}/{len(results)} cases as documented.")
335 return 0
336
337
338def main() -> int:
339 """Parse arguments and run either the selftest or a scan."""
340 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
341 parser.add_argument("--repo", default="bsikar/ra8-firmware", help="owner/name")
342 parser.add_argument("--runs", type=int, default=K_DEFAULT_RUNS, help="completed runs to scan")
343 parser.add_argument("--hours", type=int, default=None, help="only runs started within N hours")
344 parser.add_argument("--selftest", action="store_true", help="prove the detector, then exit")
345 args = parser.parse_args()
346 if args.selftest:
347 return selftest()
348 if args.runs < 1:
349 _fail("--runs must be at least 1; a scan of nothing proves nothing.")
350 return scan(args.repo, args.runs, args.hours)
351
352
353if __name__ == "__main__":
354 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298