fix(pregate): stall wording says what was measured; a failed CPU read skips one sample; ps -S on BSDs (review pass 1)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-02 23:40:24 +00:00
1 parent bb19bfb13e
commit bd5b954b27
6 files changed
+19 -13

No files matched your search

+4 -2
View File
@@ -85,7 +85,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
| `stall_timeout_s` | seconds a test command may print nothing and use no CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `stall_timeout_s` | seconds a test command may print nothing while using under a tenth of one CPU before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides; the `--remote` gate is exempt (see [supervision](#supervision)) |
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `run_profiles` | name → `{ workflow, events, jobs, artifact, max_age_hours?, os? }`: the GitHub Actions runs `clear --run` verifies as evidence on the identical tree; a profile named after a lane (`free-tests.yml/macos-named-regressions`) clears that lane (see [run evidence](#run-evidence)). `--from-run` and the main-push reuse job verify runs against them too ([from-run](#from-run), [CI reuse](#ci-reuse)) |
@@ -455,7 +455,9 @@ test-tier command:
grew by at least a tenth of the wall time, so a quiet suite that is working
(FOUN-63: a parallel `check.sh` silent for 9 minutes) is not killed, while
a hang that waits on nothing is. A hang that keeps burning CPU is left to
the timeout. Windows reads no CPU time here and uses log growth alone. The `--remote` gate is exempt: a
the timeout. Linux reads `/proc` (exited children's time included once
reaped); macOS and other BSDs read `ps -S`; a failed read skips that
sample. Windows reads no CPU time here and uses log growth alone. The `--remote` gate is exempt: a
gate that waits on CI is quiet by design, so only its timeout bounds it, and
its heartbeats add `; last output: <the gate's last line>`;
- is killed as **timed out** when that runner process reaches its timeout
+1 -1
View File
@@ -3843,7 +3843,7 @@ the lane log, then rerun or split the selection. A tool missing from PATH is
<a id="pregate-stalled"></a>
### `PREGATE_STALLED`
**Meaning.** A test command the lanes check (or a guard) launched wrote nothing to its log and its process group used no CPU for the stall timeout (default 300 s,
**Meaning.** A test command the lanes check (or a guard) launched wrote nothing to its log, and its process group used under a tenth of one CPU (Windows: log growth alone), for the stall timeout (default 300 s,
`stall_timeout_s` in the base branch's `.gstack/pregate.json` or the user-owned file, or
`--stall-timeout`). pregate killed its process group and names the last test
file started. The check is incomplete, never pass.
+2 -2
View File
@@ -20,7 +20,7 @@ import { baseInputs, sha256 } from '../context';
import { classifyCommandEnvironment, classifyEnvironment, environmentLine, environmentResult, launchCause } from '../environment';
import { launchRuntime } from '../runtime';
import { checkScratch } from '../run-scratch';
import { readRange, supervise } from '../supervise';
import { readRange, STALL_IDLE, supervise } from '../supervise';
import type { EnvironmentCause } from '../types';
import type { CheckDef, CheckResult, PregateContext } from '../types';
@@ -56,7 +56,7 @@ async function run(ctx: PregateContext): Promise<CheckResult> {
for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir));
const lines = [`runtime: ${runtime.label}`];
for (const r of runs) {
lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s and its process group used no CPU; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed (log ${r.log})` : r.timedOut ? `GUARD: ${r.guard.id} timed out after ${r.guard.timeout_s ?? ctx.timeoutMs / 1000} s (log ${r.log})` : `GUARD: ${r.guard.id} exit=${r.exit} (log ${r.log})`);
if (r.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`));
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
}
+2 -2
View File
@@ -39,7 +39,7 @@ import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph';
import { selectLanes, type Selection } from '../lane-selection';
import { launchRuntime } from '../runtime';
import { cargoModel, type CargoModel } from '../rust';
import { adapterFor, readRange, supervise, type Counter, type Supervised } from '../supervise';
import { adapterFor, readRange, STALL_IDLE, supervise, type Counter, type Supervised } from '../supervise';
import { verifyFromRun } from '../../run-evidence';
import { matchesAny } from '../../ship-policy';
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
@@ -401,7 +401,7 @@ function unfinished(ctx: PregateContext, r: Awaited<ReturnType<typeof runLocal>>
const timedOut = r.runs.filter(x => x.outcome === 'timeout');
const rest = r.failed ? `; ${r.failed} failed` : '';
if (stalled.length) {
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s and its process group used no CPU; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that waits quietly for longer` };
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s${STALL_IDLE}; process group killed; last file started: ${stalled.map(x => x.lastFile ?? `(none reported; ${x.files.length} file(s))`).join(', ')}${rest}`, fix: `read ${r.log} for the stalled file; fix the hang, or raise --stall-timeout (stall_timeout_s in ${ctx.configFile}; 0 disables) for a test that waits quietly for longer` };
}
const launch = r.runs.find(x => x.outcome === 'spawn-failed');
if (launch) {
+9 -5
View File
@@ -68,6 +68,8 @@ const ACTIVE = new Set<number>();
const USER_HZ = 100;
const CPU_SAMPLE_MS = 1_000;
const CPU_BUSY_SHARE = 0.1;
/** What a stall means beyond silence, for the PREGATE_STALLED detail and the guard line; Windows reads no CPU time. */
export const STALL_IDLE = process.platform === 'win32' ? '' : ' and its process group used under a tenth of one CPU';
/** SIGKILL every supervised process group still running (the CLI's SIGINT/SIGTERM handler, before it exits). */
export function cancelActive(): number {
@@ -99,7 +101,7 @@ export function groupCpuMs(pgid: number): number | null {
}
return ticks * 1000 / USER_HZ;
}
const ps = spawnSync('ps', ['-A', '-o', 'pgid=,time='], { encoding: 'utf8', timeout: 15_000 });
const ps = spawnSync('ps', ['-A', '-S', '-o', 'pgid=,time='], { encoding: 'utf8', timeout: 15_000 });
if (ps.status !== 0) return null;
let ms = 0;
for (const line of ps.stdout.split('\n')) {
@@ -141,7 +143,7 @@ export function supervise(o: SuperviseOptions): Promise<Supervised> {
let partial = '';
let lastGrowth = started;
let lastBusy = started;
let cpu: { at: number; ms: number } | null = { at: started, ms: 0 };
let cpu = { at: started, ms: 0 };
let lastProgress = started;
let current: { file: string; at: number; failed: boolean } | null = null;
let lastFile: string | null = null;
@@ -216,10 +218,12 @@ export function supervise(o: SuperviseOptions): Promise<Supervised> {
if (grown > size) { size = grown; lastGrowth = now; readNew(); }
if (outcome) return;
if (now >= o.deadline) { stop('timeout'); return; }
if (o.stallMs > 0 && cpu && now - cpu.at >= CPU_SAMPLE_MS) {
if (o.stallMs > 0 && process.platform !== 'win32' && now - cpu.at >= CPU_SAMPLE_MS) {
const used = groupCpuMs(child.pid!);
if (used !== null && used - cpu.ms >= CPU_BUSY_SHARE * (now - cpu.at)) lastBusy = now;
cpu = used === null ? null : { at: now, ms: used };
if (used !== null) {
if (used - cpu.ms >= CPU_BUSY_SHARE * (now - cpu.at)) lastBusy = now;
cpu = { at: now, ms: used };
}
}
if (o.stallMs > 0 && now - Math.max(lastGrowth, lastBusy) >= o.stallMs) { stop('stalled'); return; }
if (now - lastProgress < heartbeatMs) return;
+1 -1
View File
@@ -410,7 +410,7 @@ export const RESULT_CODES = {
},
PREGATE_STALLED: {
anchor: 'pregate-stalled',
summary: 'a test command printed nothing and used no CPU for the stall timeout, so its process group was killed; the check is incomplete, never pass',
summary: 'a test command printed nothing for the stall timeout while its process group used under a tenth of one CPU (log growth alone on Windows), so the group was killed; the check is incomplete, never pass',
fix: 'read the lane log for the last file started, fix the hang, or raise `--stall-timeout` (`stall_timeout_s`; 0 disables) for a test that waits quietly for longer, then rerun `gstack-pregate --stage tests`',
},
PREGATE_RECEIPT_INVALID: {