mandrel-platform 1.14.0 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -783,3 +783,55 @@ test("wiring: the check is absent from the PR-gating ci.yml", () => {
|
|
|
783
783
|
"ci.yml must not invoke the freshness check"
|
|
784
784
|
);
|
|
785
785
|
});
|
|
786
|
+
|
|
787
|
+
// ── The release gate (issue #533) ──────────────────────────────────────────
|
|
788
|
+
//
|
|
789
|
+
// The reasoning above is why a PR cannot carry this check — but it left the
|
|
790
|
+
// drift ungated entirely: `pin-drift.yml` reports it weekly and on push to
|
|
791
|
+
// main, both AFTER the merge that caused it, and nothing stopped a release
|
|
792
|
+
// shipping on top. Two did. v1.14.0's asset-download retry fix and v1.15.0's
|
|
793
|
+
// install watchdog both landed in the action directories while every workflow
|
|
794
|
+
// still pinned a sha that predated them, so every consumer that upgraded got
|
|
795
|
+
// an inert release.
|
|
796
|
+
//
|
|
797
|
+
// Release time is the one moment that is both satisfiable (the commit exists
|
|
798
|
+
// on main) and on the boundary that matters (nothing reaches a consumer
|
|
799
|
+
// un-repointed), so that is where the gate lives.
|
|
800
|
+
test("wiring: the release path REFUSES to publish stale first-party pins", () => {
|
|
801
|
+
const wf = readFileSync(join(REPO_ROOT, ".github/workflows/release-please.yml"), "utf8");
|
|
802
|
+
|
|
803
|
+
assert.ok(
|
|
804
|
+
wf.includes(SCRIPT_INVOCATION),
|
|
805
|
+
"release-please.yml invokes the freshness check"
|
|
806
|
+
);
|
|
807
|
+
|
|
808
|
+
const gate = jobBlock(wf, "pin-freshness-gate");
|
|
809
|
+
assert.ok(gate, "the gate runs in its own job");
|
|
810
|
+
assert.ok(gate.includes(SCRIPT_INVOCATION), "that job invokes the checker");
|
|
811
|
+
assert.match(
|
|
812
|
+
gate,
|
|
813
|
+
/fetch-depth: 0/,
|
|
814
|
+
"a shallow checkout cannot resolve the pinned manifests"
|
|
815
|
+
);
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
test("wiring: a FAILED freshness gate actually blocks the publish", () => {
|
|
819
|
+
// The load-bearing half. `npm-publish` is guarded by `!cancelled()`, an
|
|
820
|
+
// always()-family condition: a failed `needs` job does NOT skip it. Without
|
|
821
|
+
// an explicit success assertion the gate would be decorative — present,
|
|
822
|
+
// green-looking, and unable to stop anything.
|
|
823
|
+
const wf = readFileSync(join(REPO_ROOT, ".github/workflows/release-please.yml"), "utf8");
|
|
824
|
+
const publish = jobBlock(wf, "npm-publish");
|
|
825
|
+
|
|
826
|
+
assert.ok(publish, "the npm-publish job exists");
|
|
827
|
+
assert.match(
|
|
828
|
+
publish,
|
|
829
|
+
/needs: \[[^\]]*pin-freshness-gate[^\]]*\]/,
|
|
830
|
+
"npm-publish depends on the gate"
|
|
831
|
+
);
|
|
832
|
+
assert.match(
|
|
833
|
+
publish,
|
|
834
|
+
/needs\.pin-freshness-gate\.result == 'success'/,
|
|
835
|
+
"npm-publish asserts the gate SUCCEEDED, not merely that it ran"
|
|
836
|
+
);
|
|
837
|
+
});
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pnpm-install-watchdog.test.mjs — the suite for the setup-toolchain install
|
|
4
|
+
* watchdog (Story #530).
|
|
5
|
+
*
|
|
6
|
+
* WHY EACH LAYER IS HERE
|
|
7
|
+
*
|
|
8
|
+
* 1. THE DECISION IS PURE, so the incident can be replayed without waiting
|
|
9
|
+
* for one. The wedge that motivated this ran for 27 minutes; a suite that
|
|
10
|
+
* could only observe a real stall could not assert the thing that
|
|
11
|
+
* matters — that a tree at ~1.3% of a core trips, while a working install
|
|
12
|
+
* does not. `evaluateStall` is driven directly with the incident's
|
|
13
|
+
* measured shape.
|
|
14
|
+
*
|
|
15
|
+
* 2. THE PARSER IS TESTED ON BOTH `ps` DIALECTS, because a watchdog that
|
|
16
|
+
* misreads CPU on the platform its tests do not run on is worse than
|
|
17
|
+
* none: it would read every process as idle and kill healthy installs.
|
|
18
|
+
* A field it cannot parse must drop the row, never score it as zero —
|
|
19
|
+
* zero is precisely the claim it kills on.
|
|
20
|
+
*
|
|
21
|
+
* 3. THE FAIL-SAFE IS TESTED AS A BEHAVIOUR, not a code path. The rule the
|
|
22
|
+
* whole design rests on is that the watchdog may never be the reason a
|
|
23
|
+
* job fails, so a sampler that throws on every call must still leave the
|
|
24
|
+
* install running and exiting under its own status.
|
|
25
|
+
*
|
|
26
|
+
* 4. TWO TESTS USE REAL PROCESS TREES, because the central claim — progress
|
|
27
|
+
* is the TREE's CPU, not the parent's output — is unfalsifiable against a
|
|
28
|
+
* stubbed sampler. One tree is silent but burning CPU in a grandchild and
|
|
29
|
+
* must survive; one is genuinely asleep and must be killed with no member
|
|
30
|
+
* left behind.
|
|
31
|
+
*
|
|
32
|
+
* Their windows are seconds, not milliseconds, and deliberately so:
|
|
33
|
+
* Linux `ps -o time` has ONE-SECOND resolution, so a sub-second window
|
|
34
|
+
* reads a fully-busy tree as having used no CPU at all. A suite tuned for
|
|
35
|
+
* macOS's centiseconds would invert on the very platform CI runs.
|
|
36
|
+
*
|
|
37
|
+
* 5. THE SHELL BRANCH IS EXECUTED, not grepped. What decides whether pnpm
|
|
38
|
+
* receives identical argv is a bash branch in the composite, so the real
|
|
39
|
+
* `run:` body is extracted and run against a stub `pnpm` that echoes its
|
|
40
|
+
* argv — the same read-then-execute approach as
|
|
41
|
+
* `check-setup-toolchain-store.test.mjs` next door.
|
|
42
|
+
*
|
|
43
|
+
* Run: node --test scripts/pnpm-install-watchdog.test.mjs
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import assert from "node:assert/strict";
|
|
47
|
+
import { test } from "node:test";
|
|
48
|
+
import { execFileSync } from "node:child_process";
|
|
49
|
+
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
50
|
+
import { tmpdir } from "node:os";
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
|
|
53
|
+
import { stepByName, runScript } from "./lib/yaml-step.mjs";
|
|
54
|
+
import {
|
|
55
|
+
DEFAULTS,
|
|
56
|
+
EXIT_STALLED,
|
|
57
|
+
collectTree,
|
|
58
|
+
evaluateStall,
|
|
59
|
+
parseCpuTime,
|
|
60
|
+
parsePsRows,
|
|
61
|
+
psSampler,
|
|
62
|
+
renderStallMessage,
|
|
63
|
+
resolveConfig,
|
|
64
|
+
sumTreeCpuSeconds,
|
|
65
|
+
supervise,
|
|
66
|
+
} from "../.github/actions/setup-toolchain/pnpm-install-watchdog.mjs";
|
|
67
|
+
|
|
68
|
+
const ACTION = ".github/actions/setup-toolchain/action.yml";
|
|
69
|
+
const WORKFLOW = ".github/workflows/pr-quality.yml";
|
|
70
|
+
const DOCS = "docs/reusable-workflows.md";
|
|
71
|
+
|
|
72
|
+
const actionText = readFileSync(ACTION, "utf8");
|
|
73
|
+
const workflowText = readFileSync(WORKFLOW, "utf8");
|
|
74
|
+
const installScript = runScript(stepByName(actionText, "Install dependencies"));
|
|
75
|
+
|
|
76
|
+
/** An armed config in the shape `resolveConfig` produces, overridable per test. */
|
|
77
|
+
const config = (over = {}) => ({
|
|
78
|
+
armed: true,
|
|
79
|
+
reason: "armed",
|
|
80
|
+
stallTimeoutMs: 600_000,
|
|
81
|
+
graceMs: 120_000,
|
|
82
|
+
minCpuRate: 0.05,
|
|
83
|
+
sampleIntervalMs: 10_000,
|
|
84
|
+
...over,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** Samples at a constant CPU rate, one every `stepMs`, starting at t=0. */
|
|
88
|
+
const rampSamples = ({ rate, untilMs, stepMs = 10_000, from = 0 }) => {
|
|
89
|
+
const samples = [];
|
|
90
|
+
for (let atMs = 0; atMs <= untilMs; atMs += stepMs) {
|
|
91
|
+
samples.push({ atMs, cpuSeconds: from + (atMs / 1000) * rate });
|
|
92
|
+
}
|
|
93
|
+
return samples;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// AC-3 — the decision: the measured incident trips, a healthy install does not
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
test("the measured incident trips the stall decision", () => {
|
|
101
|
+
// Beestera/swarm-os run 34876024126: 20.99s of CPU across 27 minutes, flat.
|
|
102
|
+
const rate = 20.99 / (27 * 60); // ~0.013 — 1.3% of one core
|
|
103
|
+
const samples = rampSamples({ rate, untilMs: 27 * 60 * 1000 });
|
|
104
|
+
const verdict = evaluateStall(samples, config());
|
|
105
|
+
assert.equal(verdict.stalled, true, "a tree at ~1.3% for 27 minutes must be a stall");
|
|
106
|
+
assert.ok(verdict.rate < 0.05);
|
|
107
|
+
assert.ok(verdict.windowSeconds >= 600 - 10, "the measured window must be the trailing one");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an absolute 'CPU has not advanced' test would have MISSED the incident", () => {
|
|
111
|
+
// The reason the instrument is a rate. Over the same 600s window the wedged
|
|
112
|
+
// tree still accrued ~7.8s of CPU, so any "has it moved at all" test — or any
|
|
113
|
+
// fixed threshold below that — reads the wedge as progress.
|
|
114
|
+
const rate = 20.99 / (27 * 60);
|
|
115
|
+
const samples = rampSamples({ rate, untilMs: 27 * 60 * 1000 });
|
|
116
|
+
const verdict = evaluateStall(samples, config());
|
|
117
|
+
assert.ok(verdict.cpuSeconds > 5, `expected a nonzero CPU delta, got ${verdict.cpuSeconds}`);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("a healthy install is never a stall, however long it runs", () => {
|
|
121
|
+
const samples = rampSamples({ rate: 0.6, untilMs: 40 * 60 * 1000 });
|
|
122
|
+
assert.equal(evaluateStall(samples, config()).stalled, false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a rate exactly at the floor is progress, not a stall", () => {
|
|
126
|
+
const samples = rampSamples({ rate: 0.05, untilMs: 30 * 60 * 1000 });
|
|
127
|
+
assert.equal(evaluateStall(samples, config()).stalled, false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("nothing trips inside the grace window, however quiet", () => {
|
|
131
|
+
const samples = rampSamples({ rate: 0, untilMs: 119_000, stepMs: 1000 });
|
|
132
|
+
const verdict = evaluateStall(samples, config());
|
|
133
|
+
assert.equal(verdict.stalled, false);
|
|
134
|
+
assert.equal(verdict.reason, "within-grace");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("a window that straddles the grace boundary is not yet decidable", () => {
|
|
138
|
+
// Armed (past grace) but the trailing window would reach back into the cold
|
|
139
|
+
// start the floor is explicitly exempt from.
|
|
140
|
+
const samples = rampSamples({ rate: 0, untilMs: 300_000, stepMs: 10_000 });
|
|
141
|
+
const verdict = evaluateStall(samples, config({ graceMs: 120_000, stallTimeoutMs: 600_000 }));
|
|
142
|
+
assert.equal(verdict.stalled, false);
|
|
143
|
+
assert.equal(verdict.reason, "window-not-full");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("a run shorter than the window cannot be judged", () => {
|
|
147
|
+
const samples = rampSamples({ rate: 0, untilMs: 400_000, stepMs: 10_000 });
|
|
148
|
+
assert.equal(evaluateStall(samples, config()).reason, "window-not-full");
|
|
149
|
+
assert.equal(evaluateStall([], config()).reason, "too-few-samples");
|
|
150
|
+
assert.equal(evaluateStall([{ atMs: 0, cpuSeconds: 0 }], config()).reason, "too-few-samples");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a burst of work inside the window defers the kill", () => {
|
|
154
|
+
// The conservative direction, and the reason the rate is a mean: the cost of
|
|
155
|
+
// waiting is minutes, the cost of a wrong kill is a red required check on an
|
|
156
|
+
// innocent diff.
|
|
157
|
+
const samples = rampSamples({ rate: 0, untilMs: 1_800_000, stepMs: 10_000 });
|
|
158
|
+
const last = samples[samples.length - 1];
|
|
159
|
+
last.cpuSeconds += 60; // one minute of real work, right at the end
|
|
160
|
+
assert.equal(evaluateStall(samples, config()).stalled, false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// AC-2 — progress is the TREE's CPU, and an unreadable field is never zero
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
test("parseCpuTime reads both ps dialects", () => {
|
|
168
|
+
assert.equal(parseCpuTime("0:00"), 0);
|
|
169
|
+
assert.equal(parseCpuTime("20:59.02"), 20 * 60 + 59.02); // BSD/macOS MM:SS.cc
|
|
170
|
+
assert.equal(parseCpuTime("01:02:03"), 3723); // Linux HH:MM:SS
|
|
171
|
+
assert.equal(parseCpuTime("1-02:03:04"), 86400 + 7384); // Linux DD-HH:MM:SS
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("an unparseable time field is null, never zero", () => {
|
|
175
|
+
// Zero is the exact claim the watchdog kills on, so an unreadable field must
|
|
176
|
+
// never be able to impersonate an idle process.
|
|
177
|
+
for (const field of ["", " ", "?", "n/a", "1:2:3:4", "-", "x:yy"]) {
|
|
178
|
+
assert.equal(parseCpuTime(field), null, `expected null for ${JSON.stringify(field)}`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("parsePsRows drops rows it cannot read rather than scoring them idle", () => {
|
|
183
|
+
const rows = parsePsRows([" 1 0 0:01.50", " 2 1 ?", "garbage", " 3 1 0:02"].join("\n"));
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
rows.map((r) => r.pid),
|
|
186
|
+
[1, 3],
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("collectTree reaches grandchildren and cannot spin on a cyclic table", () => {
|
|
191
|
+
const rows = [
|
|
192
|
+
{ pid: 10, ppid: 1 },
|
|
193
|
+
{ pid: 11, ppid: 10 },
|
|
194
|
+
{ pid: 12, ppid: 11 },
|
|
195
|
+
{ pid: 99, ppid: 1 },
|
|
196
|
+
];
|
|
197
|
+
assert.deepEqual(collectTree(rows, 10).sort((a, b) => a - b), [10, 11, 12]);
|
|
198
|
+
assert.deepEqual(collectTree([{ pid: 5, ppid: 6 }, { pid: 6, ppid: 5 }], 5).sort(), [5, 6]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("sumTreeCpuSeconds counts descendants and answers null for a missing root", () => {
|
|
202
|
+
const rows = parsePsRows(["10 1 0:01.00", "11 10 0:02.00", "12 11 0:04.00", "99 1 9:00"].join("\n"));
|
|
203
|
+
assert.equal(sumTreeCpuSeconds(rows, 10), 7);
|
|
204
|
+
assert.equal(sumTreeCpuSeconds(rows, 4242), null);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// resolveConfig — the off switch, and every rejection disarming rather than
|
|
209
|
+
// inventing a threshold
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
test("an empty environment arms on the documented defaults", () => {
|
|
213
|
+
const resolved = resolveConfig({});
|
|
214
|
+
assert.equal(resolved.armed, true);
|
|
215
|
+
assert.equal(resolved.stallTimeoutMs, DEFAULTS.stallTimeoutSeconds * 1000);
|
|
216
|
+
assert.equal(resolved.graceMs, DEFAULTS.graceSeconds * 1000);
|
|
217
|
+
assert.equal(resolved.minCpuRate, DEFAULTS.minCpuRate);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("'0' is the off switch", () => {
|
|
221
|
+
const resolved = resolveConfig({ PNPM_WATCHDOG_STALL_TIMEOUT: "0" });
|
|
222
|
+
assert.equal(resolved.armed, false);
|
|
223
|
+
assert.match(resolved.reason, /disabled/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("a value that is not a non-negative number disarms rather than falling back", () => {
|
|
227
|
+
// An operator who typed a bad number asked for supervision and must be told
|
|
228
|
+
// they are not getting it. Silently substituting a default is how a watchdog
|
|
229
|
+
// ends up enforcing a threshold nobody chose.
|
|
230
|
+
for (const env of [
|
|
231
|
+
{ PNPM_WATCHDOG_STALL_TIMEOUT: "soon" },
|
|
232
|
+
{ PNPM_WATCHDOG_GRACE: "-1" },
|
|
233
|
+
{ PNPM_WATCHDOG_MIN_CPU_RATE: "5%" },
|
|
234
|
+
{ PNPM_WATCHDOG_SAMPLE_INTERVAL: "NaN" },
|
|
235
|
+
]) {
|
|
236
|
+
const resolved = resolveConfig(env);
|
|
237
|
+
assert.equal(resolved.armed, false, JSON.stringify(env));
|
|
238
|
+
assert.match(resolved.reason, /not a non-negative number|disabled/);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// AC-4 / AC-5 — supervision behaviour under an injected sampler
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
/** Supervise `node -e <src>` with a scripted sampler, collecting the log. */
|
|
247
|
+
async function superviseNode(src, { sampler, ...over }) {
|
|
248
|
+
const log = [];
|
|
249
|
+
const code = await supervise({
|
|
250
|
+
command: process.execPath,
|
|
251
|
+
args: ["-e", src],
|
|
252
|
+
config: config({ graceMs: 0, stallTimeoutMs: 200, sampleIntervalMs: 20, ...over }),
|
|
253
|
+
sampler,
|
|
254
|
+
log: (m) => log.push(m),
|
|
255
|
+
});
|
|
256
|
+
return { code, log: log.join("\n") };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
test("a sampler that always throws disarms, and the install's own status stands", async () => {
|
|
260
|
+
// The invariant the whole design rests on: the watchdog may never be the
|
|
261
|
+
// reason a job fails.
|
|
262
|
+
const { code, log } = await superviseNode("setTimeout(() => process.exit(3), 300)", {
|
|
263
|
+
sampler: async () => {
|
|
264
|
+
throw new Error("ps: command not found");
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
assert.equal(code, 3, "the install's own exit status must survive a disarm");
|
|
268
|
+
assert.match(log, /::warning::pnpm install watchdog disarmed/);
|
|
269
|
+
assert.match(log, /ps: command not found/);
|
|
270
|
+
assert.doesNotMatch(log, /::error::/);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("the disarm warning is said once, not once per sample", async () => {
|
|
274
|
+
const { log } = await superviseNode("setTimeout(() => process.exit(0), 400)", {
|
|
275
|
+
sampler: async () => {
|
|
276
|
+
throw new Error("unreadable");
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
assert.equal(log.match(/watchdog disarmed/g).length, 1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("a sampler that cannot see the child never concludes a stall", async () => {
|
|
283
|
+
// A child missing from the table has exited, or the table cannot answer for
|
|
284
|
+
// it. Neither is evidence that it stopped working.
|
|
285
|
+
const { code } = await superviseNode("setTimeout(() => process.exit(0), 400)", {
|
|
286
|
+
sampler: async () => [{ pid: 999_999, ppid: 1, cpuSeconds: 0 }],
|
|
287
|
+
});
|
|
288
|
+
assert.equal(code, 0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a stall kills the install and exits with the reserved code", async () => {
|
|
292
|
+
const { code, log } = await superviseNode("setInterval(() => {}, 1000)", {
|
|
293
|
+
// Report the real process table, but with every CPU counter frozen at
|
|
294
|
+
// zero: a tree that is present, alive, and doing nothing.
|
|
295
|
+
sampler: async () => (await psSampler()).map((r) => ({ ...r, cpuSeconds: 0 })),
|
|
296
|
+
});
|
|
297
|
+
assert.equal(code, EXIT_STALLED, "a stall must not be reported as a generic failure");
|
|
298
|
+
assert.notEqual(EXIT_STALLED, 1, "the code must be distinguishable from an ordinary failure");
|
|
299
|
+
assert.match(log, /::error::pnpm install stalled/);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("the kill message names the measured rate, the window, and the way out", async () => {
|
|
303
|
+
const message = renderStallMessage(
|
|
304
|
+
{ rate: 0.0129, windowSeconds: 600, cpuSeconds: 7.8 },
|
|
305
|
+
{ minCpuRate: 0.05 },
|
|
306
|
+
);
|
|
307
|
+
assert.match(message, /7\.80s of CPU over the last 600s/);
|
|
308
|
+
assert.match(message, /1\.29% of one core/);
|
|
309
|
+
assert.match(message, /below the 5\.00% floor/);
|
|
310
|
+
assert.match(message, /stopped, not slow/);
|
|
311
|
+
assert.match(message, /install-stall-timeout: '0'/);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("an unarmed watchdog says so and runs the install through untouched", async () => {
|
|
315
|
+
const log = [];
|
|
316
|
+
const code = await supervise({
|
|
317
|
+
command: process.execPath,
|
|
318
|
+
args: ["-e", "process.exit(7)"],
|
|
319
|
+
config: resolveConfig({ PNPM_WATCHDOG_STALL_TIMEOUT: "0" }),
|
|
320
|
+
sampler: async () => assert.fail("a disabled watchdog must not sample"),
|
|
321
|
+
log: (m) => log.push(m),
|
|
322
|
+
});
|
|
323
|
+
assert.equal(code, 7);
|
|
324
|
+
assert.match(log.join("\n"), /not armed/);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// AC-2 / AC-5 — real process trees
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
// Seconds, not milliseconds: Linux `ps -o time` has one-second resolution, so a
|
|
332
|
+
// sub-second window reads a fully-busy tree as idle.
|
|
333
|
+
const REAL_TREE = { graceMs: 0, stallTimeoutMs: 2500, sampleIntervalMs: 200 };
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A parent that produces no output for its whole life and forks a child
|
|
337
|
+
* running `childSrc`. When `pidFile` is given it records its own pid and its
|
|
338
|
+
* child's, so a test can prove afterwards that neither survived.
|
|
339
|
+
*/
|
|
340
|
+
const silentParent = (childSrc, lifetimeMs, pidFile = null) =>
|
|
341
|
+
`const { spawn } = require("node:child_process");` +
|
|
342
|
+
`const kid = spawn(process.execPath, ["-e", ${JSON.stringify(childSrc)}], { stdio: "ignore" });` +
|
|
343
|
+
(pidFile
|
|
344
|
+
? `require("node:fs").writeFileSync(${JSON.stringify(pidFile)}, process.pid + " " + kid.pid);`
|
|
345
|
+
: "") +
|
|
346
|
+
`setTimeout(() => { try { kid.kill("SIGKILL"); } catch {} process.exit(0); }, ${lifetimeMs});`;
|
|
347
|
+
|
|
348
|
+
test("a silent parent whose GRANDCHILD burns CPU is not killed", { timeout: 30_000 }, async () => {
|
|
349
|
+
// The central claim: progress is the tree's CPU, not the parent's output.
|
|
350
|
+
// This parent writes nothing at all for its whole life — an output-based
|
|
351
|
+
// watchdog would kill it — while a descendant pins a core.
|
|
352
|
+
const burn = "const end = Date.now() + 8000; while (Date.now() < end) {}";
|
|
353
|
+
// Record what the REAL sampler saw, so the test proves the CPU was actually
|
|
354
|
+
// attributed to the tree rather than merely proving nothing was killed — a
|
|
355
|
+
// watchdog that silently failed to sample would also "not kill".
|
|
356
|
+
const treeTotals = [];
|
|
357
|
+
let rootPid = null;
|
|
358
|
+
const code = await supervise({
|
|
359
|
+
command: process.execPath,
|
|
360
|
+
args: ["-e", silentParent(burn, 4000)],
|
|
361
|
+
config: config({ ...REAL_TREE, minCpuRate: 0.3 }),
|
|
362
|
+
sampler: async () => {
|
|
363
|
+
const rows = await psSampler();
|
|
364
|
+
// The supervised child is this process's own descendant; find it by the
|
|
365
|
+
// parent link rather than guessing at argv.
|
|
366
|
+
rootPid ??= rows.find((r) => r.ppid === process.pid)?.pid ?? null;
|
|
367
|
+
if (rootPid !== null) {
|
|
368
|
+
const total = sumTreeCpuSeconds(rows, rootPid);
|
|
369
|
+
if (total !== null) treeTotals.push({ total, members: collectTree(rows, rootPid).length });
|
|
370
|
+
}
|
|
371
|
+
return rows;
|
|
372
|
+
},
|
|
373
|
+
log: () => {},
|
|
374
|
+
});
|
|
375
|
+
assert.equal(code, 0, "a tree burning a full core must survive a 30% floor");
|
|
376
|
+
|
|
377
|
+
assert.ok(treeTotals.length > 0, "the sampler never resolved the supervised tree");
|
|
378
|
+
const last = treeTotals[treeTotals.length - 1];
|
|
379
|
+
assert.ok(last.members >= 2, `expected parent + grandchild in the tree, saw ${last.members}`);
|
|
380
|
+
// The parent is asleep in a timer for its whole life, so essentially all of
|
|
381
|
+
// this is the grandchild's. One second is far above the parent's own cost and
|
|
382
|
+
// far below what a burning core accrues over the run.
|
|
383
|
+
assert.ok(last.total >= 1, `expected the grandchild's CPU in the tree total, got ${last.total}s`);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("a sleeping tree is killed, and no member of it survives", { timeout: 30_000 }, async () => {
|
|
387
|
+
// Killing the ROOT is not the claim — a grandchild left spinning is exactly
|
|
388
|
+
// the process that goes on holding a self-hosted runner. So the tree records
|
|
389
|
+
// both its pids and the test checks the real process table for each.
|
|
390
|
+
const dir = mkdtempSync(path.join(tmpdir(), "pnpm-watchdog-tree-"));
|
|
391
|
+
const pidFile = path.join(dir, "pids");
|
|
392
|
+
try {
|
|
393
|
+
const sleep = "setTimeout(() => process.exit(0), 60000);";
|
|
394
|
+
const code = await supervise({
|
|
395
|
+
command: process.execPath,
|
|
396
|
+
args: ["-e", silentParent(sleep, 60_000, pidFile)],
|
|
397
|
+
config: config({ ...REAL_TREE, minCpuRate: 0.05 }),
|
|
398
|
+
sampler: psSampler,
|
|
399
|
+
log: () => {},
|
|
400
|
+
});
|
|
401
|
+
assert.equal(code, EXIT_STALLED);
|
|
402
|
+
|
|
403
|
+
const pids = readFileSync(pidFile, "utf8").trim().split(/\s+/).map(Number);
|
|
404
|
+
assert.equal(pids.length, 2, "expected the fixture to record a parent and a child pid");
|
|
405
|
+
|
|
406
|
+
// Give the sweep a moment to land, then assert every member is gone.
|
|
407
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
408
|
+
const live = new Set((await psSampler()).map((r) => r.pid));
|
|
409
|
+
for (const pid of pids) {
|
|
410
|
+
assert.equal(live.has(pid), false, `pid ${pid} survived the stall kill`);
|
|
411
|
+
}
|
|
412
|
+
} finally {
|
|
413
|
+
rmSync(dir, { recursive: true, force: true });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// AC-1 — pnpm receives identical argv, supervised or not
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Execute the composite's real `Install dependencies` body against a stub
|
|
423
|
+
* `pnpm` that echoes its argv, and return what the stub saw.
|
|
424
|
+
*/
|
|
425
|
+
function runInstall(env) {
|
|
426
|
+
const dir = mkdtempSync(path.join(tmpdir(), "pnpm-watchdog-"));
|
|
427
|
+
try {
|
|
428
|
+
const stub = path.join(dir, "pnpm");
|
|
429
|
+
writeFileSync(stub, '#!/bin/sh\necho "PNPM_ARGV: $*"\n');
|
|
430
|
+
chmodSync(stub, 0o755);
|
|
431
|
+
const script = path.join(dir, "install.sh");
|
|
432
|
+
writeFileSync(script, installScript);
|
|
433
|
+
return execFileSync("bash", [script], {
|
|
434
|
+
cwd: dir,
|
|
435
|
+
encoding: "utf8",
|
|
436
|
+
env: {
|
|
437
|
+
PATH: `${dir}${path.delimiter}${process.env.PATH}`,
|
|
438
|
+
TRUST_LOCKFILE: "false",
|
|
439
|
+
STORE_DIR_INPUT: "",
|
|
440
|
+
CACHE_ENABLED: "false",
|
|
441
|
+
TOOL_CACHE: "",
|
|
442
|
+
...env,
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
} finally {
|
|
446
|
+
rmSync(dir, { recursive: true, force: true });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const argvOf = (out) => out.match(/PNPM_ARGV: (.*)/)?.[1] ?? null;
|
|
451
|
+
|
|
452
|
+
test("pnpm receives byte-identical argv armed and disabled, in every combination", () => {
|
|
453
|
+
const actionPath = path.resolve(".github/actions/setup-toolchain");
|
|
454
|
+
const combinations = [
|
|
455
|
+
{ CACHE_ENABLED: "false", TOOL_CACHE: "/opt/hostedtoolcache" },
|
|
456
|
+
{ CACHE_ENABLED: "true", TOOL_CACHE: "/opt/hostedtoolcache" },
|
|
457
|
+
{ CACHE_ENABLED: "false", STORE_DIR_INPUT: "/mnt/fast/store" },
|
|
458
|
+
{ CACHE_ENABLED: "false", TOOL_CACHE: "/opt/hostedtoolcache", TRUST_LOCKFILE: "true" },
|
|
459
|
+
{ CACHE_ENABLED: "false", STORE_DIR_INPUT: "/mnt/my store" },
|
|
460
|
+
];
|
|
461
|
+
const watchdogEnv = (stallTimeout) => ({
|
|
462
|
+
ACTION_PATH: actionPath,
|
|
463
|
+
PNPM_WATCHDOG_STALL_TIMEOUT: stallTimeout,
|
|
464
|
+
PNPM_WATCHDOG_GRACE: "120",
|
|
465
|
+
PNPM_WATCHDOG_MIN_CPU_RATE: "0.05",
|
|
466
|
+
// Never sample in a unit test: the point here is argv, and a real sampler
|
|
467
|
+
// would make this suite depend on host process state.
|
|
468
|
+
PNPM_WATCHDOG_SAMPLE_INTERVAL: "3600",
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
for (const base of combinations) {
|
|
472
|
+
const disabled = argvOf(runInstall(base));
|
|
473
|
+
// The documented off switch is the literal '0', not an absent variable —
|
|
474
|
+
// and it is a different branch of the shell condition, so it gets its own
|
|
475
|
+
// comparison rather than riding on the unset case.
|
|
476
|
+
const offSwitch = argvOf(runInstall({ ...base, ...watchdogEnv("0") }));
|
|
477
|
+
assert.equal(offSwitch, disabled, `install-stall-timeout '0' drifted for ${JSON.stringify(base)}`);
|
|
478
|
+
const armed = argvOf(
|
|
479
|
+
runInstall({ ...base, ...watchdogEnv("600") }),
|
|
480
|
+
);
|
|
481
|
+
assert.notEqual(disabled, null, `no argv captured for ${JSON.stringify(base)}`);
|
|
482
|
+
assert.equal(armed, disabled, `argv drifted for ${JSON.stringify(base)}`);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("an empty ACTION_PATH falls back to the unsupervised install", () => {
|
|
487
|
+
// A watchdog that cannot be located must not be able to fail the install it
|
|
488
|
+
// was only meant to watch.
|
|
489
|
+
const out = runInstall({
|
|
490
|
+
ACTION_PATH: "",
|
|
491
|
+
PNPM_WATCHDOG_STALL_TIMEOUT: "600",
|
|
492
|
+
TOOL_CACHE: "/opt/hostedtoolcache",
|
|
493
|
+
});
|
|
494
|
+
assert.equal(argvOf(out), "install --frozen-lockfile --store-dir /opt/hostedtoolcache/pnpm-store");
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// ---------------------------------------------------------------------------
|
|
498
|
+
// AC-6 — the knobs are reachable end-to-end, and never reach the shell as text
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
|
|
501
|
+
const KNOBS = ["install-stall-timeout", "install-stall-grace", "install-min-cpu-rate"];
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Whether `text` has a line that is exactly `line`.
|
|
505
|
+
*
|
|
506
|
+
* Deliberately not a constructed `RegExp`: a non-literal pattern is refused by
|
|
507
|
+
* this repo's SAST rules — test files included — and an indentation-sensitive
|
|
508
|
+
* YAML key is clearer as an exact line match anyway.
|
|
509
|
+
*/
|
|
510
|
+
const hasLine = (text, line) => text.split("\n").includes(line);
|
|
511
|
+
|
|
512
|
+
/** The value of the first `key: value` line at `indent`, or null. */
|
|
513
|
+
const inputDefaultAfter = (text, key, indent) => {
|
|
514
|
+
const lines = text.split("\n");
|
|
515
|
+
const start = lines.indexOf(`${" ".repeat(indent)}${key}:`);
|
|
516
|
+
if (start === -1) return null;
|
|
517
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
518
|
+
const trimmed = lines[i].trim();
|
|
519
|
+
if (trimmed.startsWith("default:")) return trimmed.slice("default:".length).trim();
|
|
520
|
+
// A sibling key at or above this indent ends the block.
|
|
521
|
+
if (trimmed !== "" && lines[i].search(/\S/) <= indent) return null;
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
test("setup-toolchain declares every knob with the documented default", () => {
|
|
527
|
+
for (const knob of KNOBS) {
|
|
528
|
+
assert.ok(hasLine(actionText, ` ${knob}:`), `action input ${knob} is missing`);
|
|
529
|
+
}
|
|
530
|
+
assert.equal(inputDefaultAfter(actionText, "install-stall-timeout", 2), "'600'");
|
|
531
|
+
assert.equal(inputDefaultAfter(actionText, "install-stall-grace", 2), "'120'");
|
|
532
|
+
assert.equal(inputDefaultAfter(actionText, "install-min-cpu-rate", 2), "'0.05'");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("pr-quality declares each knob as a workflow_call input with a LITERAL default", () => {
|
|
536
|
+
for (const knob of KNOBS) {
|
|
537
|
+
assert.ok(hasLine(workflowText, ` ${knob}:`), `workflow input ${knob} missing`);
|
|
538
|
+
}
|
|
539
|
+
for (const knob of KNOBS) {
|
|
540
|
+
const value = inputDefaultAfter(workflowText, knob, 6);
|
|
541
|
+
assert.notEqual(value, null, `${knob} has no default`);
|
|
542
|
+
// An Actions expression in a workflow_call default is never evaluated — it
|
|
543
|
+
// reaches the callee as the literal text `${{ ... }}`.
|
|
544
|
+
assert.doesNotMatch(value, /\$\{\{/, `a workflow_call default must be a literal: ${value}`);
|
|
545
|
+
}
|
|
546
|
+
assert.equal(inputDefaultAfter(workflowText, "install-stall-timeout", 6), "'600'");
|
|
547
|
+
assert.equal(inputDefaultAfter(workflowText, "install-stall-grace", 6), "'120'");
|
|
548
|
+
assert.equal(inputDefaultAfter(workflowText, "install-min-cpu-rate", 6), "'0.05'");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("every setup-toolchain call site forwards every knob", () => {
|
|
552
|
+
// One anchor, aliased by the other tiers — so threading it once must reach
|
|
553
|
+
// them all, and a NEW call site that forgets a knob must fail here.
|
|
554
|
+
const lines = workflowText.split("\n");
|
|
555
|
+
const sites = lines
|
|
556
|
+
.map((line, i) => ({ line, i }))
|
|
557
|
+
.filter(({ line }) => line.includes("actions/setup-toolchain@"));
|
|
558
|
+
assert.equal(sites.length, 1, "expected exactly one setup-toolchain call site (the anchor)");
|
|
559
|
+
|
|
560
|
+
// The call site's `with:` block: from the `uses:` line to the first line that
|
|
561
|
+
// dedents OUT of the step. `with:` is a sibling of `uses:` at the same indent,
|
|
562
|
+
// so the boundary is a strict dedent — the next step's bullet.
|
|
563
|
+
const usesIndent = lines[sites[0].i].search(/\S/);
|
|
564
|
+
const block = [];
|
|
565
|
+
for (let i = sites[0].i + 1; i < lines.length; i++) {
|
|
566
|
+
if (lines[i].trim() !== "" && lines[i].search(/\S/) < usesIndent) break;
|
|
567
|
+
block.push(lines[i]);
|
|
568
|
+
}
|
|
569
|
+
for (const knob of KNOBS) {
|
|
570
|
+
assert.ok(
|
|
571
|
+
block.some((line) => line.trim() === `${knob}: \${{ inputs.${knob} }}`),
|
|
572
|
+
`the setup-toolchain call site does not forward ${knob}`,
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
assert.ok(
|
|
576
|
+
lines.filter((line) => line === " - *setup-toolchain").length > 0,
|
|
577
|
+
"expected the setup-toolchain anchor to be aliased by the other tiers",
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test("no caller value is interpolated into the install step's run body", () => {
|
|
582
|
+
// rules/security-baseline.md: a caller-supplied value reaches the shell as an
|
|
583
|
+
// environment variable and nothing else. An interpolated one is workflow TEXT
|
|
584
|
+
// — it is substituted before bash ever sees it, so no amount of quoting in
|
|
585
|
+
// the body can contain it.
|
|
586
|
+
assert.doesNotMatch(installScript, /\$\{\{/, "the install run body must be expression-free");
|
|
587
|
+
const stepBlock = stepByName(actionText, "Install dependencies");
|
|
588
|
+
for (const knob of KNOBS) {
|
|
589
|
+
assert.ok(stepBlock.includes(`inputs['${knob}']`), `${knob} does not reach the step via env:`);
|
|
590
|
+
}
|
|
591
|
+
assert.match(stepBlock, /ACTION_PATH: \$\{\{ github\.action_path \}\}/);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// AC-7 — the documentation a caller actually reads
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
test("reusable-workflows.md documents the knobs in the inputs table and in prose", () => {
|
|
599
|
+
const docs = readFileSync(DOCS, "utf8");
|
|
600
|
+
for (const knob of KNOBS) {
|
|
601
|
+
assert.ok(docs.includes(`\`${knob}\``), `${knob} is undocumented`);
|
|
602
|
+
}
|
|
603
|
+
assert.ok(docs.includes("install-stall-timeout: '0'"), "the off switch is undocumented");
|
|
604
|
+
assert.ok(docs.includes("34876024126"), "the measured incident is not cited");
|
|
605
|
+
assert.ok(docs.includes("still fails"), "the docs must not imply a supervised stall turns green");
|
|
606
|
+
});
|