@tiphys/kernel 0.1.0 → 0.2.0
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/AGENTS.md +56 -4
- package/assurance-modes.yaml +23 -2
- package/dist/bin/tiphys.js +86 -8
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.js +27 -20
- package/dist/src/checks.d.ts +720 -9
- package/dist/src/checks.js +1874 -163
- package/dist/src/cli.js +11 -0
- package/dist/src/commands/brief.js +27 -4
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +229 -0
- package/dist/src/commands/doctor.js +968 -27
- package/dist/src/commands/init.d.ts +3 -3
- package/dist/src/commands/init.js +57 -8
- package/dist/src/commands/lock.d.ts +33 -0
- package/dist/src/commands/lock.js +117 -6
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/pool.js +12 -1
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.js +51 -2
- package/dist/src/commands/status.d.ts +6 -4
- package/dist/src/commands/status.js +6 -4
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.js +10 -2
- package/dist/src/commands/validate.js +70 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +152 -2
- package/dist/src/exec/env.js +146 -2
- package/dist/src/fleet.d.ts +172 -0
- package/dist/src/fleet.js +219 -1
- package/dist/src/gates/citations.js +7 -1
- package/dist/src/gates/coverage.d.ts +113 -22
- package/dist/src/gates/coverage.js +166 -31
- package/dist/src/gates/credentials.d.ts +159 -0
- package/dist/src/gates/credentials.js +221 -2
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/red-witness.js +105 -13
- package/dist/src/gates/run.d.ts +49 -1
- package/dist/src/gates/run.js +83 -5
- package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
- package/dist/src/gates/suite.js +48 -7
- package/dist/src/hooks.d.ts +55 -3
- package/dist/src/hooks.js +69 -6
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/lock.d.ts +82 -4
- package/dist/src/lock.js +314 -22
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/path-identity.d.ts +32 -0
- package/dist/src/path-identity.js +38 -0
- package/dist/src/pool.d.ts +197 -1
- package/dist/src/pool.js +289 -22
- package/dist/src/roles.d.ts +31 -0
- package/dist/src/roles.js +42 -0
- package/dist/src/spawn.d.ts +307 -2
- package/dist/src/spawn.js +690 -19
- package/dist/src/status.d.ts +27 -2
- package/dist/src/status.js +34 -5
- package/dist/src/task.d.ts +295 -55
- package/dist/src/task.js +125 -123
- package/dist/src/teardown.d.ts +7 -0
- package/dist/src/teardown.js +120 -12
- package/dist/src/validate.d.ts +44 -11
- package/dist/src/validate.js +44 -34
- package/dist/src/watcher.js +1 -11
- package/dist/src/witness/run.d.ts +32 -7
- package/dist/src/witness/run.js +76 -30
- package/dist/src/witness/spec.d.ts +168 -0
- package/dist/src/witness/spec.js +240 -18
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/gate-registry.yaml +136 -0
- package/gates.manifest.json +63 -1
- package/package.json +18 -3
- package/roles/implementer.md +3 -0
- package/schemas/README.md +1 -0
- package/schemas/assurance-modes.schema.json +1 -1
- package/schemas/charter.schema.json +19 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/verdict.schema.json +9 -3
- package/schemas/write-bypass.schema.json +69 -0
package/dist/src/fleet.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, statSync } from "node:fs";
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
/**
|
|
4
4
|
* Fleet-home layout (kernel plan v1, M1-P2 step 1; blueprint section 3 with
|
|
@@ -78,3 +78,221 @@ export function loadFleet(dir) {
|
|
|
78
78
|
beaconPath: join(root, BEACON_FILE),
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Classify a layout path without opening it. lstat first, so a symlink is
|
|
83
|
+
* seen as a symlink; then stat, so a symlink to a directory is a directory
|
|
84
|
+
* and a dangling one is reported as such rather than as absent.
|
|
85
|
+
*/
|
|
86
|
+
export function classifyLayoutEntry(path) {
|
|
87
|
+
try {
|
|
88
|
+
lstatSync(path);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error.code === "ENOENT") {
|
|
92
|
+
return { kind: "absent" };
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
kind: "unexaminable",
|
|
96
|
+
reason: `${path} could not be examined: ${String(error)}`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
let stats;
|
|
100
|
+
try {
|
|
101
|
+
stats = statSync(path);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error.code === "ENOENT") {
|
|
105
|
+
return { kind: "other", reason: `${path} is a symlink to nothing` };
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: "unexaminable",
|
|
109
|
+
reason: `${path} could not be examined: ${String(error)}`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (stats.isDirectory()) {
|
|
113
|
+
return { kind: "directory" };
|
|
114
|
+
}
|
|
115
|
+
return { kind: "other", reason: `${path} exists and is not a directory` };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The EPHEMERAL directories: exactly the gitignored set, with the trailing
|
|
119
|
+
* slash that `.gitignore` needs stripped off. DERIVED from FLEET_IGNORED
|
|
120
|
+
* rather than listed again, because a second list is a second thing to keep
|
|
121
|
+
* in step and the first divergence would be silent: `tiphys resume` would
|
|
122
|
+
* rebuild one set while `.gitignore` ignored another.
|
|
123
|
+
*/
|
|
124
|
+
export const EPHEMERAL_DIRS = FLEET_IGNORED.map((entry) => entry.endsWith("/") ? entry.slice(0, -1) : entry);
|
|
125
|
+
/**
|
|
126
|
+
* The DURABLE directories: every fleet directory that is not ephemeral.
|
|
127
|
+
* A clone of a fleet home carries these and not the ephemeral ones, which
|
|
128
|
+
* is the fact `tiphys resume` exists to act on.
|
|
129
|
+
*/
|
|
130
|
+
export const DURABLE_DIRS = FLEET_DIRS.filter((name) => !EPHEMERAL_DIRS.includes(name));
|
|
131
|
+
/**
|
|
132
|
+
* The durable layout entries missing from dir, in declaration order:
|
|
133
|
+
* directories first with a trailing slash, then the root files. An empty
|
|
134
|
+
* result means the directory carries everything a clone of a fleet home
|
|
135
|
+
* carries, which is the precondition `tiphys resume` requires and never
|
|
136
|
+
* fabricates.
|
|
137
|
+
*/
|
|
138
|
+
export function missingDurableEntries(dir) {
|
|
139
|
+
const missing = [];
|
|
140
|
+
for (const name of DURABLE_DIRS) {
|
|
141
|
+
if (classifyLayoutEntry(join(dir, name)).kind !== "directory") {
|
|
142
|
+
missing.push(`${name}/`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const name of FLEET_FILES) {
|
|
146
|
+
const p = join(dir, name);
|
|
147
|
+
if (!existsSync(p) || !statSync(p).isFile()) {
|
|
148
|
+
missing.push(name);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return missing;
|
|
152
|
+
}
|
|
153
|
+
function describePathType(stats) {
|
|
154
|
+
if (stats.isDirectory()) {
|
|
155
|
+
return "a directory";
|
|
156
|
+
}
|
|
157
|
+
if (stats.isFIFO()) {
|
|
158
|
+
return "a named pipe";
|
|
159
|
+
}
|
|
160
|
+
if (stats.isSocket()) {
|
|
161
|
+
return "a socket";
|
|
162
|
+
}
|
|
163
|
+
if (stats.isCharacterDevice()) {
|
|
164
|
+
return "a character device";
|
|
165
|
+
}
|
|
166
|
+
if (stats.isBlockDevice()) {
|
|
167
|
+
return "a block device";
|
|
168
|
+
}
|
|
169
|
+
return "an entry of an unrecognized type";
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* lstat first, so a symlink is seen as a symlink; then stat, so a symlink to
|
|
173
|
+
* a regular file is regular and a dangling one is reported as such rather
|
|
174
|
+
* than as absent. The path is never opened.
|
|
175
|
+
*/
|
|
176
|
+
export function classifyPathEntry(path) {
|
|
177
|
+
try {
|
|
178
|
+
lstatSync(path);
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
if (error.code === "ENOENT") {
|
|
182
|
+
return { kind: "absent" };
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
kind: "unexaminable",
|
|
186
|
+
reason: `${path} could not be examined: ${String(error)}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
let stats;
|
|
190
|
+
try {
|
|
191
|
+
stats = statSync(path);
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
if (error.code === "ENOENT") {
|
|
195
|
+
return { kind: "dangling" };
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
kind: "unexaminable",
|
|
199
|
+
reason: `${path} could not be examined: ${String(error)}`,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (stats.isFile()) {
|
|
203
|
+
return { kind: "regular" };
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
kind: "irregular",
|
|
207
|
+
reason: `${path} is ${describePathType(stats)}, not a regular file, so it was not opened`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/** THE ONE READ of a path that might not be there and might not be a file. */
|
|
211
|
+
export function readRegularPathIfPresent(path) {
|
|
212
|
+
const entry = classifyPathEntry(path);
|
|
213
|
+
if (entry.kind === "absent" || entry.kind === "dangling") {
|
|
214
|
+
return { kind: "absent" };
|
|
215
|
+
}
|
|
216
|
+
if (entry.kind === "irregular" || entry.kind === "unexaminable") {
|
|
217
|
+
return { kind: "refused", reason: entry.reason };
|
|
218
|
+
}
|
|
219
|
+
let body;
|
|
220
|
+
try {
|
|
221
|
+
body = readFileSync(path, "utf8");
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (error.code === "ENOENT") {
|
|
225
|
+
// Removed between the probe and the read.
|
|
226
|
+
return { kind: "absent" };
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
kind: "refused",
|
|
230
|
+
reason: `${path} could not be read: ${String(error)}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return { kind: "read", body };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Refuse an open-for-WRITE of a path that is not a regular file. The hazard
|
|
237
|
+
* is symmetric: open(2) for writing on a FIFO with no reader blocks exactly
|
|
238
|
+
* as reading one with no writer does, so a staged write is as dangerous as a
|
|
239
|
+
* read. Returns the reason, or undefined when the path may be opened (absent
|
|
240
|
+
* included: creating it is the point).
|
|
241
|
+
*/
|
|
242
|
+
export function refuseOpenPathForWrite(path) {
|
|
243
|
+
const entry = classifyPathEntry(path);
|
|
244
|
+
if (entry.kind === "irregular" || entry.kind === "unexaminable") {
|
|
245
|
+
return entry.reason;
|
|
246
|
+
}
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
/* ------------------------------------------------------------------ */
|
|
250
|
+
/* The fleet home's SYNC CLASSES */
|
|
251
|
+
/* ------------------------------------------------------------------ */
|
|
252
|
+
/**
|
|
253
|
+
* THE KERNEL'S OWN WRITE-THEN-RENAME SCRATCH SUFFIXES.
|
|
254
|
+
*
|
|
255
|
+
* `FLEET_IGNORED` above is a DENYLIST of three directory prefixes, and
|
|
256
|
+
* `tiphys sync` derives "durable" as "not covered by it". That derivation is
|
|
257
|
+
* right for everything the ignore rules were written to cover and blind to a
|
|
258
|
+
* class they were never asked about: a transient artifact the KERNEL ITSELF
|
|
259
|
+
* creates beside a TRACKED target, which git therefore reports as an
|
|
260
|
+
* ordinary new durable path.
|
|
261
|
+
*
|
|
262
|
+
* The enumeration behind this list is every path the kernel writes under a
|
|
263
|
+
* fleet home, classified against `FLEET_IGNORED`. The three that land
|
|
264
|
+
* DURABLE without being fleet content are:
|
|
265
|
+
*
|
|
266
|
+
* status/current.json.tmp src/status.ts:142, a fixed name inside the
|
|
267
|
+
* tracked status/ directory.
|
|
268
|
+
* .cutover.<random>.tmp src/cutover.ts:278, a dot-prefixed random
|
|
269
|
+
* name at the fleet ROOT.
|
|
270
|
+
* tiphys-environment.json src/exclusion.ts:303, durable ON PURPOSE
|
|
271
|
+
* (M4-P21 criterion 3) and therefore NOT in
|
|
272
|
+
* this list.
|
|
273
|
+
*
|
|
274
|
+
* Two members, two directories, two naming shapes, which is why the rule is
|
|
275
|
+
* a SUFFIX and not a filename: a rule naming `current.json.tmp` would close
|
|
276
|
+
* the first and leave the second open.
|
|
277
|
+
*
|
|
278
|
+
* `FLEET_IGNORED` IS DELIBERATELY UNCHANGED. It drives `EPHEMERAL_DIRS`,
|
|
279
|
+
* `DURABLE_DIRS` and the `.gitignore` that `tiphys init` writes, so a glob
|
|
280
|
+
* added there would become a directory name `tiphys resume` tried to rebuild.
|
|
281
|
+
* Nothing stops being synced because of this constant; `tiphys sync` gains a
|
|
282
|
+
* refusal, and only for paths matching a suffix below.
|
|
283
|
+
*/
|
|
284
|
+
export const FLEET_SCRATCH_SUFFIXES = [
|
|
285
|
+
".tmp",
|
|
286
|
+
".stage",
|
|
287
|
+
".mutex",
|
|
288
|
+
];
|
|
289
|
+
/**
|
|
290
|
+
* True when a fleet-relative path is a kernel scratch artifact by its name
|
|
291
|
+
* alone. Name-only on purpose: `tiphys sync` asks this about a path git
|
|
292
|
+
* REPORTED, which may already have been renamed away by the time the
|
|
293
|
+
* question is asked, so a stat here would answer about a different world
|
|
294
|
+
* than the one being committed.
|
|
295
|
+
*/
|
|
296
|
+
export function isFleetScratchPath(relativePath) {
|
|
297
|
+
return FLEET_SCRATCH_SUFFIXES.find((suffix) => relativePath.endsWith(suffix));
|
|
298
|
+
}
|
|
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
|
|
|
3
3
|
import { lstatSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { pathsIdentifySameObject } from "../path-identity.js";
|
|
6
7
|
import { classifyEntry, readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
|
|
7
8
|
import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
8
9
|
import { formatDiagnostics, loadSchema, validate } from "./validate.js";
|
|
@@ -1156,8 +1157,13 @@ export function main(argv) {
|
|
|
1156
1157
|
return EXIT_GATE_ERROR;
|
|
1157
1158
|
}
|
|
1158
1159
|
}
|
|
1160
|
+
// IDENTITY, NOT STRING EQUALITY (M4-P2 fix round, 2026-09-16). `resolve`
|
|
1161
|
+
// normalizes relative segments and does NOT resolve symlinks, so an
|
|
1162
|
+
// invocation reached through a symlinked directory or through a symlink to
|
|
1163
|
+
// this file leaves the two sides as different spellings of one file and
|
|
1164
|
+
// this gate silently does nothing. Measured; see src/path-identity.ts:13.
|
|
1159
1165
|
const invokedDirectly = process.argv[1] !== undefined &&
|
|
1160
|
-
|
|
1166
|
+
pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1]);
|
|
1161
1167
|
if (invokedDirectly) {
|
|
1162
1168
|
process.exitCode = main(process.argv.slice(2));
|
|
1163
1169
|
}
|
|
@@ -96,10 +96,13 @@
|
|
|
96
96
|
* config-supplied pattern (both `idPattern`s and every `bucketKinds[].pattern`)
|
|
97
97
|
* is VALIDATED (compiles, and is rejected if it matches a known
|
|
98
98
|
* catastrophic-backtracking shape) before it is ever executed, and every
|
|
99
|
-
* EXECUTION of a config-supplied pattern is BOUNDED
|
|
100
|
-
*
|
|
99
|
+
* EXECUTION of a config-supplied pattern is BOUNDED (`boundedExec`, below),
|
|
100
|
+
* so neither a malformed pattern (CR-990, used to
|
|
101
101
|
* throw with no result record written) nor a ReDoS pattern (CR-991, used
|
|
102
|
-
* to hang indefinitely) can defeat this gate
|
|
102
|
+
* to hang indefinitely) can defeat this gate. M4-P28 changed WHAT that
|
|
103
|
+
* bound measures, from elapsed wall clock to CPU work, because the first
|
|
104
|
+
* one reddened for well-behaved patterns whenever the machine was busy.
|
|
105
|
+
* CR-992 (overlapping kinds)
|
|
103
106
|
* is folded into the same fix because it is the same "a config string is
|
|
104
107
|
* trusted further than its syntax justifies" mechanism one property over.
|
|
105
108
|
*/
|
|
@@ -162,29 +165,117 @@ export declare const KERNEL_COVERAGE_CONFIG: CoverageConfig;
|
|
|
162
165
|
* closes both instances at once.
|
|
163
166
|
*/
|
|
164
167
|
export declare function isEmptyCell(value: string): boolean;
|
|
165
|
-
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
168
|
+
/**
|
|
169
|
+
* THE VERDICT-PRODUCING BOUND IS CPU WORK, NOT ELAPSED TIME (M4-P28).
|
|
170
|
+
*
|
|
171
|
+
* What this constant replaces, and why the replacement is not a bigger
|
|
172
|
+
* number. Until this phase the bound was `REGEX_EXEC_TIMEOUT_MS = 250`,
|
|
173
|
+
* handed to `vm` as a WALL-CLOCK timeout, and exceeding it was reported as
|
|
174
|
+
* "did not complete within 250ms ... (possible catastrophic
|
|
175
|
+
* backtracking)". Elapsed wall time is complexity DIVIDED BY available
|
|
176
|
+
* CPU, so that condition did not test the property it named: it reddened
|
|
177
|
+
* whenever the machine was busy. Measured and recorded in
|
|
178
|
+
* delivery/verification/wall-clock-budgets-are-load-dependent.md:1, five
|
|
179
|
+
* independent witnesses hit it on six structurally different patterns,
|
|
180
|
+
* including `^(?:parked)$`, a doubly anchored literal with no quantifier,
|
|
181
|
+
* no character class and no alternation, which has nothing to backtrack
|
|
182
|
+
* over at any input length. One million executions of
|
|
183
|
+
* `^(?:R-[0-9]+[a-z]?)$` against `R-094a` take 154.2ms in total, so ONE is
|
|
184
|
+
* roughly 1.6 million times under the old budget; for one of them to
|
|
185
|
+
* exceed it the thread must be descheduled for a quarter of a second,
|
|
186
|
+
* which is a fact about the machine.
|
|
187
|
+
*
|
|
188
|
+
* Raising 250 to a larger number was considered and is REJECTED by the
|
|
189
|
+
* plan (delivery/plan/kernel-plan-m4.md:3784): it keeps the same
|
|
190
|
+
* instrument and only moves the load at which it lies.
|
|
191
|
+
*
|
|
192
|
+
* WHAT IS MEASURED NOW. `process.threadCpuUsage()` reports the CPU time
|
|
193
|
+
* this thread has consumed, in microseconds. Another process being busy
|
|
194
|
+
* does not add to it, because a descheduled thread consumes no CPU while
|
|
195
|
+
* it is not running. The regex runs on THIS thread inside
|
|
196
|
+
* `runInContext`, so the delta across that call is the work the regex
|
|
197
|
+
* did, and nothing else. On an interpreter without
|
|
198
|
+
* `process.threadCpuUsage` (added in Node 22.15; the package floor is 26)
|
|
199
|
+
* the fallback is `process.cpuUsage()`, which is process-wide and
|
|
200
|
+
* therefore an over-estimate, never an under-estimate, so the fallback
|
|
201
|
+
* cannot let a catastrophic pattern through.
|
|
202
|
+
*/
|
|
203
|
+
export declare const REGEX_EXEC_CPU_BUDGET_MS = 250;
|
|
204
|
+
/**
|
|
205
|
+
* HOW LONG ONE ATTEMPT IS WILLING TO WAIT. This is PATIENCE, not a
|
|
206
|
+
* verdict: nothing is ever reported about a pattern because this elapsed.
|
|
207
|
+
* A wall-clock interrupt is still needed, because a catastrophic match
|
|
208
|
+
* never returns on its own and `vm`'s `timeout` is the only mechanism
|
|
209
|
+
* here that can stop one (v8 checks for the termination request during a
|
|
210
|
+
* regex match, not only between statements).
|
|
211
|
+
*
|
|
212
|
+
* It is set to TWICE the CPU budget so that a thread receiving at least
|
|
213
|
+
* half of one CPU reaches the budget inside a single attempt; measured on
|
|
214
|
+
* an unloaded box, `(a+)+b` against 30 a's consumes 230.6ms of CPU in a
|
|
215
|
+
* 252ms wall window, so a backstop equal to the budget would have needed
|
|
216
|
+
* a second attempt to reach a verdict it had nearly earned.
|
|
217
|
+
*/
|
|
218
|
+
export declare const REGEX_EXEC_WALL_BACKSTOP_MS = 500;
|
|
219
|
+
/**
|
|
220
|
+
* How many interrupted attempts before this module admits it cannot
|
|
221
|
+
* reach a verdict. Each attempt doubles its own patience, and the CPU
|
|
222
|
+
* consumed ACCUMULATES across attempts, so a genuinely spinning pattern
|
|
223
|
+
* is still caught on a loaded machine (it burns CPU whenever it runs)
|
|
224
|
+
* while a benign one accumulates microseconds however often it is
|
|
225
|
+
* interrupted.
|
|
226
|
+
*/
|
|
227
|
+
export declare const REGEX_EXEC_MAX_ATTEMPTS = 4;
|
|
228
|
+
/** The pattern did too much WORK. A verdict about the pattern. */
|
|
171
229
|
export declare class RegexBoundExceededError extends Error {
|
|
172
230
|
}
|
|
173
231
|
/**
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
*
|
|
232
|
+
* No verdict was reached: every attempt was interrupted before the
|
|
233
|
+
* pattern finished, and the CPU it consumed never came near the budget,
|
|
234
|
+
* which is the signature of a thread that is not being scheduled rather
|
|
235
|
+
* than one that is spinning. Under M2-C-3 a check that cannot reach a
|
|
236
|
+
* verdict reports ERROR, never a verdict it did not earn, so this is
|
|
237
|
+
* deliberately NOT a subclass of `RegexBoundExceededError`: the two must
|
|
238
|
+
* not be confusable by a `catch` or an `instanceof`.
|
|
239
|
+
*/
|
|
240
|
+
export declare class RegexBudgetUndeterminedError extends Error {
|
|
241
|
+
}
|
|
242
|
+
/** Overrides for one call, used by the witness tests to stand in for a
|
|
243
|
+
* machine slower or busier than this one. Only the PATIENCE is varied
|
|
244
|
+
* there; the verdict instrument stays the CPU budget. */
|
|
245
|
+
export interface RegexExecBounds {
|
|
246
|
+
cpuBudgetMs?: number;
|
|
247
|
+
wallBackstopMs?: number;
|
|
248
|
+
maxAttempts?: number;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Execute `compiled.exec(value)` inside a v8 context, bounded by the CPU
|
|
252
|
+
* WORK it does rather than by the time it takes.
|
|
253
|
+
*
|
|
254
|
+
* One attempt runs the match with a wall-clock interrupt. Whatever
|
|
255
|
+
* happens, the CPU consumed by this thread during that call is added to a
|
|
256
|
+
* running total:
|
|
257
|
+
*
|
|
258
|
+
* - total CPU at or above the budget: the thread really did burn that
|
|
259
|
+
* much CPU on this one match, which no anchored non-backtracking
|
|
260
|
+
* pattern can do, so `RegexBoundExceededError` is thrown. A busy
|
|
261
|
+
* machine cannot cause this, because a descheduled thread accumulates
|
|
262
|
+
* no CPU.
|
|
263
|
+
* - the match finished: return its result, which is the only path that
|
|
264
|
+
* produces a match.
|
|
265
|
+
* - interrupted with the total still far below the budget: the machine
|
|
266
|
+
* was busy, not the pattern. Double the patience and try again.
|
|
267
|
+
*
|
|
268
|
+
* After `maxAttempts` interruptions with the CPU total still under
|
|
269
|
+
* budget, `RegexBudgetUndeterminedError` is thrown, naming the CPU
|
|
270
|
+
* consumed, the wall clock spent and the load average, so the record says
|
|
271
|
+
* what it observed instead of asserting something about the pattern.
|
|
272
|
+
*
|
|
273
|
+
* An error from `runInContext` that is NOT the timeout is also
|
|
274
|
+
* undetermined rather than a finding: the old code funnelled every throw
|
|
275
|
+
* into the catastrophic-backtracking message, which is the same
|
|
276
|
+
* substitution one cause over.
|
|
186
277
|
*/
|
|
187
|
-
export declare function boundedExec(compiled: RegExp, value: string): RegExpExecArray | null;
|
|
278
|
+
export declare function boundedExec(compiled: RegExp, value: string, bounds?: RegexExecBounds): RegExpExecArray | null;
|
|
188
279
|
/**
|
|
189
280
|
* A STATIC heuristic for the single most common catastrophic-backtracking
|
|
190
281
|
* shape (fix round 1, CR-991): a parenthesised group containing an
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { pathsIdentifySameObject } from "../path-identity.js";
|
|
4
5
|
import { Script, createContext } from "node:vm";
|
|
6
|
+
import { cpus, loadavg } from "node:os";
|
|
5
7
|
import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
|
|
6
8
|
import { loadSchema, validate } from "./validate.js";
|
|
7
9
|
import { EXIT_GATE_ERROR, exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
@@ -70,41 +72,166 @@ export function isEmptyCell(value) {
|
|
|
70
72
|
* meaningfully slower in this round's own benchmark than reusing one.
|
|
71
73
|
*/
|
|
72
74
|
const regexSandbox = createContext(Object.create(null));
|
|
73
|
-
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
75
|
+
/**
|
|
76
|
+
* THE VERDICT-PRODUCING BOUND IS CPU WORK, NOT ELAPSED TIME (M4-P28).
|
|
77
|
+
*
|
|
78
|
+
* What this constant replaces, and why the replacement is not a bigger
|
|
79
|
+
* number. Until this phase the bound was `REGEX_EXEC_TIMEOUT_MS = 250`,
|
|
80
|
+
* handed to `vm` as a WALL-CLOCK timeout, and exceeding it was reported as
|
|
81
|
+
* "did not complete within 250ms ... (possible catastrophic
|
|
82
|
+
* backtracking)". Elapsed wall time is complexity DIVIDED BY available
|
|
83
|
+
* CPU, so that condition did not test the property it named: it reddened
|
|
84
|
+
* whenever the machine was busy. Measured and recorded in
|
|
85
|
+
* delivery/verification/wall-clock-budgets-are-load-dependent.md:1, five
|
|
86
|
+
* independent witnesses hit it on six structurally different patterns,
|
|
87
|
+
* including `^(?:parked)$`, a doubly anchored literal with no quantifier,
|
|
88
|
+
* no character class and no alternation, which has nothing to backtrack
|
|
89
|
+
* over at any input length. One million executions of
|
|
90
|
+
* `^(?:R-[0-9]+[a-z]?)$` against `R-094a` take 154.2ms in total, so ONE is
|
|
91
|
+
* roughly 1.6 million times under the old budget; for one of them to
|
|
92
|
+
* exceed it the thread must be descheduled for a quarter of a second,
|
|
93
|
+
* which is a fact about the machine.
|
|
94
|
+
*
|
|
95
|
+
* Raising 250 to a larger number was considered and is REJECTED by the
|
|
96
|
+
* plan (delivery/plan/kernel-plan-m4.md:3784): it keeps the same
|
|
97
|
+
* instrument and only moves the load at which it lies.
|
|
98
|
+
*
|
|
99
|
+
* WHAT IS MEASURED NOW. `process.threadCpuUsage()` reports the CPU time
|
|
100
|
+
* this thread has consumed, in microseconds. Another process being busy
|
|
101
|
+
* does not add to it, because a descheduled thread consumes no CPU while
|
|
102
|
+
* it is not running. The regex runs on THIS thread inside
|
|
103
|
+
* `runInContext`, so the delta across that call is the work the regex
|
|
104
|
+
* did, and nothing else. On an interpreter without
|
|
105
|
+
* `process.threadCpuUsage` (added in Node 22.15; the package floor is 26)
|
|
106
|
+
* the fallback is `process.cpuUsage()`, which is process-wide and
|
|
107
|
+
* therefore an over-estimate, never an under-estimate, so the fallback
|
|
108
|
+
* cannot let a catastrophic pattern through.
|
|
109
|
+
*/
|
|
110
|
+
export const REGEX_EXEC_CPU_BUDGET_MS = 250;
|
|
111
|
+
/**
|
|
112
|
+
* HOW LONG ONE ATTEMPT IS WILLING TO WAIT. This is PATIENCE, not a
|
|
113
|
+
* verdict: nothing is ever reported about a pattern because this elapsed.
|
|
114
|
+
* A wall-clock interrupt is still needed, because a catastrophic match
|
|
115
|
+
* never returns on its own and `vm`'s `timeout` is the only mechanism
|
|
116
|
+
* here that can stop one (v8 checks for the termination request during a
|
|
117
|
+
* regex match, not only between statements).
|
|
118
|
+
*
|
|
119
|
+
* It is set to TWICE the CPU budget so that a thread receiving at least
|
|
120
|
+
* half of one CPU reaches the budget inside a single attempt; measured on
|
|
121
|
+
* an unloaded box, `(a+)+b` against 30 a's consumes 230.6ms of CPU in a
|
|
122
|
+
* 252ms wall window, so a backstop equal to the budget would have needed
|
|
123
|
+
* a second attempt to reach a verdict it had nearly earned.
|
|
124
|
+
*/
|
|
125
|
+
export const REGEX_EXEC_WALL_BACKSTOP_MS = 500;
|
|
126
|
+
/**
|
|
127
|
+
* How many interrupted attempts before this module admits it cannot
|
|
128
|
+
* reach a verdict. Each attempt doubles its own patience, and the CPU
|
|
129
|
+
* consumed ACCUMULATES across attempts, so a genuinely spinning pattern
|
|
130
|
+
* is still caught on a loaded machine (it burns CPU whenever it runs)
|
|
131
|
+
* while a benign one accumulates microseconds however often it is
|
|
132
|
+
* interrupted.
|
|
133
|
+
*/
|
|
134
|
+
export const REGEX_EXEC_MAX_ATTEMPTS = 4;
|
|
135
|
+
/** The pattern did too much WORK. A verdict about the pattern. */
|
|
79
136
|
export class RegexBoundExceededError extends Error {
|
|
80
137
|
}
|
|
81
138
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* `(a+)+b` pattern against inputs of length 18 through 40 completes in
|
|
90
|
-
* under 40ms up to length 22, then 63ms, 302ms, and is interrupted at the
|
|
91
|
-
* 250ms bound from length 26 onward, rather than running to the multi-
|
|
92
|
-
* second and then multi-minute times the unbounded engine produces at
|
|
93
|
-
* length 26 and 40.
|
|
139
|
+
* No verdict was reached: every attempt was interrupted before the
|
|
140
|
+
* pattern finished, and the CPU it consumed never came near the budget,
|
|
141
|
+
* which is the signature of a thread that is not being scheduled rather
|
|
142
|
+
* than one that is spinning. Under M2-C-3 a check that cannot reach a
|
|
143
|
+
* verdict reports ERROR, never a verdict it did not earn, so this is
|
|
144
|
+
* deliberately NOT a subclass of `RegexBoundExceededError`: the two must
|
|
145
|
+
* not be confusable by a `catch` or an `instanceof`.
|
|
94
146
|
*/
|
|
95
|
-
export
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
catch {
|
|
103
|
-
throw new RegexBoundExceededError(`pattern ${compiled.source} did not complete within ${String(REGEX_EXEC_TIMEOUT_MS)}ms ` +
|
|
104
|
-
`against a value of length ${String(value.length)} (possible catastrophic backtracking)`);
|
|
147
|
+
export class RegexBudgetUndeterminedError extends Error {
|
|
148
|
+
}
|
|
149
|
+
/** Thread CPU time where the interpreter has it, process CPU otherwise. */
|
|
150
|
+
function cpuSample() {
|
|
151
|
+
const threadReader = process.threadCpuUsage;
|
|
152
|
+
if (typeof threadReader === "function") {
|
|
153
|
+
return threadReader.call(process);
|
|
105
154
|
}
|
|
106
|
-
|
|
107
|
-
|
|
155
|
+
return process.cpuUsage();
|
|
156
|
+
}
|
|
157
|
+
/** Milliseconds of CPU (user plus system) between two samples. */
|
|
158
|
+
function cpuMillisBetween(before, after) {
|
|
159
|
+
return (after.user - before.user + (after.system - before.system)) / 1000;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Execute `compiled.exec(value)` inside a v8 context, bounded by the CPU
|
|
163
|
+
* WORK it does rather than by the time it takes.
|
|
164
|
+
*
|
|
165
|
+
* One attempt runs the match with a wall-clock interrupt. Whatever
|
|
166
|
+
* happens, the CPU consumed by this thread during that call is added to a
|
|
167
|
+
* running total:
|
|
168
|
+
*
|
|
169
|
+
* - total CPU at or above the budget: the thread really did burn that
|
|
170
|
+
* much CPU on this one match, which no anchored non-backtracking
|
|
171
|
+
* pattern can do, so `RegexBoundExceededError` is thrown. A busy
|
|
172
|
+
* machine cannot cause this, because a descheduled thread accumulates
|
|
173
|
+
* no CPU.
|
|
174
|
+
* - the match finished: return its result, which is the only path that
|
|
175
|
+
* produces a match.
|
|
176
|
+
* - interrupted with the total still far below the budget: the machine
|
|
177
|
+
* was busy, not the pattern. Double the patience and try again.
|
|
178
|
+
*
|
|
179
|
+
* After `maxAttempts` interruptions with the CPU total still under
|
|
180
|
+
* budget, `RegexBudgetUndeterminedError` is thrown, naming the CPU
|
|
181
|
+
* consumed, the wall clock spent and the load average, so the record says
|
|
182
|
+
* what it observed instead of asserting something about the pattern.
|
|
183
|
+
*
|
|
184
|
+
* An error from `runInContext` that is NOT the timeout is also
|
|
185
|
+
* undetermined rather than a finding: the old code funnelled every throw
|
|
186
|
+
* into the catastrophic-backtracking message, which is the same
|
|
187
|
+
* substitution one cause over.
|
|
188
|
+
*/
|
|
189
|
+
export function boundedExec(compiled, value, bounds = {}) {
|
|
190
|
+
const cpuBudgetMs = bounds.cpuBudgetMs ?? REGEX_EXEC_CPU_BUDGET_MS;
|
|
191
|
+
const maxAttempts = bounds.maxAttempts ?? REGEX_EXEC_MAX_ATTEMPTS;
|
|
192
|
+
let patienceMs = bounds.wallBackstopMs ?? REGEX_EXEC_WALL_BACKSTOP_MS;
|
|
193
|
+
let cpuSpentMs = 0;
|
|
194
|
+
let wallSpentMs = 0;
|
|
195
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
196
|
+
Object.assign(regexSandbox, { __pattern: compiled, __value: value, __out: undefined });
|
|
197
|
+
const cpuBefore = cpuSample();
|
|
198
|
+
const wallBefore = Date.now();
|
|
199
|
+
let failure;
|
|
200
|
+
let interrupted = false;
|
|
201
|
+
try {
|
|
202
|
+
new Script("__out = __pattern.exec(__value);").runInContext(regexSandbox, {
|
|
203
|
+
timeout: patienceMs,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
failure = error;
|
|
208
|
+
interrupted = error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT";
|
|
209
|
+
}
|
|
210
|
+
cpuSpentMs += cpuMillisBetween(cpuBefore, cpuSample());
|
|
211
|
+
wallSpentMs += Date.now() - wallBefore;
|
|
212
|
+
if (cpuSpentMs >= cpuBudgetMs) {
|
|
213
|
+
throw new RegexBoundExceededError(`pattern ${compiled.source} consumed ${cpuSpentMs.toFixed(1)}ms of CPU time ` +
|
|
214
|
+
`(budget ${String(cpuBudgetMs)}ms) against a value of length ${String(value.length)} ` +
|
|
215
|
+
`over ${String(attempt)} attempt(s) spanning ${String(wallSpentMs)}ms of wall clock ` +
|
|
216
|
+
"(catastrophic backtracking)");
|
|
217
|
+
}
|
|
218
|
+
if (failure !== undefined && !interrupted) {
|
|
219
|
+
throw new RegexBudgetUndeterminedError(`pattern ${compiled.source} against a value of length ${String(value.length)} ` +
|
|
220
|
+
`failed to execute: ${String(failure.message ?? failure)}; no verdict ` +
|
|
221
|
+
"about the pattern was reached");
|
|
222
|
+
}
|
|
223
|
+
if (!interrupted) {
|
|
224
|
+
const out = regexSandbox.__out;
|
|
225
|
+
return out ?? null;
|
|
226
|
+
}
|
|
227
|
+
patienceMs *= 2;
|
|
228
|
+
}
|
|
229
|
+
throw new RegexBudgetUndeterminedError(`pattern ${compiled.source} against a value of length ${String(value.length)} was ` +
|
|
230
|
+
`interrupted on all ${String(maxAttempts)} attempts after ${String(wallSpentMs)}ms of wall ` +
|
|
231
|
+
`clock, having consumed only ${cpuSpentMs.toFixed(1)}ms of CPU time against a budget of ` +
|
|
232
|
+
`${String(cpuBudgetMs)}ms; load average ${loadavg()[0]?.toFixed(2) ?? "unknown"} on ` +
|
|
233
|
+
`${String(cpus().length)} cpu(s). No verdict about the pattern was reached: this record ` +
|
|
234
|
+
"reports that the machine was too busy to establish one, not that the pattern is dangerous");
|
|
108
235
|
}
|
|
109
236
|
/**
|
|
110
237
|
* A STATIC heuristic for the single most common catastrophic-backtracking
|
|
@@ -690,7 +817,15 @@ function emit(resultPath, fields) {
|
|
|
690
817
|
* functions through the computed-URL pattern and must not trigger a CLI
|
|
691
818
|
* run as a side effect of that import.
|
|
692
819
|
*/
|
|
693
|
-
|
|
820
|
+
// IDENTITY, NOT STRING EQUALITY (M4-P2 fix round, 2026-09-16). Measured:
|
|
821
|
+
// through a symlinked directory in the invocation path, or through a
|
|
822
|
+
// symlink to this file, argv[1] carries the caller's spelling while
|
|
823
|
+
// import.meta.url carries the canonical one, so the bare comparison is
|
|
824
|
+
// false and this gate silently does nothing and exits 0. That is a guard
|
|
825
|
+
// that cannot go red (T-008), and six sibling gates already compare by
|
|
826
|
+
// identity.
|
|
827
|
+
if (process.argv[1] !== undefined &&
|
|
828
|
+
pathsIdentifySameObject(fileURLToPath(import.meta.url), process.argv[1])) {
|
|
694
829
|
try {
|
|
695
830
|
process.exitCode = main(process.argv.slice(2));
|
|
696
831
|
}
|