mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 15:27:56 +01:00
v1.93.35.0 fix: pregate no longer kills a quiet test suite that is busy working (GST-1095) (#3899)
* fix(pregate): a quiet suite that is busy on CPU is not stalled (GST-1095) The stall timer looked only at log growth, so a parallel check.sh that prints nothing for 9 minutes while working ended PREGATE_STALLED (FOUN-63). supervise now samples the process group's CPU time each second and treats growth of at least a tenth of wall time as liveness. An idle hang still stalls; a hang that burns CPU is left to --timeout. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * 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> * fix(pregate): a failed CPU read waits for the next sample; docs wording (fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-1095 fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
54606c525c
commit
f47d19dd21
13 files changed
+103
-24
No files matched your search
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.93.35.0] - 2026-11-03
|
||||||
|
|
||||||
|
**pregate no longer kills a quiet test suite that is busy working.**
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`lib/pregate/supervise.ts`**: the stall timeout now needs both no log output and an idle process group. Each second, supervise reads the group's CPU time (`/proc` on Linux, `ps -S` on macOS and BSDs). If it grew by at least a tenth of the wall time, the group counts as alive. Before, a parallel `check.sh` that printed nothing for 9 minutes while working ended `PREGATE_STALLED` at the 300 s default (Founderstar FOUN-63). A hang that waits on nothing still stalls. A hang that keeps using CPU now runs to `--timeout`, and either way the check is incomplete, never a pass. Windows reads no CPU time and keeps log growth alone. The `PREGATE_STALLED` detail, the guard line and the docs say what was measured. Both new tests fail on main. There a silent CPU-bound command ends `stalled` and a busy lane test ends `PREGATE_STALLED`.
|
||||||
|
|
||||||
## [1.93.34.0] - 2026-11-03
|
## [1.93.34.0] - 2026-11-03
|
||||||
|
|
||||||
**The docsync fault-callback replays and the runShadow deadline test stop failing on loaded remote pregate VMs.**
|
**The docsync fault-callback replays and the runShadow deadline test stop failing on loaded remote pregate VMs.**
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# gstack digest v1.93.34.0 — regenerate/re-copy after upgrading gstack
|
# gstack digest v1.93.35.0 — regenerate/re-copy after upgrading gstack
|
||||||
|
|
||||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||||
for agent hosts without a full skill install. The full skills add workflows,
|
for agent hosts without a full skill install. The full skills add workflows,
|
||||||
|
|||||||
+12
-5
@@ -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.*`) |
|
| `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)) |
|
| `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)) |
|
| `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 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 |
|
| `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)) |
|
| `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)) |
|
| `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)) |
|
||||||
@@ -459,10 +459,17 @@ test-tier command:
|
|||||||
a one-file run (each serial file) reports its file from the launch for any
|
a one-file run (each serial file) reports its file from the launch for any
|
||||||
runner. Other runners (node --test, vitest, jest, pytest, playwright test, flutter test, dart test, cargo test) get heartbeats
|
runner. Other runners (node --test, vitest, jest, pytest, playwright test, flutter test, dart test, cargo test) get heartbeats
|
||||||
only. A heartbeat follows each 60 s with no progress line;
|
only. A heartbeat follows each 60 s with no progress line;
|
||||||
- is killed as **stalled** when its log does not grow for the stall timeout
|
- is killed as **stalled** when its log does not grow and its process group
|
||||||
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
|
stays under a tenth of one CPU for the stall timeout (`--stall-timeout <s>`, else
|
||||||
check is `incomplete` with `PREGATE_STALLED` and names the last file
|
`stall_timeout_s`, else 300; `0` disables): the check is `incomplete` with
|
||||||
started; the next serial file still runs. The `--remote` gate is exempt: a
|
`PREGATE_STALLED` and names the last file started; the next serial file
|
||||||
|
still runs. The group counts as busy when its CPU time, sampled each second,
|
||||||
|
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. 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
|
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>`;
|
its heartbeats add `; last output: <the gate's last line>`;
|
||||||
- is killed as **timed out** when that runner process reaches its timeout
|
- is killed as **timed out** when that runner process reaches its timeout
|
||||||
|
|||||||
@@ -3917,14 +3917,14 @@ the lane log, then rerun or split the selection. A tool missing from PATH is
|
|||||||
<a id="pregate-stalled"></a>
|
<a id="pregate-stalled"></a>
|
||||||
### `PREGATE_STALLED`
|
### `PREGATE_STALLED`
|
||||||
|
|
||||||
**Meaning.** A test command the lanes check (or a guard) launched wrote nothing to its log 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_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
|
`--stall-timeout`). pregate killed its process group and names the last test
|
||||||
file started. The check is incomplete, never pass.
|
file started. The check is incomplete, never pass.
|
||||||
|
|
||||||
**Fix.** Open the printed log, find the hang in the named file (a blocked
|
**Fix.** Open the printed log, find the hang in the named file (a blocked
|
||||||
child, a server that never exits, a synchronous wait), fix it and rerun
|
child, a server that never exits, a synchronous wait), fix it and rerun
|
||||||
`gstack-pregate --stage tests`. A test that is legitimately quiet for longer
|
`gstack-pregate --stage tests`. A test that legitimately waits quietly for longer
|
||||||
needs a larger `--stall-timeout`; `0` disables the stall kill and leaves only
|
needs a larger `--stall-timeout`; `0` disables the stall kill and leaves only
|
||||||
`--timeout`. The `--remote` gate never stalls: it is bounded by `--timeout`
|
`--timeout`. The `--remote` gate never stalls: it is bounded by `--timeout`
|
||||||
(else `timeout_s`), so a gate that waits on CI needs a timeout long enough for
|
(else `timeout_s`), so a gate that waits on CI needs a timeout long enough for
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { baseInputs, sha256 } from '../context';
|
|||||||
import { classifyCommandEnvironment, classifyEnvironment, environmentLine, environmentResult, launchCause } from '../environment';
|
import { classifyCommandEnvironment, classifyEnvironment, environmentLine, environmentResult, launchCause } from '../environment';
|
||||||
import { launchRuntime } from '../runtime';
|
import { launchRuntime } from '../runtime';
|
||||||
import { checkScratch } from '../run-scratch';
|
import { checkScratch } from '../run-scratch';
|
||||||
import { readRange, supervise } from '../supervise';
|
import { readRange, STALL_IDLE, supervise } from '../supervise';
|
||||||
import type { EnvironmentCause } from '../types';
|
import type { EnvironmentCause } from '../types';
|
||||||
import type { CheckDef, CheckResult, PregateContext } 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));
|
for (const g of selected) runs.push(await runGuard(ctx, g, runtime.env, dir));
|
||||||
const lines = [`runtime: ${runtime.label}`];
|
const lines = [`runtime: ${runtime.label}`];
|
||||||
for (const r of runs) {
|
for (const r of runs) {
|
||||||
lines.push(r.stalled ? `GUARD: ${r.guard.id} stalled: no output for ${ctx.stallTimeoutMs / 1000} s; 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.exit !== 0) lines.push(...r.tail.map(l => ` ${l}`));
|
||||||
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
|
if (r.environment) lines.push(` ${r.guard.id} ${environmentLine(r.environment)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { isDeviceDart, listRepoFiles, readRepoFile } from '../import-graph';
|
|||||||
import { selectLanes, type Selection } from '../lane-selection';
|
import { selectLanes, type Selection } from '../lane-selection';
|
||||||
import { launchRuntime } from '../runtime';
|
import { launchRuntime } from '../runtime';
|
||||||
import { cargoModel, type CargoModel } from '../rust';
|
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 { verifyFromRun } from '../../run-evidence';
|
||||||
import { matchesAny } from '../../ship-policy';
|
import { matchesAny } from '../../ship-policy';
|
||||||
import type { CheckDef, CheckResult, PregateContext, RemoteObligation } from '../types';
|
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 timedOut = r.runs.filter(x => x.outcome === 'timeout');
|
||||||
const rest = r.failed ? `; ${r.failed} failed` : '';
|
const rest = r.failed ? `; ${r.failed} failed` : '';
|
||||||
if (stalled.length) {
|
if (stalled.length) {
|
||||||
return { code: 'PREGATE_STALLED', detail: `no test output for ${ctx.stallTimeoutMs / 1000} s; 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 is quiet 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');
|
const launch = r.runs.find(x => x.outcome === 'spawn-failed');
|
||||||
if (launch) {
|
if (launch) {
|
||||||
|
|||||||
@@ -29,8 +29,9 @@
|
|||||||
* the pre-push hook and `gstack-pregate sealed`, not by `run`)
|
* the pre-push hook and `gstack-pregate sealed`, not by `run`)
|
||||||
* price_file the call-plan data file `gstack-eval-plan price-gate` prices
|
* price_file the call-plan data file `gstack-eval-plan price-gate` prices
|
||||||
* a paid gate from (GSTACK_EVAL_CALLS v1; never executed)
|
* a paid gate from (GSTACK_EVAL_CALLS v1; never executed)
|
||||||
* stall_timeout_s seconds without test output before the lanes check kills
|
* stall_timeout_s seconds without test output or CPU activity before the
|
||||||
* the runner's process group (default 300; 0 disables)
|
* lanes check kills the runner's process group (default 300;
|
||||||
|
* 0 disables)
|
||||||
* timeout_s seconds each runner process may take (the unit-file group,
|
* timeout_s seconds each runner process may take (the unit-file group,
|
||||||
* each serial file, each guard without its own timeout_s, the
|
* each serial file, each guard without its own timeout_s, the
|
||||||
* remote gate) when `--timeout` is not passed (default 600)
|
* remote gate) when `--timeout` is not passed (default 600)
|
||||||
@@ -352,7 +353,7 @@ export function explainConfig(): string {
|
|||||||
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
|
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
|
||||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, label_keys, identifier_keys, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs; label_keys turns on sealed.label_join, which refuses a published record that puts one of those fields next to an identifier (identifier_keys globs, or a hex value) (docs/pregate.md#sealed)',
|
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, label_keys, identifier_keys, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs; label_keys turns on sealed.label_join, which refuses a published record that puts one of those fields next to an identifier (identifier_keys globs, or a hex value) (docs/pregate.md#sealed)',
|
||||||
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
|
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
|
||||||
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
|
'stall_timeout_s: seconds without test output or CPU activity before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
|
||||||
'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',
|
'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',
|
||||||
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
|
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
|
||||||
'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)',
|
'run_profiles: name → { workflow, events, jobs, artifact, max_age_hours?, os? }; the GitHub Actions runs gstack-pregate clear --run verifies as evidence on the identical tree (docs/pregate.md#run-evidence)',
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* pregate/supervise — how a test-tier command runs (Lane C, GBRA-95): its
|
* pregate/supervise — how a test-tier command runs (Lane C, GBRA-95): its
|
||||||
* own process group, stdout and stderr appended to a log file (no pipes, so
|
* own process group, stdout and stderr appended to a log file (no pipes, so
|
||||||
* a backgrounded grandchild holding the output can never keep the check
|
* a backgrounded grandchild holding the output can never keep the check
|
||||||
* waiting), supervised asynchronously. A stall is no log growth for
|
* waiting), supervised asynchronously. A stall is `stallMs` with no log growth
|
||||||
* `stallMs`; the deadline is the check's `--timeout`. Either kills the whole
|
* and no CPU activity in the process group (GST-1095: a quiet parallel suite
|
||||||
|
* that is busy working is alive; a hang that keeps burning CPU is left to the
|
||||||
|
* deadline); the deadline is the check's `--timeout`. Either kills the whole
|
||||||
* process group (SIGTERM, then SIGKILL after a grace period) and the run is
|
* process group (SIGTERM, then SIGKILL after a grace period) and the run is
|
||||||
* incomplete. After a normal exit the group's leftover members are killed.
|
* incomplete. After a normal exit the group's leftover members are killed.
|
||||||
*
|
*
|
||||||
@@ -34,7 +36,7 @@ export interface SuperviseOptions {
|
|||||||
log: string;
|
log: string;
|
||||||
/** Absolute epoch ms when the run is killed as timed out. */
|
/** Absolute epoch ms when the run is killed as timed out. */
|
||||||
deadline: number;
|
deadline: number;
|
||||||
/** No log growth for this long kills the group; 0 disables. */
|
/** No log growth and no CPU activity in the group for this long kills it; 0 disables. */
|
||||||
stallMs: number;
|
stallMs: number;
|
||||||
heartbeatMs?: number;
|
heartbeatMs?: number;
|
||||||
graceMs?: number;
|
graceMs?: number;
|
||||||
@@ -63,6 +65,11 @@ export interface Supervised {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE = new Set<number>();
|
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). */
|
/** SIGKILL every supervised process group still running (the CLI's SIGINT/SIGTERM handler, before it exits). */
|
||||||
export function cancelActive(): number {
|
export function cancelActive(): number {
|
||||||
@@ -80,6 +87,31 @@ export function killGroup(pid: number, signal: NodeJS.Signals): void {
|
|||||||
try { process.kill(-pid, signal); } catch { /* group already gone */ }
|
try { process.kill(-pid, signal); } catch { /* group already gone */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CPU time in ms used by the process group `pgid`, reaped children included where the platform reports them; null where it cannot be read (Windows). */
|
||||||
|
export function groupCpuMs(pgid: number): number | null {
|
||||||
|
if (process.platform === 'win32') return null;
|
||||||
|
if (fs.existsSync('/proc/self/stat')) {
|
||||||
|
let ticks = 0;
|
||||||
|
for (const name of fs.readdirSync('/proc')) {
|
||||||
|
if (!/^\d+$/.test(name)) continue;
|
||||||
|
let stat: string;
|
||||||
|
try { stat = fs.readFileSync(`/proc/${name}/stat`, 'utf8'); } catch { continue; }
|
||||||
|
const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
||||||
|
if (Number(f[2]) === pgid) ticks += Number(f[11]) + Number(f[12]) + Number(f[13]) + Number(f[14]);
|
||||||
|
}
|
||||||
|
return ticks * 1000 / USER_HZ;
|
||||||
|
}
|
||||||
|
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')) {
|
||||||
|
const [id, time] = line.trim().split(/\s+/);
|
||||||
|
const t = time && /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(time);
|
||||||
|
if (Number(id) === pgid && t) ms += (((Number(t[1] ?? 0) * 24 + Number(t[2] ?? 0)) * 60 + Number(t[3])) * 60 + Number(t[4])) * 1000;
|
||||||
|
}
|
||||||
|
return ms;
|
||||||
|
}
|
||||||
|
|
||||||
/** The adapter that reads per-file progress from this runner's output, or null when it has none. */
|
/** The adapter that reads per-file progress from this runner's output, or null when it has none. */
|
||||||
export function adapterFor(command: string): ProgressAdapter | null {
|
export function adapterFor(command: string): ProgressAdapter | null {
|
||||||
for (const [name, re] of Object.entries(PROGRESS_ADAPTERS)) if (re.test(command)) return name as ProgressAdapter;
|
for (const [name, re] of Object.entries(PROGRESS_ADAPTERS)) if (re.test(command)) return name as ProgressAdapter;
|
||||||
@@ -110,6 +142,8 @@ export function supervise(o: SuperviseOptions): Promise<Supervised> {
|
|||||||
let readAt = start;
|
let readAt = start;
|
||||||
let partial = '';
|
let partial = '';
|
||||||
let lastGrowth = started;
|
let lastGrowth = started;
|
||||||
|
let lastBusy = started;
|
||||||
|
let cpu = { at: started, ms: 0 };
|
||||||
let lastProgress = started;
|
let lastProgress = started;
|
||||||
let current: { file: string; at: number; failed: boolean } | null = null;
|
let current: { file: string; at: number; failed: boolean } | null = null;
|
||||||
let lastFile: string | null = null;
|
let lastFile: string | null = null;
|
||||||
@@ -184,7 +218,12 @@ export function supervise(o: SuperviseOptions): Promise<Supervised> {
|
|||||||
if (grown > size) { size = grown; lastGrowth = now; readNew(); }
|
if (grown > size) { size = grown; lastGrowth = now; readNew(); }
|
||||||
if (outcome) return;
|
if (outcome) return;
|
||||||
if (now >= o.deadline) { stop('timeout'); return; }
|
if (now >= o.deadline) { stop('timeout'); return; }
|
||||||
if (o.stallMs > 0 && now - lastGrowth >= o.stallMs) { stop('stalled'); return; }
|
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 = { at: now, ms: used ?? cpu.ms };
|
||||||
|
}
|
||||||
|
if (o.stallMs > 0 && now - Math.max(lastGrowth, lastBusy) >= o.stallMs) { stop('stalled'); return; }
|
||||||
if (now - lastProgress < heartbeatMs) return;
|
if (now - lastProgress < heartbeatMs) return;
|
||||||
let last: string | undefined;
|
let last: string | undefined;
|
||||||
if (o.lastLine) {
|
if (o.lastLine) {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export interface PregateContext {
|
|||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
/** Where timeoutMs came from: `--timeout`, `timeout_s from <policy>`, or the 600 s default. */
|
/** Where timeoutMs came from: `--timeout`, `timeout_s from <policy>`, or the 600 s default. */
|
||||||
timeoutSource?: string;
|
timeoutSource?: string;
|
||||||
/** No output for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */
|
/** No output and no CPU activity for this long kills a test-tier process group (`--stall-timeout`, `stall_timeout_s`); 0 disables. */
|
||||||
stallTimeoutMs: number;
|
stallTimeoutMs: number;
|
||||||
/** Live stderr lines: per-file progress, heartbeats, the stage result. */
|
/** Live stderr lines: per-file progress, heartbeats, the stage result. */
|
||||||
progress: (line: string) => void;
|
progress: (line: string) => void;
|
||||||
|
|||||||
+2
-2
@@ -410,8 +410,8 @@ export const RESULT_CODES = {
|
|||||||
},
|
},
|
||||||
PREGATE_STALLED: {
|
PREGATE_STALLED: {
|
||||||
anchor: 'pregate-stalled',
|
anchor: 'pregate-stalled',
|
||||||
summary: 'a test command printed nothing 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 is quiet for longer, then rerun `gstack-pregate --stage tests`',
|
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: {
|
PREGATE_RECEIPT_INVALID: {
|
||||||
anchor: 'pregate-receipt-invalid',
|
anchor: 'pregate-receipt-invalid',
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "gstack",
|
"name": "gstack",
|
||||||
"version": "1.93.34",
|
"version": "1.93.35",
|
||||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -66,6 +66,21 @@ describe('supervise: process group, log file, stall and deadline', () => {
|
|||||||
expect(printing.wall).toBeGreaterThan(1500);
|
expect(printing.wall).toBeGreaterThan(1500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Value: protects=a quiet suite that is busy working is not killed as stalled, while a busy run that never ends still hits the deadline
|
||||||
|
// (GST-1095); fails_when=stall detection looks only at log growth and kills a CPU-busy silent run; why_new=a quiet 9-minute parallel check.sh
|
||||||
|
// ended PREGATE_STALLED on Founderstar (FOUN-63); seam=none; no_claim=a hung run that keeps burning CPU is caught by the deadline, not the stall timer
|
||||||
|
test('a silent command that keeps using CPU is not stalled; a busy run that never ends still times out at the deadline', async () => {
|
||||||
|
const dir = scratch();
|
||||||
|
const busy = `${process.execPath} -e 'const end = Date.now() + 3500; while (Date.now() < end) {} console.log("done")'`;
|
||||||
|
const quiet = await run(dir, busy, { stallMs: 1500 });
|
||||||
|
expect(quiet.outcome).toBe('exited');
|
||||||
|
expect(quiet.exit).toBe(0);
|
||||||
|
expect(quiet.log).toContain('done');
|
||||||
|
const forever = await run(dir, `${process.execPath} -e 'for (;;) {}'`, { stallMs: 1500, deadline: Date.now() + 4000 });
|
||||||
|
expect(forever.outcome).toBe('timeout');
|
||||||
|
expect(forever.exit).toBeNull();
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
test('a group that ignores SIGTERM is SIGKILLed after the grace period', async () => {
|
test('a group that ignores SIGTERM is SIGKILLed after the grace period', async () => {
|
||||||
const dir = scratch();
|
const dir = scratch();
|
||||||
const r = await run(dir, `trap '' TERM; echo $$ > ${dir}/stubborn.pid; while :; do sleep 0.1; done`, { stallMs: 500, graceMs: 400 });
|
const r = await run(dir, `trap '' TERM; echo $$ > ${dir}/stubborn.pid; while :; do sleep 0.1; done`, { stallMs: 500, graceMs: 400 });
|
||||||
@@ -195,6 +210,16 @@ describe('gstack-pregate: the test stage never hangs silently', () => {
|
|||||||
expect(r.err).toMatch(/^gstack-pregate: lanes stalled — .*; fix: .* \(PREGATE_STALLED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pregate-stalled\)$/m);
|
expect(r.err).toMatch(/^gstack-pregate: lanes stalled — .*; fix: .* \(PREGATE_STALLED; https:\/\/github\.com\/garrytan\/gstack\/blob\/main\/docs\/troubleshooting\.md#pregate-stalled\)$/m);
|
||||||
}, 90_000);
|
}, 90_000);
|
||||||
|
|
||||||
|
// Value: protects=the FOUN-63 shape end to end: a lane test that is silent past --stall-timeout but busy passes instead of PREGATE_STALLED
|
||||||
|
// (GST-1095); fails_when=the lanes check kills a CPU-busy quiet test at the stall timeout; why_new=FOUN-63; seam=none; no_claim=none
|
||||||
|
test('a test that is silent past the stall timeout but busy runs to a pass', () => {
|
||||||
|
const repo = makeFixtureRepo(); repos.push(repo);
|
||||||
|
commit(repo, { 'test/busy.test.js': "const test = require('node:test');\ntest('quiet and busy', () => { const end = Date.now() + 5000; while (Date.now() < end) {} });\n" });
|
||||||
|
const r = pregate(repo, ['--stall-timeout', '2']);
|
||||||
|
expect(lanes(repo)).toMatchObject({ status: 'pass' });
|
||||||
|
expect(r.status).toBe(EXIT.ok);
|
||||||
|
}, 90_000);
|
||||||
|
|
||||||
test('a test whose child backgrounds and holds stdout passes promptly instead of waiting out --timeout', () => {
|
test('a test whose child backgrounds and holds stdout passes promptly instead of waiting out --timeout', () => {
|
||||||
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, runner: 'node {files}' } }); repos.push(repo);
|
const repo = makeFixtureRepo({ pregate: { ...BASE_PREGATE, runner: 'node {files}' } }); repos.push(repo);
|
||||||
commit(repo, { 'test/bg.test.js': NODE_BACKGROUND });
|
commit(repo, { 'test/bg.test.js': NODE_BACKGROUND });
|
||||||
|
|||||||
Reference in new issue
Block a user