tickmarkr 2.7.4 → 2.7.5
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/dist/cli/commands/verify.js +4 -4
- package/dist/run/lease.d.ts +19 -2
- package/dist/run/lease.js +56 -11
- package/dist/run/suite-census.d.ts +3 -0
- package/dist/run/suite-census.js +37 -28
- package/dist/run/verification-job.d.ts +4 -2
- package/dist/run/verification-job.js +1 -1
- package/package.json +1 -1
|
@@ -15,7 +15,7 @@ import { GATE_NAMES } from "../../graph/schema.js";
|
|
|
15
15
|
import { executionSignal } from "../../run/execution-budget.js";
|
|
16
16
|
import { addDetachedWorktree, linkNodeModules, removeWorktree, shGit, shGitOk } from "../../run/git.js";
|
|
17
17
|
import { foldOwedChecks, integrationMapped, owedCriteria, Journal, OWED_DISCHARGE_EVENT } from "../../run/journal.js";
|
|
18
|
-
import { withRepositoryLease } from "../../run/lease.js";
|
|
18
|
+
import { describeLeaseWait, withRepositoryLease } from "../../run/lease.js";
|
|
19
19
|
import { isPidLive } from "../../run/lock.js";
|
|
20
20
|
import { recordLaunchServices } from "../../run/launchservices-check.js";
|
|
21
21
|
/**
|
|
@@ -529,9 +529,9 @@ export async function verify(argv, cwd = process.cwd(), options = {}) {
|
|
|
529
529
|
return after ? rows.map((r) => ({ ...r, pass: false, details: `${refused(after)}\n${r.details}`, meta: { ...r.meta, subjectChanged: after } })) : rows;
|
|
530
530
|
}, {
|
|
531
531
|
signal: executionSignal(),
|
|
532
|
-
onWait: (holder) => {
|
|
533
|
-
console.error(`verify: waiting for the repository's runner lease
|
|
534
|
-
recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd });
|
|
532
|
+
onWait: (holder, blockers) => {
|
|
533
|
+
console.error(`verify: waiting for the repository's runner lease ${describeLeaseWait(holder, blockers)}`);
|
|
534
|
+
recordJournal?.append("suite-wait", values.task ?? "VERIFY", { holderPid: holder.pid, holderCwd: holder.cwd, ...(blockers ? { blockers } : {}) });
|
|
535
535
|
},
|
|
536
536
|
});
|
|
537
537
|
results = [...results, ...suite].sort((a, b) => GATE_NAMES.indexOf(a.gate) - GATE_NAMES.indexOf(b.gate));
|
package/dist/run/lease.d.ts
CHANGED
|
@@ -35,11 +35,19 @@ export interface RepositoryLeaseHolder {
|
|
|
35
35
|
roots?: number[];
|
|
36
36
|
rootBirths?: Record<string, string>;
|
|
37
37
|
}
|
|
38
|
+
/** Queue row 120: a live process keeping a dead or released holder's lease, as the wait names it: pid, executable and
|
|
39
|
+
* cwd. Its arguments and environment are never printed or read (`ps -o comm=`, the census's cwd-only probe). */
|
|
40
|
+
export interface LeaseBlocker {
|
|
41
|
+
pid: number;
|
|
42
|
+
executable: string;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
}
|
|
38
45
|
export interface RepositoryLeaseOptions {
|
|
39
46
|
pollMs?: number;
|
|
40
47
|
signal?: AbortSignal;
|
|
41
|
-
/** Called once per distinct holder the waiter is queued behind
|
|
42
|
-
|
|
48
|
+
/** Called once per distinct holder the waiter is queued behind, and again whenever the set of processes keeping a
|
|
49
|
+
* dead holder's lease changes; `blockers` names that set (queue row 120). */
|
|
50
|
+
onWait?: (holder: RepositoryLeaseHolder, blockers?: readonly LeaseBlocker[]) => void;
|
|
43
51
|
/** The token a descendant inherited; defaults to REPOSITORY_LEASE_TOKEN_ENV. */
|
|
44
52
|
inherited?: string;
|
|
45
53
|
/** Manifest jobs bridge capabilities through explicit child environments, never process.env. */
|
|
@@ -82,6 +90,15 @@ export declare const readHolder: (path: string) => RepositoryLeaseHolder | undef
|
|
|
82
90
|
export declare function tryReserve(path: string, mine: RepositoryLeaseHolder): boolean;
|
|
83
91
|
/** Exposed for recovery regressions; the standalone verify uses the combined critical section. */
|
|
84
92
|
export declare const reclaimDead: (path: string, identity: string, pollMs?: number) => Promise<boolean>;
|
|
93
|
+
/** Each blocker's pid, executable and working directory, and NO argument: `ps -o comm=` (a command line joins argv with
|
|
94
|
+
* spaces, so no redaction could tell where a secret value ends, D-1710). The cwd comes from the census's cwd-only probe
|
|
95
|
+
* (async, batched, never rejecting, no environment read; queue row 103: no synchronous lsof on a waiting loop), imported
|
|
96
|
+
* lazily because suite-census imports this module. Diagnostics only: an unreadable process reads "(executable
|
|
97
|
+
* unreadable)", and this never throws into the wait. */
|
|
98
|
+
export declare function describeLeaseBlockers(pids: readonly number[]): Promise<LeaseBlocker[]>;
|
|
99
|
+
/** The wait's own words: the holder alone while it lives; once it has exited or released the lease, every process still
|
|
100
|
+
* keeping it. */
|
|
101
|
+
export declare function describeLeaseWait(holder: RepositoryLeaseHolder, blockers?: readonly LeaseBlocker[]): string;
|
|
85
102
|
export declare function protectedProcesses(holder: RepositoryLeaseHolder, tokenEnv?: string, ownedOnly?: boolean): Promise<number[]>;
|
|
86
103
|
export declare const repositoryLeaseEnvironment: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
|
|
87
104
|
/** Called synchronously at spawn, before any user callback, and only on an owned generation. */
|
package/dist/run/lease.js
CHANGED
|
@@ -305,14 +305,47 @@ const reclaimDeadLocked = (path, identity) => {
|
|
|
305
305
|
export const reclaimDead = async (path, identity, pollMs = 10) => withMutationLock(path, pollMs, undefined, () => reclaimDeadLocked(path, identity));
|
|
306
306
|
const inspectAndReserve = async (path, mine) => {
|
|
307
307
|
let occupant = readOccupant(path);
|
|
308
|
-
|
|
308
|
+
const dead = occupant?.holder !== undefined && !holderAlive(occupant.holder);
|
|
309
|
+
// A dead holder's protected tree is what keeps the lease; the waiter is told which processes those are (queue row 120).
|
|
310
|
+
const blocking = dead && occupant.holder.protectOrphans ? await protectedProcesses(occupant.holder) : [];
|
|
311
|
+
if (occupant && (!occupant.holder || (dead && !blocking.length))) {
|
|
309
312
|
reclaimDeadLocked(path, occupant.identity);
|
|
310
313
|
occupant = readOccupant(path);
|
|
311
314
|
}
|
|
312
315
|
if (!occupant && tryReserve(path, mine))
|
|
313
316
|
return { acquired: true };
|
|
314
|
-
return { acquired: false, holder: occupant?.holder };
|
|
317
|
+
return { acquired: false, holder: occupant?.holder, ...(blocking.length ? { blocking } : {}) };
|
|
315
318
|
};
|
|
319
|
+
/** Each blocker's pid, executable and working directory, and NO argument: `ps -o comm=` (a command line joins argv with
|
|
320
|
+
* spaces, so no redaction could tell where a secret value ends, D-1710). The cwd comes from the census's cwd-only probe
|
|
321
|
+
* (async, batched, never rejecting, no environment read; queue row 103: no synchronous lsof on a waiting loop), imported
|
|
322
|
+
* lazily because suite-census imports this module. Diagnostics only: an unreadable process reads "(executable
|
|
323
|
+
* unreadable)", and this never throws into the wait. */
|
|
324
|
+
export async function describeLeaseBlockers(pids) {
|
|
325
|
+
const rows = await new Promise((done) => {
|
|
326
|
+
try {
|
|
327
|
+
execFile("ps", ["-o", "pid=,comm=", "-p", pids.join(",")], { encoding: "utf8", timeout: 5_000 }, (_error, stdout) => done(stdout ?? ""));
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
done("");
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
const executables = new Map(rows.split("\n").flatMap((line) => { const row = /^\s*(\d+)\s+(.*)$/.exec(line); return row ? [[Number(row[1]), row[2].trim()]] : []; }));
|
|
334
|
+
const cwds = await import("./suite-census.js").then(({ batchedProcessCwds }) => batchedProcessCwds(pids)).catch(() => undefined);
|
|
335
|
+
return pids.map((pid) => {
|
|
336
|
+
const cwd = cwds?.get(pid);
|
|
337
|
+
return { pid, executable: executables.get(pid) || "(executable unreadable)", ...(cwd ? { cwd } : {}) };
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/** The wait's own words: the holder alone while it lives; once it has exited or released the lease, every process still
|
|
341
|
+
* keeping it. */
|
|
342
|
+
export function describeLeaseWait(holder, blockers) {
|
|
343
|
+
const held = `held by pid ${holder.pid} in ${holder.cwd}`;
|
|
344
|
+
if (!blockers?.length)
|
|
345
|
+
return held;
|
|
346
|
+
const named = blockers.map((b) => `pid ${b.pid} ${b.executable}${b.cwd ? ` (cwd ${b.cwd})` : ""}`).join("; ");
|
|
347
|
+
return `${held}, which has exited or released it; ${blockers.length} process(es) it started still run and keep the lease until they exit: ${named}`;
|
|
348
|
+
}
|
|
316
349
|
// A durable capability is also a crash owner: census checks the exact generation's inherited
|
|
317
350
|
// environment and recorded process groups, including live orphans reparented after owner death.
|
|
318
351
|
// An unreadable census refuses reclamation/release. No dead-pid shortcut admits over a live tree.
|
|
@@ -386,6 +419,8 @@ export function registerRepositoryChild(pid) {
|
|
|
386
419
|
}
|
|
387
420
|
export const withFreshCommandLease = (command, run) => ownership.run({ token: "", active: false }, () => withCommandLease(command, run));
|
|
388
421
|
const repositoryQueues = new Map();
|
|
422
|
+
/** Queue row 120: the queue head's last view of what keeps a dead holder's lease, so in-process followers name it too. */
|
|
423
|
+
const repositoryBlocking = new Map();
|
|
389
424
|
/** The standalone wrapper uses the same command-then-repository order when a scheduler exists.
|
|
390
425
|
* Without one (CLI/global setup), this remains exactly the outer file reservation API. */
|
|
391
426
|
export function withRepositoryLease(cwd, run, opts = {}) {
|
|
@@ -426,10 +461,11 @@ async function repositoryLease(cwd, run, opts) {
|
|
|
426
461
|
const ticket = Symbol("repository job");
|
|
427
462
|
queue.add(ticket);
|
|
428
463
|
let waitingOn;
|
|
429
|
-
const waitOn = (holder) => {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
464
|
+
const waitOn = async (holder, blocking = []) => {
|
|
465
|
+
const key = holder && `${holder.token}\0${[...blocking].sort((a, b) => a - b).join(",")}`;
|
|
466
|
+
if (holder && key !== waitingOn) {
|
|
467
|
+
waitingOn = key;
|
|
468
|
+
opts.onWait?.(holder, blocking.length ? await describeLeaseBlockers(blocking) : undefined);
|
|
433
469
|
}
|
|
434
470
|
};
|
|
435
471
|
try {
|
|
@@ -437,12 +473,19 @@ async function repositoryLease(cwd, run, opts) {
|
|
|
437
473
|
opts.signal?.throwIfAborted();
|
|
438
474
|
if (queue.values().next().value === ticket) {
|
|
439
475
|
const attempt = await withMutationLock(path, pollMs, opts.signal, () => inspectAndReserve(path, mine));
|
|
440
|
-
if (attempt.acquired)
|
|
476
|
+
if (attempt.acquired) {
|
|
477
|
+
repositoryBlocking.delete(path);
|
|
441
478
|
break;
|
|
442
|
-
|
|
479
|
+
}
|
|
480
|
+
if (attempt.holder)
|
|
481
|
+
repositoryBlocking.set(path, { token: attempt.holder.token, blocking: attempt.blocking ?? [] });
|
|
482
|
+
await waitOn(attempt.holder, attempt.blocking);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
const holder = readHolder(path);
|
|
486
|
+
const seen = repositoryBlocking.get(path);
|
|
487
|
+
await waitOn(holder, holder && seen?.token === holder.token ? seen.blocking : []);
|
|
443
488
|
}
|
|
444
|
-
else
|
|
445
|
-
waitOn(readHolder(path));
|
|
446
489
|
await new Promise(wake => setTimeout(wake, pollMs));
|
|
447
490
|
}
|
|
448
491
|
const exported = process.env[REPOSITORY_LEASE_TOKEN_ENV];
|
|
@@ -495,8 +538,10 @@ async function repositoryLease(cwd, run, opts) {
|
|
|
495
538
|
}
|
|
496
539
|
finally {
|
|
497
540
|
queue.delete(ticket);
|
|
498
|
-
if (!queue.size)
|
|
541
|
+
if (!queue.size) {
|
|
499
542
|
repositoryQueues.delete(path);
|
|
543
|
+
repositoryBlocking.delete(path);
|
|
544
|
+
}
|
|
500
545
|
// A waiter removes no repository generation, including cancellation before acquisition.
|
|
501
546
|
}
|
|
502
547
|
}
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* not already probed — the census asks for a parent's cwd too. The answers feed countLiveSuites' existing lookups.
|
|
7
7
|
*/
|
|
8
8
|
export type ProbeExec = (file: string, args: readonly string[]) => Promise<string>;
|
|
9
|
+
/** Each pid's working directory: /proc first, then ONE batched lsof for the rest; never rejects, and reads no process
|
|
10
|
+
* environment (the runner-lease wait names blockers through it, queue row 120). Answers land in `cwds`. */
|
|
11
|
+
export declare function batchedProcessCwds(pids: readonly number[], exec?: ProbeExec, proc?: ProcReader, cwds?: Map<number, string>): Promise<Map<number, string>>;
|
|
9
12
|
/** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
|
|
10
13
|
export declare function runnerPids(snapshot: string): number[];
|
|
11
14
|
/** The /proc reads, injectable so the Linux branch is testable anywhere; each throws where /proc has no entry. */
|
package/dist/run/suite-census.js
CHANGED
|
@@ -16,6 +16,42 @@ const probeExec = (file, args) => new Promise((resolve) => {
|
|
|
16
16
|
resolve("");
|
|
17
17
|
}
|
|
18
18
|
});
|
|
19
|
+
// D-1567 batch poisoning: lsof and ps fail the WHOLE command for one -p member they cannot take (a marker of 22 digits
|
|
20
|
+
// prints as 1e+21, 310 nines as Infinity; ps refuses one above int32), erasing every other pid's answer. The base probed
|
|
21
|
+
// per pid, so such a pid lost only its own answer: it stays unanswered here and never joins a batch.
|
|
22
|
+
const probeable = (pid) => Number.isSafeInteger(pid) && pid > 0 && pid <= 2_147_483_647;
|
|
23
|
+
/** Each pid's working directory: /proc first, then ONE batched lsof for the rest; never rejects, and reads no process
|
|
24
|
+
* environment (the runner-lease wait names blockers through it, queue row 120). Answers land in `cwds`. */
|
|
25
|
+
export async function batchedProcessCwds(pids, exec = probeExec, proc = procReader, cwds = new Map()) {
|
|
26
|
+
const rest = [];
|
|
27
|
+
for (const pid of pids.filter(probeable)) {
|
|
28
|
+
try {
|
|
29
|
+
cwds.set(pid, proc.cwd(pid));
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
rest.push(pid);
|
|
33
|
+
} // Darwin has no /proc
|
|
34
|
+
}
|
|
35
|
+
if (rest.length === 0)
|
|
36
|
+
return cwds;
|
|
37
|
+
let listing = "";
|
|
38
|
+
try {
|
|
39
|
+
listing = await exec("lsof", ["-a", "-p", rest.join(","), "-d", "cwd", "-Fn"]);
|
|
40
|
+
}
|
|
41
|
+
catch { /* the census never rejects */ }
|
|
42
|
+
let pid;
|
|
43
|
+
for (const line of listing.split("\n")) {
|
|
44
|
+
if (line.startsWith("p"))
|
|
45
|
+
pid = Number(line.slice(1));
|
|
46
|
+
else if (line.startsWith("n") && pid !== undefined) {
|
|
47
|
+
try {
|
|
48
|
+
cwds.set(pid, realpathSync(line.slice(1)));
|
|
49
|
+
}
|
|
50
|
+
catch { /* the directory is gone */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return cwds;
|
|
54
|
+
}
|
|
19
55
|
/** The pids of every live (non-zombie) row of a `ps -o pid=,ppid=,state=,command=` snapshot whose command is a runner. */
|
|
20
56
|
export function runnerPids(snapshot) {
|
|
21
57
|
const pids = [];
|
|
@@ -41,34 +77,7 @@ export async function batchedProcessProbes(pids, exec = probeExec, proc = procRe
|
|
|
41
77
|
const cwds = new Map();
|
|
42
78
|
const parents = new Map();
|
|
43
79
|
const parentIn = (text) => new RegExp(`(?:^|\\s)${SUITE_PARENT_ENV}=(\\d+)(?:\\s|$)`).exec(text)?.[1];
|
|
44
|
-
|
|
45
|
-
// prints as 1e+21, 310 nines as Infinity; ps refuses one above int32), erasing every other pid's answer. The base probed
|
|
46
|
-
// per pid, so such a pid lost only its own answer: it stays unanswered here and never joins a batch.
|
|
47
|
-
const probeable = (pid) => Number.isSafeInteger(pid) && pid > 0 && pid <= 2_147_483_647;
|
|
48
|
-
const probeCwds = async (wanted) => {
|
|
49
|
-
const rest = [];
|
|
50
|
-
for (const pid of wanted.filter(probeable)) {
|
|
51
|
-
try {
|
|
52
|
-
cwds.set(pid, proc.cwd(pid));
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
rest.push(pid);
|
|
56
|
-
} // Darwin has no /proc
|
|
57
|
-
}
|
|
58
|
-
if (rest.length === 0)
|
|
59
|
-
return;
|
|
60
|
-
let pid;
|
|
61
|
-
for (const line of (await ask("lsof", ["-a", "-p", rest.join(","), "-d", "cwd", "-Fn"])).split("\n")) {
|
|
62
|
-
if (line.startsWith("p"))
|
|
63
|
-
pid = Number(line.slice(1));
|
|
64
|
-
else if (line.startsWith("n") && pid !== undefined) {
|
|
65
|
-
try {
|
|
66
|
-
cwds.set(pid, realpathSync(line.slice(1)));
|
|
67
|
-
}
|
|
68
|
-
catch { /* the directory is gone */ }
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
};
|
|
80
|
+
const probeCwds = (wanted) => batchedProcessCwds(wanted, ask, proc, cwds);
|
|
72
81
|
const probeParents = async (wanted) => {
|
|
73
82
|
const rest = [];
|
|
74
83
|
for (const pid of wanted.filter(probeable)) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ShellReceipt } from "./protocol.js";
|
|
2
2
|
import type { GateEvidenceOptions } from "../gates/baseline.js";
|
|
3
|
-
import { type RepositoryLeaseHolder } from "./lease.js";
|
|
3
|
+
import { type LeaseBlocker, type RepositoryLeaseHolder } from "./lease.js";
|
|
4
4
|
export interface VerificationPhase {
|
|
5
5
|
phase: "discovery" | "first-pass" | "continuation-discovery" | "continuation";
|
|
6
6
|
nonce: string;
|
|
@@ -33,7 +33,9 @@ export interface VerificationJobReport {
|
|
|
33
33
|
releasedAt?: number;
|
|
34
34
|
reservationReused?: boolean;
|
|
35
35
|
reservation?: RepositoryLeaseHolder;
|
|
36
|
-
waitingOn?: RepositoryLeaseHolder
|
|
36
|
+
waitingOn?: RepositoryLeaseHolder & {
|
|
37
|
+
blockers?: readonly LeaseBlocker[];
|
|
38
|
+
};
|
|
37
39
|
state: "queued" | "running" | "completed" | "failed" | "cancelled";
|
|
38
40
|
phases: VerificationPhase[];
|
|
39
41
|
}
|
|
@@ -132,7 +132,7 @@ export async function withVerificationJob(command, cwd, reportPath, evidence, ru
|
|
|
132
132
|
}
|
|
133
133
|
}));
|
|
134
134
|
}, { isolated: true, protectOrphans: true, independent: !!jobs.getStore(), signal, pollMs: 20,
|
|
135
|
-
onWait: holder => { report.waitingOn = { ...holder }; job.persist(); },
|
|
135
|
+
onWait: (holder, blockers) => { report.waitingOn = { ...holder, ...(blockers ? { blockers } : {}) }; job.persist(); },
|
|
136
136
|
onAdmission: holder => { owner = holder; report.repositoryAdmittedAt = clock(); report.reservation = { ...holder }; job.persist(); },
|
|
137
137
|
onRelease: () => { report.reapedAt ??= clock(); report.repositoryReleasedAt = clock(); job.persist(); } }));
|
|
138
138
|
report.releasedAt = clock();
|