@brainervirus/workit-core 8.11.0 → 8.12.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.
@@ -0,0 +1,747 @@
1
+ // Host resources for heavy checks (issue #282, slice 1): a machine-wide lease
2
+ // serializes heavy `workit check` runs across every agent and worktree, and on
3
+ // Linux with a delegated user systemd each heavy run gets its own
4
+ // `systemd-run --user --scope` with MemoryMax and CPUQuota, so parallel agents
5
+ // cannot push the desktop session into systemd-oomd.
6
+ //
7
+ // The lease is a set of slot files, `<state>/leases/slot-<n>.json`, created
8
+ // with O_EXCL: holding one is holding the lease. A slot is held while its
9
+ // workit process runs or, after workit is killed, while the check's process
10
+ // group or scope still has processes. It is stale, and reclaimed by the next
11
+ // acquirer under a short mkdir guard, once those are gone, after a reboot, or
12
+ // (where the holder cannot be identified) past its age bound.
13
+ //
14
+ // Settings: built-in defaults (derived from total RAM and CPUs), then
15
+ // `resources` in the matching workspaces.json entry (the machine owner's
16
+ // word), then `resources` in workit.checks.json, which can only tighten them.
17
+ // Only the CLI imports this module; hooks never do.
18
+ import { randomUUID } from "node:crypto";
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { tailLines, type CheckRun } from "../checks";
23
+
24
+ /** Named checks that take the lease when nothing is configured. */
25
+ export const DEFAULT_HEAVY_CHECKS = ["test", "build", "typecheck"];
26
+ const DEFAULT_SLOTS = 1;
27
+ const DEFAULT_WAIT_S = 600;
28
+ const DEFAULT_MEMORY_SHARE = 0.4;
29
+ const DEFAULT_CPU_SHARE = 0.7;
30
+ const MAX_SLOTS = 64;
31
+ /** A slot file nobody could parse is a writer mid-create; after this it is debris. */
32
+ const UNREADABLE_TTL_MS = 30_000;
33
+ /** A reclaim guard lives for microseconds; one older than this was abandoned. */
34
+ const GUARD_TTL_MS = 30_000;
35
+
36
+ export type ResourceSettings = {
37
+ /** How many heavy checks may run at once on this machine. */
38
+ slots: number;
39
+ /** Named checks that are heavy. */
40
+ heavy: string[];
41
+ /** systemd MemoryMax for a heavy check's scope, in bytes. */
42
+ memoryMax: string;
43
+ /** systemd CPUQuota for a heavy check's scope (percent of one CPU). */
44
+ cpuQuota: string;
45
+ /** How long a heavy check waits for the lease before giving up. */
46
+ waitMs: number;
47
+ /** False turns the systemd scope off (the lease still applies). */
48
+ scope: boolean;
49
+ };
50
+
51
+ export type HostShape = { totalMemBytes: number; cpus: number };
52
+
53
+ export const hostShape = (): HostShape => ({
54
+ totalMemBytes: os.totalmem(),
55
+ cpus: Math.max(1, os.availableParallelism?.() ?? os.cpus().length),
56
+ });
57
+
58
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
59
+ typeof value === "object" && value !== null && !Array.isArray(value);
60
+
61
+ const SIZE = /^(\d+(?:\.\d+)?)([KMGT]?)$/iu;
62
+ const PERCENT = /^(\d+(?:\.\d+)?)%$/u;
63
+ const UNITS: Record<string, number> = { "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
64
+
65
+ /** "40%" of total RAM, or a size ("8G", "8589934592"), as MemoryMax bytes. */
66
+ function memoryValue(value: unknown, host: HostShape): number | Error {
67
+ const text = typeof value === "number" ? String(value) : typeof value === "string" ? value : "";
68
+ const percent = PERCENT.exec(text.trim());
69
+ if (percent) {
70
+ const share = Number(percent[1]);
71
+ if (share <= 0 || share > 100) return new Error(`memoryMax ${text} must be 1-100%`);
72
+ return Math.floor((host.totalMemBytes * share) / 100);
73
+ }
74
+ const size = SIZE.exec(text.trim());
75
+ if (size && Number(size[1]) > 0)
76
+ return Math.floor(Number(size[1]) * UNITS[size[2].toUpperCase()]);
77
+ return new Error(`memoryMax must be a percent of RAM ("40%") or a size ("8G"), got ${text}`);
78
+ }
79
+
80
+ /** "70%" of the whole machine, or a CPU count (4 → 400), as CPUQuota percent of one CPU. */
81
+ function cpuValue(value: unknown, host: HostShape): number | Error {
82
+ if (typeof value === "number" && value > 0) return Math.round(value * 100);
83
+ const percent = typeof value === "string" ? PERCENT.exec(value.trim()) : null;
84
+ if (percent) {
85
+ const share = Number(percent[1]);
86
+ if (share <= 0 || share > 100) return new Error(`cpuQuota ${percent[0]} must be 1-100%`);
87
+ return Math.max(1, Math.round(host.cpus * share));
88
+ }
89
+ return new Error(
90
+ `cpuQuota must be a percent of the machine ("70%") or a CPU count, got ${String(value)}`,
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Where a `resources` block comes from. `machine` (the user's workspaces.json)
96
+ * sets anything. `repo` (a committed workit.checks.json) can only tighten: it
97
+ * adds heavy checks, lowers memoryMax and cpuQuota, and sets its own wait; it
98
+ * cannot raise the machine's slots or turn the scope off.
99
+ */
100
+ export type ResourceSource = { label: string; trust: "machine" | "repo"; value: unknown };
101
+
102
+ /**
103
+ * Merge `resources` blocks over the defaults, machine sources before repo
104
+ * ones. Lenient: an invalid or refused field is reported and the earlier
105
+ * value kept, so a typo never turns the lease off.
106
+ */
107
+ export function resolveResourceSettings(
108
+ sources: ResourceSource[],
109
+ host: HostShape = hostShape(),
110
+ ): { settings: ResourceSettings; warnings: string[] } {
111
+ let memory = Math.floor(host.totalMemBytes * DEFAULT_MEMORY_SHARE);
112
+ let cpu = Math.max(1, Math.round(host.cpus * DEFAULT_CPU_SHARE * 100));
113
+ const settings: ResourceSettings = {
114
+ slots: DEFAULT_SLOTS,
115
+ heavy: [...DEFAULT_HEAVY_CHECKS],
116
+ memoryMax: "",
117
+ cpuQuota: "",
118
+ waitMs: DEFAULT_WAIT_S * 1000,
119
+ scope: true,
120
+ };
121
+ const warnings: string[] = [];
122
+ const ordered = [
123
+ ...sources.filter((source) => source.trust === "machine"),
124
+ ...sources.filter((source) => source.trust === "repo"),
125
+ ];
126
+ for (const { label, trust, value } of ordered) {
127
+ if (value === undefined || value === null) continue;
128
+ if (!isRecord(value)) {
129
+ warnings.push(`${label}: resources must be an object`);
130
+ continue;
131
+ }
132
+ const repo = trust === "repo";
133
+ const warn = (field: string, message: string) =>
134
+ warnings.push(`${label}: ${field}: ${message}`);
135
+ const machineOnly = (field: string) =>
136
+ warn(field, "is machine-wide; set it in your workspaces.json, not in the repo");
137
+ if (value.slots !== undefined) {
138
+ const slots = value.slots;
139
+ if (repo) machineOnly("slots");
140
+ else if (
141
+ typeof slots === "number" &&
142
+ Number.isInteger(slots) &&
143
+ slots >= 1 &&
144
+ slots <= MAX_SLOTS
145
+ )
146
+ settings.slots = slots;
147
+ else warn("slots", `must be an integer 1-${MAX_SLOTS}`);
148
+ }
149
+ if (value.scope !== undefined) {
150
+ if (repo) machineOnly("scope");
151
+ else if (typeof value.scope === "boolean") settings.scope = value.scope;
152
+ else warn("scope", "must be true or false");
153
+ }
154
+ if (value.heavy !== undefined) {
155
+ const heavy = isRecord(value.heavy) ? Object.keys(value.heavy) : value.heavy;
156
+ if (!Array.isArray(heavy) || !heavy.every((item) => typeof item === "string"))
157
+ warn("heavy", "must be a list of check names");
158
+ // A repo adds heavy checks; it never makes the machine's heavy ones light.
159
+ else settings.heavy = repo ? [...new Set([...settings.heavy, ...heavy])] : heavy;
160
+ }
161
+ if (value.memoryMax !== undefined) {
162
+ const next = memoryValue(value.memoryMax, host);
163
+ if (next instanceof Error) warn("memoryMax", next.message);
164
+ else if (repo && next > memory) warn("memoryMax", "a repo can only lower it");
165
+ else memory = next;
166
+ }
167
+ if (value.cpuQuota !== undefined) {
168
+ const next = cpuValue(value.cpuQuota, host);
169
+ if (next instanceof Error) warn("cpuQuota", next.message);
170
+ else if (repo && next > cpu) warn("cpuQuota", "a repo can only lower it");
171
+ else cpu = next;
172
+ }
173
+ if (value.wait !== undefined) {
174
+ if (typeof value.wait === "number" && value.wait >= 0) settings.waitMs = value.wait * 1000;
175
+ else warn("wait", "must be a number of seconds");
176
+ }
177
+ }
178
+ settings.memoryMax = String(memory);
179
+ settings.cpuQuota = `${cpu}%`;
180
+ return { settings, warnings };
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // the machine lease
185
+
186
+ /** Workit's state dir for `env` (the logger's resolution, over an explicit env). */
187
+ export function stateDir(env: NodeJS.ProcessEnv, platform: NodeJS.Platform = process.platform) {
188
+ if (env.WORKFLOW_TOOLKIT_STATE) return env.WORKFLOW_TOOLKIT_STATE;
189
+ if (env.XDG_STATE_HOME) return path.join(env.XDG_STATE_HOME, "workit");
190
+ const home = env.HOME || os.homedir();
191
+ if (platform === "darwin") return path.join(home, "Library", "Application Support", "workit");
192
+ if (platform === "win32")
193
+ return path.join(env.LOCALAPPDATA ?? path.join(home, "AppData", "Local"), "workit");
194
+ return path.join(home, ".local", "state", "workit");
195
+ }
196
+
197
+ export const leaseDir = (env: NodeJS.ProcessEnv): string => path.join(stateDir(env), "leases");
198
+
199
+ export type LeaseRecord = {
200
+ id: string;
201
+ /** The workit process that took the lease. */
202
+ pid: number;
203
+ /** Field 22 of /proc/<pid>/stat, so a recycled pid does not keep a lease alive. */
204
+ processStart: string | null;
205
+ /** The kernel boot id (Linux). */
206
+ bootId: string | null;
207
+ /** Boot time in ms, where there is no boot id (macOS, Windows). */
208
+ bootTime?: number | null;
209
+ /** The check's process group: it outlives a SIGKILLed workit. */
210
+ pgid?: number | null;
211
+ /** Past this age a lease whose holder cannot be identified (no processStart) is stale. */
212
+ maxAgeMs?: number | null;
213
+ check: string;
214
+ cwd: string;
215
+ session: string | null;
216
+ startedAt: string;
217
+ /** The check's systemd scope unit; it holds the lease while it has processes. */
218
+ scope: string | null;
219
+ };
220
+
221
+ const readText = (file: string): string | null => {
222
+ try {
223
+ return fs.readFileSync(file, "utf8");
224
+ } catch {
225
+ return null;
226
+ }
227
+ };
228
+
229
+ /** The kernel boot id (Linux), so a lease from before a reboot is stale. */
230
+ export const bootId = (): string | null =>
231
+ readText("/proc/sys/kernel/random/boot_id")?.trim() || null;
232
+
233
+ /** When the machine booted (ms), for platforms without a boot id. */
234
+ const bootTime = (): number => Math.round((Date.now() - os.uptime() * 1000) / 1000) * 1000;
235
+ /** Clock steps and uptime rounding move the computed boot time; a reboot moves it far more. */
236
+ const BOOT_SLACK_MS = 120_000;
237
+
238
+ /** The fields of /proc/<pid>/stat after the comm, so a comm with spaces cannot shift them. */
239
+ const statFields = (pid: number): string[] | null => {
240
+ const stat = readText(`/proc/${pid}/stat`);
241
+ return stat ? stat.slice(stat.lastIndexOf(")") + 2).split(" ") : null;
242
+ };
243
+
244
+ /** Start time of `pid` (Linux). */
245
+ export const processStart = (pid: number): string | null => statFields(pid)?.[19] ?? null;
246
+
247
+ const pidAlive = (pid: number): boolean => {
248
+ try {
249
+ process.kill(pid, 0);
250
+ return true;
251
+ } catch (error) {
252
+ return (error as NodeJS.ErrnoException).code === "EPERM";
253
+ }
254
+ };
255
+
256
+ /** Does process group `pgid` still have members? (Windows: is the process alive.) */
257
+ const groupAlive = (pgid: number): boolean =>
258
+ process.platform === "win32" ? pidAlive(pgid) : pidAlive(-pgid);
259
+
260
+ const SCOPE_UNIT = /^[\w.@-]+\.scope$/u;
261
+
262
+ /** Does the user scope `unit` still hold processes? */
263
+ function scopeBusy(unit: string): boolean {
264
+ if (process.platform !== "linux" || !SCOPE_UNIT.test(unit)) return false;
265
+ const uid = process.getuid?.() ?? 0;
266
+ const manager = `/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service`;
267
+ return [`${manager}/app.slice/${unit}`, `${manager}/${unit}`].some(
268
+ (dir) => (readText(`${dir}/cgroup.procs`) ?? "").trim() !== "",
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Is `pid` this process or one of its ancestors? Walks the ppid chain on
274
+ * Linux; elsewhere there is no cheap chain, so it answers true.
275
+ */
276
+ function isAncestor(pid: number): boolean {
277
+ if (process.platform !== "linux") return true;
278
+ let current = process.pid;
279
+ for (let step = 0; step < 128 && current > 1; step += 1) {
280
+ if (current === pid) return true;
281
+ const ppid = Number(statFields(current)?.[1]);
282
+ if (!Number.isInteger(ppid) || ppid <= 0) return false;
283
+ current = ppid;
284
+ }
285
+ return current === pid;
286
+ }
287
+
288
+ const parseLease = (text: string | null): LeaseRecord | null => {
289
+ if (!text) return null;
290
+ try {
291
+ const value: unknown = JSON.parse(text);
292
+ return isRecord(value) && typeof value.id === "string" && typeof value.pid === "number"
293
+ ? (value as LeaseRecord)
294
+ : null;
295
+ } catch {
296
+ return null;
297
+ }
298
+ };
299
+
300
+ export type LeaseProbe = {
301
+ alive?: (pid: number) => boolean;
302
+ boot?: string | null;
303
+ bootTime?: number | null;
304
+ start?: (pid: number) => string | null;
305
+ group?: (pgid: number) => boolean;
306
+ scope?: (unit: string) => boolean;
307
+ now?: number;
308
+ };
309
+
310
+ /**
311
+ * Why `lease` no longer holds anything, or null while it does. It holds while
312
+ * its workit process runs, or, once that is gone (a SIGKILL), while the
313
+ * check's process group or scope still has processes. A lease from before a
314
+ * reboot, or one past its age bound whose holder cannot be identified, is
315
+ * stale either way.
316
+ */
317
+ export function staleReason(lease: LeaseRecord, probe: LeaseProbe = {}): string | null {
318
+ const boot = probe.boot === undefined ? bootId() : probe.boot;
319
+ if (lease.bootId && boot) {
320
+ if (lease.bootId !== boot) return "taken before a reboot";
321
+ } else if (typeof lease.bootTime === "number") {
322
+ const now = probe.bootTime === undefined ? bootTime() : probe.bootTime;
323
+ if (now !== null && Math.abs(now - lease.bootTime) > BOOT_SLACK_MS)
324
+ return "taken before a reboot";
325
+ }
326
+ const start = lease.processStart ? (probe.start ?? processStart)(lease.pid) : null;
327
+ const age = (probe.now ?? Date.now()) - Date.parse(lease.startedAt);
328
+ if (!start && typeof lease.maxAgeMs === "number" && age > lease.maxAgeMs)
329
+ return `older than its ${Math.round(lease.maxAgeMs / 1000)} s bound`;
330
+ const holder = !(probe.alive ?? pidAlive)(lease.pid)
331
+ ? `holder pid ${lease.pid} is gone`
332
+ : start && start !== lease.processStart
333
+ ? `pid ${lease.pid} now belongs to another process`
334
+ : null;
335
+ if (!holder) return null;
336
+ // workit is gone, but the check it started may still run.
337
+ if (typeof lease.pgid === "number" && lease.pgid > 0 && (probe.group ?? groupAlive)(lease.pgid))
338
+ return null;
339
+ if (lease.scope && (probe.scope ?? scopeBusy)(lease.scope)) return null;
340
+ return holder;
341
+ }
342
+
343
+ export type LeaseHolder = Pick<LeaseRecord, "pid" | "check" | "cwd" | "session" | "startedAt">;
344
+
345
+ const holderOf = (lease: LeaseRecord): LeaseHolder => ({
346
+ pid: lease.pid,
347
+ check: lease.check,
348
+ cwd: lease.cwd,
349
+ session: lease.session,
350
+ startedAt: lease.startedAt,
351
+ });
352
+
353
+ /** Remove a stale slot under a mkdir guard, re-checking it inside so a fresh holder is never removed. */
354
+ function reclaim(file: string, stale: (lease: LeaseRecord | null, ageMs: number) => boolean) {
355
+ const guard = `${file}.reclaim`;
356
+ try {
357
+ fs.mkdirSync(guard);
358
+ } catch {
359
+ try {
360
+ if (Date.now() - fs.statSync(guard).mtimeMs > GUARD_TTL_MS)
361
+ fs.rmSync(guard, { force: true, recursive: true });
362
+ } catch {
363
+ // Gone already.
364
+ }
365
+ return false;
366
+ }
367
+ try {
368
+ let ageMs = 0;
369
+ try {
370
+ ageMs = Date.now() - fs.statSync(file).mtimeMs;
371
+ } catch {
372
+ return true;
373
+ }
374
+ if (!stale(parseLease(readText(file)), ageMs)) return false;
375
+ fs.rmSync(file, { force: true });
376
+ return true;
377
+ } finally {
378
+ fs.rmSync(guard, { force: true, recursive: true });
379
+ }
380
+ }
381
+
382
+ export type HeldLease = {
383
+ record: LeaseRecord;
384
+ slot: number;
385
+ file: string;
386
+ /** Holders seen while waiting (empty when the lease was free). */
387
+ waitedOn: LeaseHolder[];
388
+ waitedMs: number;
389
+ /** Stale leases reclaimed on the way, with the reason. */
390
+ reclaimed: Array<LeaseHolder & { reason: string }>;
391
+ release: () => void;
392
+ };
393
+
394
+ export type AcquireOptions = {
395
+ dir: string;
396
+ slots: number;
397
+ check: string;
398
+ cwd: string;
399
+ session: string | null;
400
+ waitMs: number;
401
+ pollMs?: number;
402
+ /** The age bound recorded on the lease (see LeaseRecord.maxAgeMs). */
403
+ maxAgeMs?: number;
404
+ /** Called once, when the first attempt finds every slot held. */
405
+ onWait?: (holders: LeaseHolder[]) => void;
406
+ /** Overrides for tests. */
407
+ probe?: LeaseProbe;
408
+ pid?: number;
409
+ };
410
+
411
+ export type AcquireResult =
412
+ | { ok: true; lease: HeldLease }
413
+ | { ok: false; reason: "timeout"; holders: LeaseHolder[]; waitedMs: number }
414
+ | { ok: false; reason: "unavailable"; error: string };
415
+
416
+ /** One pass over the slots: the slot taken, or the live holders. */
417
+ function tryOnce(
418
+ options: AcquireOptions,
419
+ record: LeaseRecord,
420
+ reclaimed: HeldLease["reclaimed"],
421
+ ): { slot: number; file: string } | { holders: LeaseHolder[] } {
422
+ const holders: LeaseHolder[] = [];
423
+ const text = `${JSON.stringify(record)}\n`;
424
+ for (let slot = 0; slot < options.slots; slot += 1) {
425
+ const file = path.join(options.dir, `slot-${slot}.json`);
426
+ for (let attempt = 0; attempt < 2; attempt += 1) {
427
+ try {
428
+ const fd = fs.openSync(file, "wx", 0o600);
429
+ try {
430
+ fs.writeSync(fd, text);
431
+ } finally {
432
+ fs.closeSync(fd);
433
+ }
434
+ return { slot, file };
435
+ } catch (error) {
436
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
437
+ }
438
+ const lease = parseLease(readText(file));
439
+ let reason: string | null = null;
440
+ const removed = reclaim(file, (current, ageMs) => {
441
+ reason = current
442
+ ? staleReason(current, options.probe)
443
+ : ageMs > UNREADABLE_TTL_MS
444
+ ? "unreadable"
445
+ : null;
446
+ return reason !== null;
447
+ });
448
+ if (removed) {
449
+ if (lease && reason) reclaimed.push({ ...holderOf(lease), reason });
450
+ continue;
451
+ }
452
+ if (lease) holders.push(holderOf(lease));
453
+ break;
454
+ }
455
+ }
456
+ return { holders };
457
+ }
458
+
459
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
460
+
461
+ /** Remove `file` only while it still holds lease `id`: a reclaimed and retaken slot is someone else's. */
462
+ function removeOwn(file: string, id: string): void {
463
+ if (parseLease(readText(file))?.id === id) fs.rmSync(file, { force: true });
464
+ }
465
+
466
+ /**
467
+ * Take one of `slots` machine-wide lease slots, waiting (polling every
468
+ * `pollMs`) up to `waitMs` for a live holder to finish. The returned lease is
469
+ * released by `release()`, and on process exit as a backstop.
470
+ */
471
+ export async function acquireLease(options: AcquireOptions): Promise<AcquireResult> {
472
+ const pid = options.pid ?? process.pid;
473
+ const boot = bootId();
474
+ const record: LeaseRecord = {
475
+ id: randomUUID(),
476
+ pid,
477
+ processStart: processStart(pid),
478
+ bootId: boot,
479
+ bootTime: boot ? null : bootTime(),
480
+ pgid: null,
481
+ maxAgeMs: options.maxAgeMs ?? null,
482
+ check: options.check,
483
+ cwd: options.cwd,
484
+ session: options.session,
485
+ startedAt: new Date().toISOString(),
486
+ scope: null,
487
+ };
488
+ try {
489
+ fs.mkdirSync(options.dir, { recursive: true, mode: 0o700 });
490
+ } catch (error) {
491
+ return { ok: false, reason: "unavailable", error: (error as Error).message };
492
+ }
493
+ const started = Date.now();
494
+ const reclaimed: HeldLease["reclaimed"] = [];
495
+ let waitedOn: LeaseHolder[] = [];
496
+ for (;;) {
497
+ let outcome: ReturnType<typeof tryOnce>;
498
+ try {
499
+ outcome = tryOnce(options, record, reclaimed);
500
+ } catch (error) {
501
+ return { ok: false, reason: "unavailable", error: (error as Error).message };
502
+ }
503
+ if ("slot" in outcome) {
504
+ const { file, slot } = outcome;
505
+ let released = false;
506
+ const release = () => {
507
+ if (released) return;
508
+ released = true;
509
+ process.off("exit", release);
510
+ removeOwn(file, record.id);
511
+ };
512
+ process.on("exit", release);
513
+ return {
514
+ ok: true,
515
+ lease: {
516
+ record,
517
+ slot,
518
+ file,
519
+ waitedOn,
520
+ waitedMs: waitedOn.length ? Date.now() - started : 0,
521
+ reclaimed,
522
+ release,
523
+ },
524
+ };
525
+ }
526
+ const waited = Date.now() - started;
527
+ if (!waitedOn.length) {
528
+ waitedOn = outcome.holders;
529
+ options.onWait?.(outcome.holders);
530
+ }
531
+ if (waited >= options.waitMs)
532
+ return { ok: false, reason: "timeout", holders: outcome.holders, waitedMs: waited };
533
+ await sleep(Math.min(options.pollMs ?? 2000, Math.max(1, options.waitMs - waited)));
534
+ }
535
+ }
536
+
537
+ /**
538
+ * Record the check's scope unit or process group on a held lease, so the lease
539
+ * stays held while they run, even if workit itself is killed. Never writes
540
+ * over a slot that is no longer this lease's.
541
+ */
542
+ export function updateLease(lease: HeldLease, patch: Pick<Partial<LeaseRecord>, "scope" | "pgid">) {
543
+ Object.assign(lease.record, patch);
544
+ try {
545
+ if (parseLease(readText(lease.file))?.id !== lease.record.id) return;
546
+ const temp = `${lease.file}.${process.pid}.tmp`;
547
+ fs.writeFileSync(temp, `${JSON.stringify(lease.record)}\n`, { mode: 0o600 });
548
+ fs.renameSync(temp, lease.file);
549
+ } catch {
550
+ // The lease still holds; only the note is missing.
551
+ }
552
+ }
553
+
554
+ /**
555
+ * The lease an outer heavy check passed down as WORKIT_LEASE, when it is held
556
+ * in `dir` by this process or one of its ancestors. A nested heavy check
557
+ * reuses it instead of queueing behind its own parent; a leaked or forged id
558
+ * from an unrelated process is ignored.
559
+ */
560
+ export function heldLease(
561
+ dir: string,
562
+ id: string | undefined,
563
+ options: { probe?: LeaseProbe; ancestor?: (pid: number) => boolean } = {},
564
+ ): LeaseRecord | null {
565
+ if (!id || !/^[0-9a-f-]{36}$/u.test(id)) return null;
566
+ let names: string[];
567
+ try {
568
+ names = fs.readdirSync(dir);
569
+ } catch {
570
+ return null;
571
+ }
572
+ for (const name of names) {
573
+ if (!/^slot-\d+\.json$/u.test(name)) continue;
574
+ const lease = parseLease(readText(path.join(dir, name)));
575
+ if (
576
+ lease?.id === id &&
577
+ !staleReason(lease, options.probe) &&
578
+ (options.ancestor ?? isAncestor)(lease.pid)
579
+ )
580
+ return lease;
581
+ }
582
+ return null;
583
+ }
584
+ /** "test, pid 123, 1m 40s, /repo/wt" for a waiting or timeout message. */
585
+ export function describeHolder(holder: LeaseHolder, now = Date.now()): string {
586
+ const seconds = Math.max(0, Math.round((now - Date.parse(holder.startedAt)) / 1000));
587
+ const age = seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
588
+ return `${holder.check}, pid ${holder.pid}${holder.session ? `, session ${holder.session}` : ""}, ${age}, ${holder.cwd}`;
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // the systemd user scope
593
+
594
+ export type ScopeSupport = { ok: true } | { ok: false; reason: string };
595
+
596
+ const onPath = (command: string, env: NodeJS.ProcessEnv): boolean =>
597
+ (env.PATH ?? "")
598
+ .split(path.delimiter)
599
+ .filter(Boolean)
600
+ .some((dir) => {
601
+ try {
602
+ fs.accessSync(path.join(dir, command), fs.constants.X_OK);
603
+ return true;
604
+ } catch {
605
+ return false;
606
+ }
607
+ });
608
+
609
+ /**
610
+ * Can heavy checks run in their own `systemd-run --user --scope`? Needs Linux,
611
+ * `systemd-run` on PATH, a running user manager, and the memory and cpu
612
+ * controllers delegated to it (cgroup v2).
613
+ */
614
+ export function scopeSupport(
615
+ env: NodeJS.ProcessEnv,
616
+ platform: NodeJS.Platform = process.platform,
617
+ read: (file: string) => string | null = readText,
618
+ ): ScopeSupport {
619
+ if (platform !== "linux") return { ok: false, reason: `no systemd scopes on ${platform}` };
620
+ if (!onPath("systemd-run", env)) return { ok: false, reason: "systemd-run is not on PATH" };
621
+ const uid = process.getuid?.() ?? 0;
622
+ const runtime = env.XDG_RUNTIME_DIR || `/run/user/${uid}`;
623
+ if (!fs.existsSync(path.join(runtime, "systemd", "private")))
624
+ return { ok: false, reason: "no user systemd manager is running" };
625
+ const controllers = read(
626
+ `/sys/fs/cgroup/user.slice/user-${uid}.slice/user@${uid}.service/cgroup.controllers`,
627
+ );
628
+ if (controllers === null)
629
+ return { ok: false, reason: "cgroup v2 is not mounted for the user manager" };
630
+ const missing = ["memory", "cpu"].filter((name) => !controllers.split(/\s+/u).includes(name));
631
+ if (missing.length)
632
+ return { ok: false, reason: `the user manager is not delegated ${missing.join(" and ")}` };
633
+ return { ok: true };
634
+ }
635
+
636
+ /** The `systemd-run` prefix that runs a command in its own limited scope. */
637
+ export function scopePrefix(
638
+ unit: string,
639
+ settings: Pick<ResourceSettings, "memoryMax" | "cpuQuota">,
640
+ ) {
641
+ return [
642
+ "systemd-run",
643
+ "--user",
644
+ "--scope",
645
+ "--quiet",
646
+ "--collect",
647
+ `--unit=${unit}`,
648
+ "-p",
649
+ `MemoryMax=${settings.memoryMax}`,
650
+ "-p",
651
+ `CPUQuota=${settings.cpuQuota}`,
652
+ "--",
653
+ ];
654
+ }
655
+
656
+ /** systemd-run's own failure before it ran the command (bus, unit, property). */
657
+ const SCOPE_START_FAILURE =
658
+ /^Failed to (?:connect to bus|create bus connection|start transient scope unit|parse \S+?=)/u;
659
+
660
+ type Sinks = { onStdout: (text: string) => void; onStderr: (text: string) => void };
661
+
662
+ /**
663
+ * Run a command in its scope, and plainly when systemd-run itself failed
664
+ * before the command ran (one "Failed to …" line, non-zero exit). The scoped
665
+ * attempt's first stderr line is held back until it is known not to be that
666
+ * failure, so the caller never sees systemd-run's line on a fallback.
667
+ * `fallback` is that line when the command ran unscoped.
668
+ */
669
+ export async function runScoped(
670
+ run: (wrapper: readonly string[] | null, sinks: Sinks) => Promise<CheckRun>,
671
+ wrapper: readonly string[] | null,
672
+ sinks: Sinks,
673
+ ): Promise<{ result: CheckRun; fallback: string | null }> {
674
+ if (!wrapper) return { result: await run(null, sinks), fallback: null };
675
+ let state = "deciding" as "deciding" | "passing" | "held";
676
+ let firstLine = "";
677
+ const held: Array<[keyof Sinks, string]> = [];
678
+ const flush = () => {
679
+ state = "passing";
680
+ for (const [sink, text] of held.splice(0)) sinks[sink](text);
681
+ };
682
+ const result = await run(wrapper, {
683
+ onStdout: (text) => {
684
+ if (state === "passing") return sinks.onStdout(text);
685
+ held.push(["onStdout", text]);
686
+ // systemd-run writes only to stderr: stdout means the command runs.
687
+ if (state === "deciding") flush();
688
+ },
689
+ onStderr: (text) => {
690
+ if (state === "passing") return sinks.onStderr(text);
691
+ held.push(["onStderr", text]);
692
+ if (state === "held") return;
693
+ firstLine += text;
694
+ const newline = firstLine.indexOf("\n");
695
+ if (newline < 0 && firstLine.length < 4096) return;
696
+ if (SCOPE_START_FAILURE.test(firstLine.slice(0, newline < 0 ? undefined : newline)))
697
+ state = "held";
698
+ else flush();
699
+ },
700
+ });
701
+ const lines = tailLines(result.log, 2);
702
+ if (
703
+ state !== "passing" &&
704
+ result.exitCode !== 0 &&
705
+ lines.length === 1 &&
706
+ SCOPE_START_FAILURE.test(lines[0])
707
+ )
708
+ return { result: await run(null, sinks), fallback: lines[0] };
709
+ flush();
710
+ return { result, fallback: null };
711
+ }
712
+
713
+ /** Notice markers older than this are pruned. */
714
+ const NOTICE_TTL_MS = 7 * 86_400_000;
715
+
716
+ /**
717
+ * Should the "no scope" notice print? Once per session: a marker under the
718
+ * state dir records that this session was told. Markers older than a week
719
+ * are pruned when a new one is written.
720
+ */
721
+ export function firstNotice(
722
+ env: NodeJS.ProcessEnv,
723
+ session: string | null,
724
+ key: string,
725
+ now = Date.now(),
726
+ ): boolean {
727
+ const id = (session ?? `ppid-${process.ppid}`).replace(/[^A-Za-z0-9_.-]/gu, "_").slice(0, 120);
728
+ const dir = path.join(stateDir(env), "notices");
729
+ try {
730
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
731
+ fs.writeFileSync(path.join(dir, `${key}-${id}`), `${new Date(now).toISOString()}\n`, {
732
+ flag: "wx",
733
+ mode: 0o600,
734
+ });
735
+ } catch (error) {
736
+ return (error as NodeJS.ErrnoException).code !== "EEXIST";
737
+ }
738
+ try {
739
+ for (const name of fs.readdirSync(dir)) {
740
+ const file = path.join(dir, name);
741
+ if (now - fs.statSync(file).mtimeMs > NOTICE_TTL_MS) fs.rmSync(file, { force: true });
742
+ }
743
+ } catch {
744
+ // Pruning is best effort.
745
+ }
746
+ return true;
747
+ }