@rightkit/release 0.6.7 → 0.6.10
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/README.md +35 -10
- package/admission-files.mjs +1 -1
- package/app-check.mjs +94 -20
- package/assemble-app-command.mjs +151 -0
- package/assembled-app.mjs +445 -0
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/darwin/rightkit-swap.sign.json +6 -0
- package/bin/rightkit-gate.mjs +957 -83
- package/build-launch.mjs +88 -0
- package/build-ownership.mjs +1231 -0
- package/build-release.mjs +2 -1
- package/cargo-contract.mjs +3 -1
- package/cargo-target-shim.mjs +166 -0
- package/class-a-launch-guard.mjs +126 -0
- package/cli/right-release.mjs +3 -1
- package/codesign-shim.mjs +11 -1
- package/deterministic-scans.mjs +807 -56
- package/development-signing.mjs +4 -1
- package/development.mjs +110 -20
- package/door-policy.mjs +61 -6
- package/door.mjs +23 -0
- package/gate-executor.mjs +14 -2
- package/gate-stages.mjs +15 -5
- package/heavy-command.mjs +3 -2
- package/install-app.mjs +11 -2
- package/install-assembled-app.mjs +423 -0
- package/ios-dev.mjs +70 -3
- package/linux-artifacts.mjs +93 -0
- package/local-publish.mjs +161 -26
- package/managed-cargo.mjs +47 -5
- package/managed-root-guard.mjs +34 -0
- package/native-helpers.mjs +355 -0
- package/package-recipe-targets.mjs +52 -7
- package/package.json +5 -3
- package/pipeline-receipts.mjs +302 -302
- package/project-type.mjs +2 -2
- package/provenance-verify.mjs +115 -0
- package/publish-cargo.mjs +8 -6
- package/qa-admission-policy.json +10 -1
- package/recipe-build.mjs +73 -5
- package/release-config.mjs +1 -1
- package/release.mjs +15 -5
- package/rightkit-versions.json +13 -7
- package/snapshot-check.mjs +306 -0
- package/snapshot-git.mjs +166 -0
- package/snapshot-request.mjs +161 -0
- package/snapshot-root-config.mjs +76 -0
- package/snapshot.mjs +856 -0
- package/stage-commands.mjs +60 -0
- package/stage-sidecar.mjs +57 -7
- package/test-command.mjs +17 -4
- package/unit-gate.mjs +259 -0
- package/web-build.mjs +38 -6
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
// Host-wide build ownership (docs/GATE-CONTRACT.md §1 "Commit-only build", "Broker lease"; joint plan step 4, G1).
|
|
2
|
+
//
|
|
3
|
+
// The gate acquires the host's build ownership BEFORE it syncs a snapshot and holds it until the stage completes, so no other
|
|
4
|
+
// request can replace a snapshot while anything reads it. One owner per host, at ONE fixed location per host (`ownershipFile`:
|
|
5
|
+
// `<managed root>/build-ownership/owner.json` on Mac & Dell, `~/.local/state/rightkit/build-ownership/owner.json` on Linux), never
|
|
6
|
+
// derived from the snapshot root or from an environment variable: builds are one per host, and the snapshot's own sync lock only
|
|
7
|
+
// covers the sync itself. The directory is mode 0700 and refused when group- or other-accessible (`ensureOwnershipDir`).
|
|
8
|
+
//
|
|
9
|
+
// The owner record is written whole (create-exclusive) and names a fresh handle (the `ownership.handle` a broker request carries),
|
|
10
|
+
// the gate's pid & start identity, the stage, snapshot & commit, how the gate's children are contained, and whether a broker
|
|
11
|
+
// request may have been submitted under this handle. The handle exists before any sync or submit.
|
|
12
|
+
//
|
|
13
|
+
// A dead gate PID is NOT enough to take ownership over: compilers and Class A children can outlive a crashed gate and still read
|
|
14
|
+
// the snapshot. A record is reclaimed only on positive proof that everything it covers is gone:
|
|
15
|
+
// - the owner process is gone (not running, or its pid now runs a process with another start identity), and
|
|
16
|
+
// - its covered children are gone:
|
|
17
|
+
// Windows: the gate runs inside its own named Job Object (every descendant joins it, detached ones and those Node nests in
|
|
18
|
+
// its own job included; measured on the Dell, D:\scratch\rk-step4\dell-jobexp.log). A holder process outside the gate's
|
|
19
|
+
// jobs keeps the job's only handle and records `drained` once the job has no active process. Proof: the job opens with 0
|
|
20
|
+
// active, or it is gone and its holder recorded `drained` (or `failed` before the gate ever ran inside it). A gate whose
|
|
21
|
+
// launcher's job lets children leave silently (a Node parent) cannot hold its children in the job; a self-check at
|
|
22
|
+
// acquisition records that (`contained: false`), and such a gate's crash is never cleared except by a reboot.
|
|
23
|
+
// POSIX, four nets, all of which must be clear: (1) the gate's process group, counting members started after the
|
|
24
|
+
// ownership whose ancestry leads to the owner, to an orphan (re-parented to pid 1) or to ANY other process that predates
|
|
25
|
+
// the ownership (an orphan adopted by a subreaper such as systemd --user or WSL's init reads that way; only the owner's
|
|
26
|
+
// recorded parent, or a process that shares the member's group, is a peer that is not the gate's child); (2) no process
|
|
27
|
+
// whose environment carries the handle (RIGHTKIT_BUILD_OWNERSHIP=<handle>), which catches a descendant that started its
|
|
28
|
+
// own session (Linux /proc/<pid>/environ; macOS `ps eww`, which hides the environment of Apple platform binaries only);
|
|
29
|
+
// (3) no process that uses the snapshot directory in any way that survives a new session, a scrubbed environment and a
|
|
30
|
+
// working directory elsewhere: its working or root directory, executable, an open file descriptor or a mapped file
|
|
31
|
+
// inside it (Linux /proc/<pid>/{cwd,root,exe,fd,maps}; macOS `lsof`, platform binaries included), or an argument naming
|
|
32
|
+
// it (/proc/<pid>/cmdline; macOS `ps -ww`); (4) every process group a gate under this ownership started detached
|
|
33
|
+
// (spawnUnderOwnership records it before the child runs; class A scans, right-release builds and heavy-command start
|
|
34
|
+
// their children that way) is empty, and no such spawn was left half-recorded.
|
|
35
|
+
// Unknown is held: on Linux a /proc entry of this user's that cannot be read (EACCES/EPERM, e.g. a non-dumpable process)
|
|
36
|
+
// never counts as "not a reader" by default. It is skipped only when it vanished (ENOENT/ESRCH), belongs to another user
|
|
37
|
+
// (/proc/<pid>/status, world-readable), or the INVARIANT below holds: such a process is out of this ownership's reach only
|
|
38
|
+
// when it started before this ownership's baseline (/proc/<pid>/stat field 22 against the boot time, less START_SLACK_MS
|
|
39
|
+
// of reading error): the later of the acquisition of the previous owner whose release proved zero readers (a durable marker,
|
|
40
|
+
// `last-clean-release.json` beside the record, written only by a clean release; a record carries it as `cleanBefore`) and
|
|
41
|
+
// the snapshot root's birth in this boot, capped at acquisition; with neither, this record's own acquisition. A process
|
|
42
|
+
// that predates THIS ownership but started after the previous acquisition may be the previous owner's leftover, so it
|
|
43
|
+
// stays held when a baseline from a clean release or a birth exists (it is only dropped when it predates that baseline). An
|
|
44
|
+
// unreadable stat: the process is held. Residual on POSIX, stated plainly: a descendant that started a
|
|
45
|
+
// new session, dropped the variable, runs outside the snapshot directory, names it in no argument AND holds no file in it
|
|
46
|
+
// open or mapped at the instant the nets sample it (it reads between samples) is invisible; a sampled net cannot prove
|
|
47
|
+
// such a process will not read next. (On macOS an Apple platform binary no longer escapes that way: hidden means held
|
|
48
|
+
// after the baseline unless lineage clears it.) Another user's unreadable processes are
|
|
49
|
+
// skipped on the premise that they cannot read this user's managed root.
|
|
50
|
+
// Any host: a reboot after the record was written ends every process it covered.
|
|
51
|
+
// - if a broker request may have been submitted, `rustright snapshot-lease status` reports REAPED for that snapshot & handle.
|
|
52
|
+
// Any other answer (alive, a probe that fails or is unsupported, ACTIVE, UNKNOWN, a record or holder state that cannot be read)
|
|
53
|
+
// is busy: never a reclaim. The same rules decide a normal release: the record is removed only when nothing but the gate itself
|
|
54
|
+
// is left; otherwise it stays, and the next gate reclaims it once the survivors are gone.
|
|
55
|
+
//
|
|
56
|
+
// Children inherit the ownership through RIGHTKIT_BUILD_OWNERSHIP=<handle>: a nested gate (the door running `check`) whose
|
|
57
|
+
// variable names the current record joins it instead of waiting on itself, and never releases it.
|
|
58
|
+
|
|
59
|
+
import crypto from "node:crypto";
|
|
60
|
+
import fs from "node:fs";
|
|
61
|
+
import os from "node:os";
|
|
62
|
+
import path from "node:path";
|
|
63
|
+
import { spawn as spawnDetachedDefault, spawnSync } from "./background-process.mjs";
|
|
64
|
+
import { managedRuntimeClient } from "./managed-cargo.mjs";
|
|
65
|
+
import { resolveManagedRoots } from "./managed-roots.mjs";
|
|
66
|
+
import { requireManagedRoot } from "./managed-root-guard.mjs";
|
|
67
|
+
|
|
68
|
+
export const OWNERSHIP_SCHEMA = "rightkit.build-ownership/1";
|
|
69
|
+
export const HOLDER_SCHEMA = "rightkit.build-ownership-holder/1";
|
|
70
|
+
export const OWNERSHIP_ENV = "RIGHTKIT_BUILD_OWNERSHIP";
|
|
71
|
+
/** The owner record's path, exported beside the handle so every process under the ownership records detached children there. */
|
|
72
|
+
export const OWNERSHIP_FILE_ENV = "RIGHTKIT_BUILD_OWNERSHIP_FILE";
|
|
73
|
+
export const LEASE_STATUS_SCHEMA = "rustright.snapshot-lease-status.v1";
|
|
74
|
+
const LEASE_STATES = new Set(["ACTIVE", "REAPED", "UNKNOWN"]);
|
|
75
|
+
// A start time read from `ps` (or /proc/<pid>/stat) is never late, only early: ps prints whole seconds (up to 1 s early), and on
|
|
76
|
+
// Linux the figure is btime (whole seconds, so the boot is up to 1 s early) plus the process's ticks: measured on WSL2, a process
|
|
77
|
+
// 98 ms old read as 788 ms old. So a start can read up to about 2 s before the process really began; this slack (with margin) is
|
|
78
|
+
// how much earlier than an instant a reading may be and still be "not before" it. A member started right after the gate took
|
|
79
|
+
// ownership counts as the gate's however its start reads.
|
|
80
|
+
const START_SLACK_MS = 3000;
|
|
81
|
+
// Two start identities of one pid are the same process when they agree to within this (ps prints seconds; this gate's own start
|
|
82
|
+
// is read from its time origin on Windows, a few hundred ms after the OS creation time at most).
|
|
83
|
+
const SAME_START_MS = 3000;
|
|
84
|
+
const sameStart = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs(a - b) <= SAME_START_MS;
|
|
85
|
+
|
|
86
|
+
export class OwnershipBusy extends Error {
|
|
87
|
+
constructor(message, detail = {}) { super(message); this.code = "busy"; this.detail = detail; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The directory beside the host's snapshots (or beside any injected root) that holds the owner record. */
|
|
91
|
+
export const OWNERSHIP_DIRNAME = "build-ownership";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where the host's one owner record lives (GC:24 "the host's build ownership"; GC:52 "host-wide build ownership"; item 5 row 5.17):
|
|
95
|
+
* ONE fixed location per host, independent of the snapshot root and of every environment variable. Two gates whose environments
|
|
96
|
+
* choose different snapshot roots (XDG_STATE_HOME, a configured root) must still contend for one record, so nothing here reads
|
|
97
|
+
* `process.env`, and nothing derives from where snapshots live.
|
|
98
|
+
* Mac & Dell: `<managed root>/build-ownership/owner.json` (the host's fixed managed root, `/Volumes/D/.rightkit-managed`,
|
|
99
|
+
* `D:\.rightkit-managed`).
|
|
100
|
+
* Linux (WSL2 included): `<account home>/.local/state/rightkit/build-ownership/owner.json`, the account home taken from the
|
|
101
|
+
* passwd database (`os.userInfo()`), never from $HOME.
|
|
102
|
+
* `home` replaces the Linux account home and `beside` replaces the whole location (`<dirname(beside)>/build-ownership/owner.json`);
|
|
103
|
+
* both are injection points for fixtures and tests only. A production caller passes neither: the root a caller derived from the
|
|
104
|
+
* environment must never be passed as `beside`, because that is the defect this row removes.
|
|
105
|
+
*/
|
|
106
|
+
export function ownershipFile({ platform = process.platform, home, beside } = {}) {
|
|
107
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
108
|
+
if (beside !== undefined) {
|
|
109
|
+
if (typeof beside !== "string" || beside === "") throw new TypeError(`ownershipFile: beside must be a path (${JSON.stringify(beside)})`);
|
|
110
|
+
return paths.join(paths.dirname(beside), OWNERSHIP_DIRNAME, "owner.json");
|
|
111
|
+
}
|
|
112
|
+
if (platform === "darwin" || platform === "win32") return paths.join(resolveManagedRoots({ env: {}, platform, home: "" }).dataRoot, OWNERSHIP_DIRNAME, "owner.json");
|
|
113
|
+
let base = home;
|
|
114
|
+
if (base === undefined) {
|
|
115
|
+
try { base = os.userInfo().homedir; } catch (e) { throw new OwnershipBusy(`build ownership: this account's home directory cannot be read (${e.message}), so the host's owner record has no location`); }
|
|
116
|
+
}
|
|
117
|
+
return paths.join(base, ".local", "state", "rightkit", OWNERSHIP_DIRNAME, "owner.json");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The managed-root guard, FIRST in `acquireBuildOwnership` (decision D4, rows 5.15 and 5.17): on Mac & Dell, when the record is at
|
|
122
|
+
* the host's own location (inside the host's managed root, or inside the `managedRoot` a fixture injects), the managed root must
|
|
123
|
+
* already exist, be a plain directory and, on the Mac, live on another device than `/`. Taking ownership creates `build-ownership/`,
|
|
124
|
+
* and must never be what brings a missing managed root (or a boot-volume substitute for an unmounted one) into being. Throws the
|
|
125
|
+
* guard's named refusal (`code: "refused"`, `reason`), before anything is read or written. Linux has no managed root.
|
|
126
|
+
*/
|
|
127
|
+
function guardManagedRoot(file, { platform, managedRoot, device }) {
|
|
128
|
+
if (platform !== "darwin" && platform !== "win32") return;
|
|
129
|
+
const root = managedRoot ?? resolveManagedRoots({ env: {}, platform, home: "" }).dataRoot;
|
|
130
|
+
if (managedRoot === undefined) {
|
|
131
|
+
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
132
|
+
const fold = (x) => paths.resolve(x).toLowerCase(); // both volumes are case-insensitive by default
|
|
133
|
+
const rel = paths.relative(fold(root), fold(file));
|
|
134
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${paths.sep}`) || paths.isAbsolute(rel)) return; // a record elsewhere (a fixture's): not the host's
|
|
135
|
+
}
|
|
136
|
+
requireManagedRoot(root, { platform, device });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Creates the ownership directory mode 0700 (one level on Mac & Dell) and refuses an existing POSIX one that grants group or other access (item 5 row 5.22):
|
|
141
|
+
* another user's unreadable process is skipped by the nets on the premise that it cannot read this user's managed root, and a
|
|
142
|
+
* directory other users could write would let them forge or remove the record. Windows has no mode bits; its directory ACL is the
|
|
143
|
+
* same trust the record itself has.
|
|
144
|
+
*/
|
|
145
|
+
export function ensureOwnershipDir(dir, { platform = process.platform } = {}) {
|
|
146
|
+
// Mac & Dell: exactly ONE level, `build-ownership/` inside a managed root that already exists (decision D4: the gate never
|
|
147
|
+
// creates the managed root, and a recursive creation here would). Linux: the durable state directory under the account home is
|
|
148
|
+
// created whole (GC:63).
|
|
149
|
+
if (platform === "linux") fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
150
|
+
else { try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; } }
|
|
151
|
+
if (platform === "win32" || process.platform === "win32") return;
|
|
152
|
+
const st = fs.statSync(dir);
|
|
153
|
+
if (!st.isDirectory()) throw new OwnershipBusy(`build ownership directory ${dir} is not a directory`);
|
|
154
|
+
if ((st.mode & 0o077) !== 0) throw new OwnershipBusy(`build ownership directory ${dir} is mode ${(st.mode & 0o777).toString(8).padStart(3, "0")}: group or other users can reach it, so the premise that another user's processes cannot read this user's managed root does not hold; chmod 700 ${dir} (nothing ran)`, { dir, mode: st.mode & 0o777 });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const jobName = (handle) => `Local\\RightKitBuildOwnership-${handle}`;
|
|
158
|
+
/** Where the process groups of children a gate under this ownership started detached (POSIX) are recorded. */
|
|
159
|
+
export const childrenDir = (file, handle) => path.join(path.dirname(file), `children-${handle}`);
|
|
160
|
+
export const holderFile = (file, handle) => path.join(path.dirname(file), `holder-${handle}.json`);
|
|
161
|
+
|
|
162
|
+
const run = (spawn, command, args, env = {}) => spawn(command, args, { encoding: "utf8", env: { ...process.env, ...env }, timeout: 60_000, maxBuffer: 1 << 26 });
|
|
163
|
+
|
|
164
|
+
// One C# type for every Windows probe: create, open, assign and count a named job.
|
|
165
|
+
const JOB_TYPE = `Add-Type @"
|
|
166
|
+
using System; using System.Runtime.InteropServices;
|
|
167
|
+
public static class RkJob {
|
|
168
|
+
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern IntPtr CreateJobObjectW(IntPtr a, string name);
|
|
169
|
+
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)] public static extern IntPtr OpenJobObjectW(uint access, bool inherit, string name);
|
|
170
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
|
|
171
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool AssignProcessToJobObject(IntPtr job, IntPtr proc);
|
|
172
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool QueryInformationJobObject(IntPtr job, int cls, byte[] info, int len, IntPtr ret);
|
|
173
|
+
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
|
|
174
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool IsProcessInJob(IntPtr proc, IntPtr job, out bool result);
|
|
175
|
+
[DllImport("kernel32.dll")] public static extern IntPtr GetCurrentProcess();
|
|
176
|
+
public static int Active(IntPtr job) { var b = new byte[48]; if (!QueryInformationJobObject(job, 1, b, 48, IntPtr.Zero)) return -1; return BitConverter.ToInt32(b, 40); }
|
|
177
|
+
}
|
|
178
|
+
"@`;
|
|
179
|
+
const psString = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
180
|
+
const encoded = (script) => Buffer.from(script, "utf16le").toString("base64");
|
|
181
|
+
|
|
182
|
+
/** Where the holder's script is written for PowerShell -File (beside the holder record). */
|
|
183
|
+
export const holderScriptFile = (file) => `${file}.ps1`;
|
|
184
|
+
/** cmd.exe's longest command line; the holder's launch must stay under it. */
|
|
185
|
+
export const CMD_LINE_LIMIT = 8191;
|
|
186
|
+
/** The holder's launch: `cmd /d /c start "" /b powershell -File <script>` (see contain). */
|
|
187
|
+
export const holderLaunchArgs = (script) => ["/d", "/c", "start", "\"\"", "/b", "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", script];
|
|
188
|
+
|
|
189
|
+
/** The holder: creates the gate's job, puts the gate in it, then keeps the only handle until the job is empty. */
|
|
190
|
+
export function holderScript({ name, pid, file, selfDelete = false }) {
|
|
191
|
+
return [
|
|
192
|
+
"$ErrorActionPreference = 'Stop'",
|
|
193
|
+
...(selfDelete ? ["try { Remove-Item -LiteralPath $PSCommandPath -Force } catch { }"] : []),
|
|
194
|
+
JOB_TYPE,
|
|
195
|
+
`$name = ${psString(name)}; $gate = [uint32]${Number(pid)}; $file = ${psString(file)}`,
|
|
196
|
+
`function Put($o) { $o.schema = '${HOLDER_SCHEMA}'; $o.holder = $PID; $o.at = (Get-Date).ToUniversalTime().ToString('o'); $tmp = "$file.$PID.tmp"; [IO.File]::WriteAllText($tmp, ($o | ConvertTo-Json -Compress)); Move-Item -Force -LiteralPath $tmp -Destination $file }`,
|
|
197
|
+
"try {",
|
|
198
|
+
" $h = [RkJob]::CreateJobObjectW([IntPtr]::Zero, $name); $e = [Runtime.InteropServices.Marshal]::GetLastWin32Error()",
|
|
199
|
+
" if ($h -eq [IntPtr]::Zero) { throw \"CreateJobObject failed ($e)\" }",
|
|
200
|
+
" if ($e -eq 183) { throw \"job $name already exists\" }",
|
|
201
|
+
" $p = [RkJob]::OpenProcess(0x0101, $false, $gate); if ($p -eq [IntPtr]::Zero) { throw \"OpenProcess $gate failed ($([Runtime.InteropServices.Marshal]::GetLastWin32Error()))\" }",
|
|
202
|
+
" if (-not [RkJob]::AssignProcessToJobObject($h, $p)) { throw \"AssignProcessToJobObject failed ($([Runtime.InteropServices.Marshal]::GetLastWin32Error()))\" }",
|
|
203
|
+
" [RkJob]::CloseHandle($p) | Out-Null",
|
|
204
|
+
" Put @{ state = 'holding'; job = $name }",
|
|
205
|
+
"} catch { Put @{ state = 'failed'; why = \"$_\" }; exit 1 }",
|
|
206
|
+
// Reports the live count whenever it changes, drained at zero. A release asks for a fresh count by writing `<file>.query`
|
|
207
|
+
// ({nonce}); the holder counts the job after reading it and answers in `<file>.ack` with that nonce (queryHolder).
|
|
208
|
+
"$last = -1; $query = \"$file.query\"; $ack = \"$file.ack\"; $answered = ''",
|
|
209
|
+
// Each nonce is answered once (rewriting the ack would contend with the reader removing it).
|
|
210
|
+
"function Answer { try { $q = [IO.File]::ReadAllText($query) | ConvertFrom-Json; $n = [string]$q.nonce; if ($n -notmatch '^[0-9a-f]{32,128}$' -or $n -eq $script:answered) { return }; $c = [RkJob]::Active($h); $o = @{ schema = '" + HOLDER_SCHEMA + "'; nonce = $n; active = $c; holder = $PID; at = (Get-Date).ToUniversalTime().ToString('o') }; $tmp = \"$ack.$PID.tmp\"; [IO.File]::WriteAllText($tmp, ($o | ConvertTo-Json -Compress)); Move-Item -Force -LiteralPath $tmp -Destination $ack; $script:answered = $n } catch { } }",
|
|
211
|
+
"while ($true) { if ([IO.File]::Exists($query)) { Answer }; $a = [RkJob]::Active($h); if ($a -eq 0) { Put @{ state = 'drained'; job = $name }; exit 0 }; if ($a -gt 0 -and $a -ne $last) { Put @{ state = 'holding'; job = $name; active = $a }; $last = $a }; Start-Sleep -Milliseconds 50 }",
|
|
212
|
+
].join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A fresh count of the gate's job from its holder (outside the job): writes `<holder file>.query` with a new nonce and waits for
|
|
217
|
+
* `<holder file>.ack` carrying that nonce, which the holder writes after counting the job anew. Returns `{ active, holder, at, nonce }`
|
|
218
|
+
* or throws: no answer within `timeoutMs` (a dead or hung holder) is unknown, never the last count it wrote.
|
|
219
|
+
*/
|
|
220
|
+
export function queryHolder(file, { timeoutMs = 5000, nonce = crypto.randomBytes(16).toString("hex"), sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
|
|
221
|
+
const query = `${file}.query`; const ack = `${file}.ack`;
|
|
222
|
+
fs.rmSync(ack, { force: true });
|
|
223
|
+
const tmp = `${query}.${crypto.randomUUID()}.tmp`;
|
|
224
|
+
fs.writeFileSync(tmp, JSON.stringify({ nonce }), { flag: "wx", mode: 0o600 });
|
|
225
|
+
try { fs.renameSync(tmp, query); } finally { fs.rmSync(tmp, { force: true }); }
|
|
226
|
+
try {
|
|
227
|
+
const until = Date.now() + timeoutMs;
|
|
228
|
+
for (;;) {
|
|
229
|
+
let answer = null;
|
|
230
|
+
try { answer = JSON.parse(fs.readFileSync(ack, "utf8").replace(/^\uFEFF/, "")); } catch { answer = null; }
|
|
231
|
+
if (answer?.schema === HOLDER_SCHEMA && answer.nonce === nonce) {
|
|
232
|
+
if (!Number.isInteger(answer.active)) throw new Error(`the holder's answer to ${nonce} carries no count`);
|
|
233
|
+
return { active: answer.active, holder: answer.holder, at: answer.at, nonce };
|
|
234
|
+
}
|
|
235
|
+
if (Date.now() > until) throw new Error(`the job holder did not answer a fresh count within ${timeoutMs} ms`);
|
|
236
|
+
sleep(25);
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
// The holder may hold either file open for a moment (Windows refuses the removal then); a leftover is harmless: the next
|
|
240
|
+
// query replaces the query and removes the ack before it waits, and only an answer with its own nonce counts.
|
|
241
|
+
for (const f of [query, ack]) { try { fs.rmSync(f, { force: true }); } catch { /* left for the next query */ } }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Run as a child of the gate (as its work runs): `IN` when this process is in job `name`, `OUT` when not, `ERR:<n>` otherwise. */
|
|
246
|
+
export function selfCheckScript(name) {
|
|
247
|
+
return [
|
|
248
|
+
"$ErrorActionPreference = 'Stop'",
|
|
249
|
+
JOB_TYPE,
|
|
250
|
+
`$j = [RkJob]::OpenJobObjectW(0x0004, $false, ${psString(name)}); if ($j -eq [IntPtr]::Zero) { 'ERR:' + [Runtime.InteropServices.Marshal]::GetLastWin32Error(); exit 0 }`,
|
|
251
|
+
"$in = $false; if (-not [RkJob]::IsProcessInJob([RkJob]::GetCurrentProcess(), $j, [ref]$in)) { 'ERR:' + [Runtime.InteropServices.Marshal]::GetLastWin32Error(); exit 0 }",
|
|
252
|
+
"if ($in) { 'IN' } else { 'OUT' }",
|
|
253
|
+
].join("\n");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Probe of a named job: `JOB:<active>` (opened), `GONE` (no such object), `ERR:<n>` otherwise; plus `BOOT:<ms>`. */
|
|
257
|
+
function jobProbeScript(name) {
|
|
258
|
+
return [
|
|
259
|
+
"$ErrorActionPreference = 'Stop'",
|
|
260
|
+
JOB_TYPE,
|
|
261
|
+
`$j = [RkJob]::OpenJobObjectW(0x0004, $false, ${psString(name)}); $e = [Runtime.InteropServices.Marshal]::GetLastWin32Error()`,
|
|
262
|
+
"if ($j -ne [IntPtr]::Zero) { 'JOB:' + [RkJob]::Active($j); [RkJob]::CloseHandle($j) | Out-Null } elseif ($e -eq 2) { 'GONE' } else { 'ERR:' + $e }",
|
|
263
|
+
].join("\n");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const PATH_CHAR = /[A-Za-z0-9._@+~-]/;
|
|
267
|
+
/** Whether `text` (an argument list) names the directory `dir`: `dir` itself or a path under it, not a longer sibling name. */
|
|
268
|
+
export function namesDir(text, dir) {
|
|
269
|
+
const d = dir.length > 1 ? dir.replace(/\/+$/, "") : dir;
|
|
270
|
+
if (!d) return false;
|
|
271
|
+
for (let i = text.indexOf(d); i !== -1; i = text.indexOf(d, i + 1)) {
|
|
272
|
+
const before = i === 0 ? "" : text[i - 1]; const after = text[i + d.length] ?? "";
|
|
273
|
+
if ((before === "" || !PATH_CHAR.test(before)) && (after === "" || after === "/" || !PATH_CHAR.test(after))) return true;
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** The most parent links `classifyHiddenProcesses` follows from one process before it holds it (a cycle or an absurdly deep chain is unknown). */
|
|
279
|
+
export const HIDDEN_WALK_LIMIT = 1024;
|
|
280
|
+
/** What `launchctl blame <pid>` prints (exit 3) for a process launchd does not manage, or that no longer exists. */
|
|
281
|
+
export const NOT_MANAGED_LINE = "Process is not managed by launchd or does not exist.";
|
|
282
|
+
/** `launchctl blame` answer -> `managed` (a launchd job: exit 0 with a reason), `unmanaged` (exit 3, the line above) or `unknown` (anything else). */
|
|
283
|
+
export function blameKind(answer) {
|
|
284
|
+
const line = String(answer?.line ?? "").trim();
|
|
285
|
+
if (answer?.rc === 3 && line === NOT_MANAGED_LINE) return "unmanaged";
|
|
286
|
+
if (answer?.rc === 0 && line !== "" && line !== NOT_MANAGED_LINE) return "managed";
|
|
287
|
+
return "unknown";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* macOS (item 5 row 5.24a, rule 5.24a' of signed/item5-sage-decisions-2.md D1 with D2; item186-decision.md:47-71 with the gate's
|
|
292
|
+
* anchor in place of the broker's root start). `ps eww` prints no environment for an Apple platform binary (/bin/*, /usr/bin/*,
|
|
293
|
+
* system daemons), so the token net cannot tell such a process from one that never carried the handle, and the snapshot-use net
|
|
294
|
+
* samples only the instant it runs. A gate descendant that is a platform binary, started its own session and touches nothing in the
|
|
295
|
+
* snapshot at that instant would escape both. So: hidden means held after the anchor, cleared only by lineage proof.
|
|
296
|
+
*
|
|
297
|
+
* `procs` are the live processes as `{ pid, ppid, group, startedMs, uid, state, hidden, uninspected, command }` (`startedMs` has
|
|
298
|
+
* `ps lstart`'s one-second resolution; `state` is `ps stat`, whose `X` means traced; `hidden`: `ps eww` shows no environment, an
|
|
299
|
+
* empty environment included; `uninspected`: `lsof` listed nothing for it). `anchorMs` is the baseline (the later of the last clean
|
|
300
|
+
* release and the snapshot root's birth in this boot, capped at acquisition); with none, the anchor is the record's own acquisition
|
|
301
|
+
* (`owner.acquiredAt`), never "no anchor": a process that started before this ownership began is no unproven work of any owner,
|
|
302
|
+
* because every record is published on a free host after a release that proved zero readers, or by a reclaim that proved the
|
|
303
|
+
* previous work gone. `owner` is the record; `recorded` the detached children the gate recorded, `[{ group, command }]` (a child
|
|
304
|
+
* leads the group named by its own pid). `blame(pid) -> { rc, line }` is `launchctl blame`; `reread(pids) -> Map(pid -> { ppid,
|
|
305
|
+
* startedMs })` re-lists pids (the binding against pid reuse).
|
|
306
|
+
*
|
|
307
|
+
* A candidate is a same-user, live (not a zombie) process the gate did not itself start for this probe, that is hidden or that
|
|
308
|
+
* `lsof` could not inspect. Each is judged in this order:
|
|
309
|
+
* (t) it, or a process on its walk, is traced (a ptrace attach reparents to the tracer) -> HELD.
|
|
310
|
+
* (a) it started before the anchor second -> drop. The anchor second itself counts as after: `lstart` is whole seconds.
|
|
311
|
+
* (b) it is in a recorded detached group -> HELD. It is in the owner's group -> HELD unless its parent is one of the owner's
|
|
312
|
+
* recorded ancestors, matched by pid and start identity (`gate | tee`: that ancestor's other child), when it goes to (c).
|
|
313
|
+
* The owner's own recorded ancestors are never its children and are not candidates.
|
|
314
|
+
* (c) walk up from it; for each parent, in order:
|
|
315
|
+
* the parent is the owner or a recorded child; the parent cannot be read; the parent started after its child (pid reuse);
|
|
316
|
+
* or the walk passes HIDDEN_WALK_LIMIT steps or loops -> HELD.
|
|
317
|
+
* The parent is launchd (pid 1): the child X on the walk started at or after the anchor; `launchctl blame X` decides. A
|
|
318
|
+
* launchd job (exit 0 and a reason) means the chain was born under launchd and never passed through the gate -> drop,
|
|
319
|
+
* once the re-read after the blame calls confirms X (still a child of pid 1, same start). Not managed (exit 3) is a reparented
|
|
320
|
+
* orphan, or a vanished one -> HELD. Anything else (another exit code, no output, a spawn error, a timeout) -> HELD.
|
|
321
|
+
* The parent provably started before the anchor, and is not launchd -> drop (a terminal opened after the anchor under a
|
|
322
|
+
* Terminal that predates it).
|
|
323
|
+
* Otherwise move to the parent and repeat. A recorded ancestor is walked like any other process: the exemption in (b) is never
|
|
324
|
+
* an immediate drop.
|
|
325
|
+
* Residual R1 (trusted per-user model, AR:273-276): a gate descendant can ask launchd to spawn a process (`launchctl submit`, `open`, an
|
|
326
|
+
* XPC service, a LaunchAgent). That process is launchd-born, so it is dropped. macOS has no subreaper, so nothing else reparents a
|
|
327
|
+
* descendant to a process that predates the anchor.
|
|
328
|
+
* Returns `[{ pid, why }]`, every process held, each reason naming the pid.
|
|
329
|
+
*/
|
|
330
|
+
export function classifyHiddenProcesses({ procs, own = new Set(), uid, owner, recorded = [], anchorMs, alive = () => true, blame = null, reread = null }) {
|
|
331
|
+
const byPid = new Map(procs.map((p) => [p.pid, p]));
|
|
332
|
+
const anchor = Number.isFinite(anchorMs) ? anchorMs : Date.parse(owner?.acquiredAt ?? "");
|
|
333
|
+
const anchorSecond = Number.isFinite(anchor) ? Math.floor(anchor / 1000) * 1000 : NaN;
|
|
334
|
+
const ownerGroup = Number.isInteger(owner?.containment?.group) ? owner.containment.group : null;
|
|
335
|
+
const recordedGroups = new Set(recorded.map((r) => r.group));
|
|
336
|
+
const recordedChild = (p) => recordedGroups.has(p.pid);
|
|
337
|
+
const isOwner = (p) => p.pid === owner?.pid && sameStart(p.startedMs, owner?.startedMs);
|
|
338
|
+
const ancestors = Array.isArray(owner?.ancestors) ? owner.ancestors : [];
|
|
339
|
+
const isAncestor = (p) => ancestors.some((a) => a?.pid === p.pid && sameStart(a.startedMs, p.startedMs));
|
|
340
|
+
const traced = (p) => /X/.test(String(p.state));
|
|
341
|
+
const before = (p) => p.startedMs < anchorSecond;
|
|
342
|
+
const held = [];
|
|
343
|
+
const cleared = []; // [{ p, top, hold }]: dropped by a launchd-job top, pending the re-read
|
|
344
|
+
const blamed = new Map(); // one answer per pid within this sample
|
|
345
|
+
const blameOf = (pid) => {
|
|
346
|
+
if (!blamed.has(pid)) { let a; try { a = typeof blame === "function" ? blame(pid) : null; } catch { a = null; } blamed.set(pid, a); }
|
|
347
|
+
return blamed.get(pid);
|
|
348
|
+
};
|
|
349
|
+
const name = (p) => `pid ${p.pid} (${String(p.command ?? "?").slice(0, 80)}), started ${Number.isFinite(p.startedMs) ? new Date(p.startedMs).toISOString() : "at a time that cannot be read"}`;
|
|
350
|
+
for (const p of procs) {
|
|
351
|
+
if (own.has(p.pid) || p.uid !== uid || /^Z/.test(String(p.state)) || !(p.hidden || p.uninspected)) continue;
|
|
352
|
+
const what = p.hidden ? "its environment is hidden (`ps eww` shows none)" : "`lsof` cannot inspect it";
|
|
353
|
+
const hold = (why) => { if (alive(p.pid)) held.push({ pid: p.pid, why: `${name(p)}: ${what}, and ${why}` }); };
|
|
354
|
+
if (isOwner(p) || isAncestor(p)) continue; // the owner, or one of its parents: never its child
|
|
355
|
+
if (!Number.isFinite(anchorSecond)) { hold("this ownership has no anchor and its record has no readable acquisition time, so nothing hidden can be cleared"); continue; }
|
|
356
|
+
if (traced(p)) { hold("it is traced (a ptrace attach reparents it to the tracer), so its lineage proves nothing"); continue; } // (t)
|
|
357
|
+
if (!Number.isFinite(p.startedMs)) { hold("its start time cannot be read"); continue; }
|
|
358
|
+
if (before(p)) continue; // (a)
|
|
359
|
+
const parentRow = byPid.get(p.ppid);
|
|
360
|
+
const peer = !!parentRow && isAncestor(parentRow) && !traced(parentRow);
|
|
361
|
+
if (recordedGroups.has(p.group)) { hold(`it is in process group ${p.group}, a detached group this gate recorded`); continue; } // (b)
|
|
362
|
+
if (p.group === ownerGroup && !peer) { hold(`it is in process group ${p.group}, the owner's group`); continue; }
|
|
363
|
+
// (c)
|
|
364
|
+
let x = p; let verdict = null; let top = null; const seen = new Set([p.pid]);
|
|
365
|
+
for (let steps = 0; verdict === null; steps++) {
|
|
366
|
+
if (steps >= HIDDEN_WALK_LIMIT) { verdict = `the ancestry walk passed ${HIDDEN_WALK_LIMIT} steps`; break; }
|
|
367
|
+
const parent = byPid.get(x.ppid);
|
|
368
|
+
if (!parent) { verdict = `the parent of pid ${x.pid} (pid ${x.ppid}) cannot be read`; break; }
|
|
369
|
+
if (isOwner(parent)) { verdict = `its ancestry reaches the owner (pid ${parent.pid}) through pid ${x.pid}`; break; }
|
|
370
|
+
if (recordedChild(parent)) { verdict = `its ancestry reaches a child this gate recorded (pid ${parent.pid}) through pid ${x.pid}`; break; }
|
|
371
|
+
if (traced(parent)) { verdict = `pid ${parent.pid} on its walk is traced, so its lineage proves nothing`; break; }
|
|
372
|
+
if (parent.pid === 1) {
|
|
373
|
+
const answer = blameOf(x.pid);
|
|
374
|
+
const kind = blameKind(answer);
|
|
375
|
+
if (kind === "managed") { verdict = false; top = x; break; }
|
|
376
|
+
verdict = kind === "unmanaged" ? `pid ${x.pid} is an orphan: its parent is launchd, it started at or after the anchor, and launchctl blame says it is not managed by launchd (a reparented orphan, or one that vanished)`
|
|
377
|
+
: `pid ${x.pid}'s parent is launchd and launchctl blame gave no usable answer (${answer ? `exit ${answer.rc}, ${JSON.stringify(String(answer.line ?? "").slice(0, 80))}` : "it could not run"}), so it is unknown`;
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
if (!Number.isFinite(parent.startedMs) || parent.startedMs > x.startedMs) { verdict = `pid ${parent.pid} started after its child pid ${x.pid} (pid reuse), so the lineage cannot be trusted`; break; }
|
|
381
|
+
if (before(parent)) { verdict = false; break; } // a parent that provably predates the anchor, and is not launchd
|
|
382
|
+
if (seen.has(parent.pid)) { verdict = `the ancestry of pid ${p.pid} loops at pid ${parent.pid}`; break; }
|
|
383
|
+
seen.add(parent.pid); x = parent;
|
|
384
|
+
}
|
|
385
|
+
if (verdict) hold(verdict);
|
|
386
|
+
else if (top) cleared.push({ p, top, hold });
|
|
387
|
+
}
|
|
388
|
+
// Binding against pid reuse: a top cleared as a launchd job is re-listed after the blame calls; one that is no longer a child of
|
|
389
|
+
// launchd, started at another time, or is gone, was another process when it was judged.
|
|
390
|
+
if (cleared.length) {
|
|
391
|
+
const tops = [...new Set(cleared.map((c) => c.top.pid))];
|
|
392
|
+
let fresh = null; try { fresh = typeof reread === "function" ? reread(tops) : null; } catch { fresh = null; }
|
|
393
|
+
for (const c of cleared) {
|
|
394
|
+
const f = fresh?.get(c.top.pid);
|
|
395
|
+
if (!f) c.hold(fresh ? `pid ${c.top.pid}, the launchd job it descends from, is gone on the re-read, so its lineage changed during the sample` : `pid ${c.top.pid}, the launchd job it descends from, could not be re-read, so the answer cannot be bound to it`);
|
|
396
|
+
else if (f.ppid !== 1 || f.startedMs !== c.top.startedMs) c.hold(`pid ${c.top.pid}, the launchd job it descends from, is not the same process on the re-read (parent ${f.ppid}, started ${Number.isFinite(f.startedMs) ? new Date(f.startedMs).toISOString() : "?"}; it was parent 1, started ${new Date(c.top.startedMs).toISOString()}): pid reuse`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return held;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The host's process facts the ownership rules rely on. Each answer is a fact or a throw; a throw means "unknown" and the
|
|
404
|
+
* caller treats it as busy.
|
|
405
|
+
*/
|
|
406
|
+
export function processProbe({ platform = process.platform, spawn = spawnSync, spawnHolder = spawnDetachedDefault, kill = process.kill.bind(process), readFile = fs.readFileSync, readdir = fs.readdirSync, readlink = fs.readlinkSync, exists: procExists = fs.existsSync, proc = "/proc", uid = process.getuid?.() } = {}) {
|
|
407
|
+
const exists = (pid) => {
|
|
408
|
+
try { kill(pid, 0); return true; } catch (e) { if (e.code === "ESRCH") return false; if (e.code === "EPERM") return true; throw e; }
|
|
409
|
+
};
|
|
410
|
+
if (platform === "win32") {
|
|
411
|
+
const ps = (script) => {
|
|
412
|
+
const r = run(spawn, "powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded(script)]);
|
|
413
|
+
if (r.error || r.status !== 0) throw new Error(`powershell: ${r.error?.message ?? (String(r.stderr ?? "").trim().split(/\r?\n/).pop() || `exit ${r.status}`)}`);
|
|
414
|
+
return String(r.stdout ?? "").trim();
|
|
415
|
+
};
|
|
416
|
+
return {
|
|
417
|
+
platform,
|
|
418
|
+
/**
|
|
419
|
+
* `{ alive, startedMs }`. This gate's own start comes from its time origin (no process launch). Another process's start is
|
|
420
|
+
* a Unix time in ms from `[DateTimeOffset]`: `[datetime]'1970-01-01Z'` parses as LOCAL time, so subtracting it was off by the
|
|
421
|
+
* zone's offset (5.5 h on the Dell, 2026-10-29), which made a live owner read as another process and, west of UTC, a reboot
|
|
422
|
+
* appear to have happened.
|
|
423
|
+
*/
|
|
424
|
+
identity(pid) {
|
|
425
|
+
if (pid === process.pid) return { alive: true, startedMs: Math.round(performance.timeOrigin), group: null };
|
|
426
|
+
if (!exists(pid)) return { alive: false, startedMs: null, group: null };
|
|
427
|
+
const out = ps(`$p = Get-Process -Id ${Number(pid)} -ErrorAction SilentlyContinue; if ($p) { 'START:' + ([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds() } else { 'GONE' }`);
|
|
428
|
+
if (out === "GONE") return { alive: false, startedMs: null, group: null };
|
|
429
|
+
const m = /START:(\d+)/.exec(out);
|
|
430
|
+
if (!m) throw new Error(`cannot read the start time of pid ${pid}`);
|
|
431
|
+
return { alive: true, startedMs: Number(m[1]), group: null };
|
|
432
|
+
},
|
|
433
|
+
bootTime() {
|
|
434
|
+
const m = /BOOT:(\d+)/.exec(ps("'BOOT:' + ([DateTimeOffset](Get-CimInstance Win32_OperatingSystem).LastBootUpTime).ToUnixTimeMilliseconds()"));
|
|
435
|
+
if (!m) throw new Error("cannot read the boot time");
|
|
436
|
+
return Number(m[1]);
|
|
437
|
+
},
|
|
438
|
+
/** A fresh count from the job's holder (see queryHolder): asked at release, when a probe this gate spawned would sit in the job. */
|
|
439
|
+
queryHolder: (file, options) => queryHolder(file, options),
|
|
440
|
+
/** `{ open: true, active }`, `{ open: false }` (no such job), or a throw. */
|
|
441
|
+
jobState(name) {
|
|
442
|
+
const out = ps(jobProbeScript(name));
|
|
443
|
+
const job = /JOB:(-?\d+)/.exec(out);
|
|
444
|
+
if (job) { const active = Number(job[1]); if (active < 0) throw new Error(`cannot count the processes of ${name}`); return { open: true, active }; }
|
|
445
|
+
if (/^GONE$/m.test(out)) return { open: false };
|
|
446
|
+
throw new Error(`cannot open ${name}: ${out}`);
|
|
447
|
+
},
|
|
448
|
+
/**
|
|
449
|
+
* Starts the holder that puts `pid` in job `name`; returns once it reports holding (or throws). The holder must outlive
|
|
450
|
+
* this gate: it is started by `cmd /c start /b`, so it is cmd's child, not Node's, and silently leaves the kill-on-close
|
|
451
|
+
* job Node keeps for its own children (libuv sets SILENT_BREAKAWAY_OK); cmd exits at once. A DETACHED_PROCESS PowerShell
|
|
452
|
+
* never starts in a console-less session (measured on the Dell over ssh, 2026-10-29), so `detached` is not used.
|
|
453
|
+
*/
|
|
454
|
+
contain({ name, pid, file, timeoutMs = 60_000, sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) }) {
|
|
455
|
+
// The script goes in a file beside the holder record (mode 0600 means nothing on NTFS: the file has the ownership directory's
|
|
456
|
+
// ACL, the same trust the record itself has), never on the command line: cmd.exe cuts a command line at
|
|
457
|
+
// 8191 characters, and the encoded holder passed that once it answered fresh queries (the holder never started; Dell
|
|
458
|
+
// 2026-10-29, D:\scratch\rk-step4\dell-ev7-build-ownership.log). The holder deletes the file once PowerShell has read it.
|
|
459
|
+
const script = holderScriptFile(file);
|
|
460
|
+
fs.rmSync(script, { force: true });
|
|
461
|
+
fs.writeFileSync(script, holderScript({ name, pid, file, selfDelete: true }), { flag: "wx", mode: 0o600 });
|
|
462
|
+
const holding = (() => {
|
|
463
|
+
try {
|
|
464
|
+
const child = spawnHolder("cmd.exe", holderLaunchArgs(script), { stdio: "ignore" });
|
|
465
|
+
child.unref?.();
|
|
466
|
+
const until = Date.now() + timeoutMs;
|
|
467
|
+
for (;;) {
|
|
468
|
+
const state = readHolder(file);
|
|
469
|
+
if (state?.state === "holding") return state;
|
|
470
|
+
if (state?.state === "failed") throw new Error(`the job holder could not contain the gate: ${state.why}`);
|
|
471
|
+
if (Date.now() > until) throw new Error(`the job holder did not report within ${timeoutMs} ms`);
|
|
472
|
+
sleep(50);
|
|
473
|
+
}
|
|
474
|
+
} finally { fs.rmSync(script, { force: true }); }
|
|
475
|
+
})();
|
|
476
|
+
// Self-check: a child of this gate, started as its work is started, asks Windows whether it is in the job. Under a launcher
|
|
477
|
+
// whose own job lets children leave silently (a Node parent: libuv's job sets SILENT_BREAKAWAY_OK, and a job nested under
|
|
478
|
+
// it loses them too) it is not, and nothing then proves this gate's work gone after a crash: the record says so and a dead
|
|
479
|
+
// owner stays busy. (Counting the job's processes cannot tell: a console child's host process lands in it either way.)
|
|
480
|
+
const answer = ps(selfCheckScript(name));
|
|
481
|
+
if (!/^(IN|OUT)$/m.test(answer)) throw new Error(`the containment self-check gave no answer (${answer.slice(0, 120)})`);
|
|
482
|
+
return { ...holding, contained: /^IN$/m.test(answer) };
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
// Every process with its parent, group and start, plus the pids that are this gate's own (itself, its ancestors and the `ps`
|
|
487
|
+
// that listed them), which are never another owner's children even when they share its group.
|
|
488
|
+
const psList = () => {
|
|
489
|
+
const r = run(spawn, "ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "pgid=", "-o", "lstart="], { LC_ALL: "C", LANG: "C" });
|
|
490
|
+
if (r.error || r.status !== 0) throw new Error(`ps: ${r.error?.message ?? (String(r.stderr ?? "").trim() || `exit ${r.status}`)}`);
|
|
491
|
+
const rows = String(r.stdout ?? "").split(/\r?\n/).map((l) => /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(l)).filter(Boolean)
|
|
492
|
+
.map((m) => ({ pid: Number(m[1]), ppid: Number(m[2]), group: Number(m[3]), startedMs: Date.parse(m[4].replace(/\s+/g, " ")) }));
|
|
493
|
+
const byPid = new Map(rows.map((row) => [row.pid, row]));
|
|
494
|
+
const own = new Set(Number.isInteger(r.pid) ? [r.pid] : []);
|
|
495
|
+
for (let pid = process.pid; pid > 1 && !own.has(pid); pid = byPid.get(pid)?.ppid ?? 0) own.add(pid);
|
|
496
|
+
return { rows, own, byPid };
|
|
497
|
+
};
|
|
498
|
+
// Linux /proc. An entry this gate cannot read is a fact to decide, never a silent skip: gone (ENOENT/ESRCH) is not a reader;
|
|
499
|
+
// another user's process is skipped (it cannot read this user's managed root; its uid comes from /proc/<pid>/status, readable
|
|
500
|
+
// by everyone); anything else, including a process of this user (EACCES/EPERM: non-dumpable, hidepid) is UNKNOWN and throws, so
|
|
501
|
+
// the caller stays busy.
|
|
502
|
+
const GONE = new Set(["ENOENT", "ESRCH"]);
|
|
503
|
+
// A real Linux host always has /proc, so its absence there is unknown (throws below); a Linux platform simulated on another host
|
|
504
|
+
// (gate-contract's linux cases on a Mac) has nothing real to read and falls through to "no net".
|
|
505
|
+
const procAvailable = () => platform === "linux" && (process.platform === "linux" || procExists(proc));
|
|
506
|
+
const procPids = () => {
|
|
507
|
+
if (!procExists(proc)) throw new Error(`no ${proc} on this linux host`);
|
|
508
|
+
return readdir(proc).filter((name) => /^\d+$/.test(name) && Number(name) !== process.pid).map(Number);
|
|
509
|
+
};
|
|
510
|
+
const bootMs = () => {
|
|
511
|
+
const m = /^btime (\d+)$/m.exec(String(readFile(`${proc}/stat`, "utf8")));
|
|
512
|
+
if (!m) throw new Error(`no btime in ${proc}/stat`);
|
|
513
|
+
return Number(m[1]) * 1000;
|
|
514
|
+
};
|
|
515
|
+
// When pid started, from /proc/<pid>/stat field 22 (clock ticks since boot, USER_HZ = 100 on Linux) and the boot time. stat and
|
|
516
|
+
// status stay readable for non-dumpable processes. btime has one-second resolution, so the figure can be up to a second early.
|
|
517
|
+
const TICKS_PER_SECOND = 100;
|
|
518
|
+
const startedAt = (pid, boot) => {
|
|
519
|
+
const text = String(readFile(`${proc}/${pid}/stat`, "utf8"));
|
|
520
|
+
const fields = text.slice(text.lastIndexOf(")") + 2).split(" "); // from field 3 (state) on; comm may hold spaces and parens
|
|
521
|
+
const ticks = Number(fields[19]);
|
|
522
|
+
if (!Number.isInteger(ticks)) throw new Error(`cannot read the start time in ${proc}/${pid}/stat`);
|
|
523
|
+
return boot + Math.round((ticks * 1000) / TICKS_PER_SECOND);
|
|
524
|
+
};
|
|
525
|
+
// THE INVARIANT behind skipping an unreadable (EACCES/EPERM) process of this user, which could be reading the snapshot unseen:
|
|
526
|
+
// it is skipped only if it started before `skipBefore` (less START_SLACK_MS of reading error), the BASELINE of this ownership,
|
|
527
|
+
// which is the later of
|
|
528
|
+
// (1) the acquisition of the previous owner whose release proved zero readers (the record's `cleanBefore`, from the durable
|
|
529
|
+
// marker of that release): the process predates that owner too, so it is no leftover of any owner whose work is unresolved;
|
|
530
|
+
// (2) the birth of the snapshot root in this boot (the root's birth record), capped at this ownership's acquisition: a process
|
|
531
|
+
// that started before the root existed cannot have held it open from before, and one that started before this ownership
|
|
532
|
+
// is not the gate's own.
|
|
533
|
+
// With neither (a previous release that ended held, unknown or reclaimed leaves no marker; a missing, malformed or other-boot
|
|
534
|
+
// birth record) the baseline is this ownership's own acquisition (decision D1, 5.23 amended): nothing that started before this
|
|
535
|
+
// record was published is the unproven work of any owner, because every record is published on a free host after a release that
|
|
536
|
+
// proved zero readers, or by a reclaim that proved the previous work gone. A process that started at or after that, when its
|
|
537
|
+
// start time cannot be read, or when its stat is unreadable, stays held.
|
|
538
|
+
const unreadable = (pid, e, skipBefore) => {
|
|
539
|
+
if (GONE.has(e.code)) return;
|
|
540
|
+
if (!["EACCES", "EPERM"].includes(e.code)) throw e;
|
|
541
|
+
let status;
|
|
542
|
+
try { status = String(readFile(`${proc}/${pid}/status`, "utf8")); } catch (e2) { if (GONE.has(e2.code)) return; throw new Error(`${proc}/${pid} is unreadable (${e.code}) and so is its owner (${e2.code ?? e2.message}), so what it reads is unknown`); }
|
|
543
|
+
// A zombie (Z) or dead (X) process has no memory, descriptors or working directory, and its /proc entries refuse reads: it reads nothing.
|
|
544
|
+
if (/^State:\s*[ZXx]/m.test(status)) return;
|
|
545
|
+
const m = /^Uid:\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/m.exec(status);
|
|
546
|
+
if (!m) throw new Error(`${proc}/${pid} is unreadable (${e.code}) and its owner cannot be read, so what it reads is unknown`);
|
|
547
|
+
if (Number.isInteger(uid) && ![1, 2, 3, 4].some((i) => Number(m[i]) === uid)) return;
|
|
548
|
+
const what = `${/^Name:\s*(.*)$/m.exec(status)?.[1] ?? "?"}, ${/^State:\s*(\S+)/m.exec(status)?.[1] ?? "?"}`;
|
|
549
|
+
const unknown = (why) => new Error(`${proc}/${pid} (a process of this user: ${what}) is unreadable (${e.code}) and ${why}, so what it reads is unknown`);
|
|
550
|
+
if (!Number.isFinite(skipBefore)) throw unknown("there is no baseline for this ownership (no clean previous release is recorded, and no birth record of the snapshot root in this boot)");
|
|
551
|
+
let startMs;
|
|
552
|
+
try { startMs = startedAt(pid, bootMs()); } catch (e3) { if (GONE.has(e3.code)) return; throw unknown(`its start time cannot be read (${e3.code ?? e3.message})`); }
|
|
553
|
+
if (startMs + START_SLACK_MS < skipBefore) return;
|
|
554
|
+
throw unknown(`it started at ${new Date(startMs).toISOString()}, not before the baseline ${new Date(skipBefore).toISOString()} (the later of the last clean release's acquisition and the snapshot root's birth, else this ownership's own acquisition)`);
|
|
555
|
+
};
|
|
556
|
+
return {
|
|
557
|
+
platform,
|
|
558
|
+
/** `{ alive, startedMs, group }` from `ps` (C locale). */
|
|
559
|
+
identity(pid) {
|
|
560
|
+
if (!exists(pid)) return { alive: false, startedMs: null, group: null };
|
|
561
|
+
const row = psList().rows.find((p) => p.pid === pid);
|
|
562
|
+
if (!row) return { alive: false, startedMs: null, group: null };
|
|
563
|
+
if (!Number.isFinite(row.startedMs)) throw new Error(`cannot read the start time of pid ${pid}`);
|
|
564
|
+
return { alive: true, startedMs: row.startedMs, group: row.group };
|
|
565
|
+
},
|
|
566
|
+
/** This boot's identity for a snapshot root's birth record (null when unknown). */
|
|
567
|
+
bootId() { return platform === "linux" ? hostBootId({ platform, readFile, bootIdFile: `${proc}/sys/kernel/random/boot_id` }) : hostBootId({ platform, spawn }); },
|
|
568
|
+
bootTime() {
|
|
569
|
+
if (platform === "linux") {
|
|
570
|
+
return bootMs();
|
|
571
|
+
}
|
|
572
|
+
const r = run(spawn, "sysctl", ["-n", "kern.boottime"]);
|
|
573
|
+
const m = /sec = (\d+)/.exec(String(r.stdout ?? ""));
|
|
574
|
+
if (r.error || r.status !== 0 || !m) throw new Error("cannot read kern.boottime");
|
|
575
|
+
return Number(m[1]) * 1000;
|
|
576
|
+
},
|
|
577
|
+
/**
|
|
578
|
+
* Process group `group` now: `members` (this gate, its ancestors and its own `ps` left out), every process by pid (for
|
|
579
|
+
* ancestry) and the set of this gate's own pids.
|
|
580
|
+
*/
|
|
581
|
+
groupSnapshot(group) {
|
|
582
|
+
if (!Number.isInteger(group) || group <= 1) throw new Error(`no usable process group (${group})`);
|
|
583
|
+
const { rows, own } = psList();
|
|
584
|
+
const members = rows.filter((p) => p.group === group && !own.has(p.pid));
|
|
585
|
+
for (const p of members) if (!Number.isFinite(p.startedMs)) throw new Error(`cannot read the start time of pid ${p.pid}`);
|
|
586
|
+
return { members, byPid: new Map(rows.map((row) => [row.pid, row])), own };
|
|
587
|
+
},
|
|
588
|
+
/**
|
|
589
|
+
* Pids of processes whose environment carries `<OWNERSHIP_ENV>=<handle>` (this gate's own chain left out). Linux reads
|
|
590
|
+
* /proc/<pid>/environ (an unreadable entry of this user's throws: unknown); macOS reads `ps eww`, which shows the environment
|
|
591
|
+
* of every process the user may inspect except Apple platform binaries (/bin/*, /usr/bin/*), which `hiddenHolders` judges by
|
|
592
|
+
* lineage and the snapshot-use net covers. null where neither exists.
|
|
593
|
+
*/
|
|
594
|
+
tokenHolders(handle, skipBefore) {
|
|
595
|
+
const token = `${OWNERSHIP_ENV}=${handle}`;
|
|
596
|
+
if (procAvailable()) {
|
|
597
|
+
const needle = Buffer.from(`${token}\0`);
|
|
598
|
+
const found = [];
|
|
599
|
+
for (const pid of procPids()) {
|
|
600
|
+
let environ;
|
|
601
|
+
try { environ = readFile(`${proc}/${pid}/environ`); } catch (e) { unreadable(pid, e, skipBefore); continue; }
|
|
602
|
+
if (Buffer.concat([environ, Buffer.from("\0")]).includes(needle)) found.push(pid);
|
|
603
|
+
}
|
|
604
|
+
return found;
|
|
605
|
+
}
|
|
606
|
+
if (platform !== "darwin") return null;
|
|
607
|
+
const { own } = psList();
|
|
608
|
+
const r = run(spawn, "ps", ["eww", "-A", "-o", "pid=", "-o", "command="], { LC_ALL: "C", LANG: "C" });
|
|
609
|
+
if (r.error || r.status !== 0) throw new Error(`ps eww: ${r.error?.message ?? (String(r.stderr ?? "").trim() || `exit ${r.status}`)}`);
|
|
610
|
+
const pattern = new RegExp(`(^|\\s)${token.replace(/[.*+?^${}()|[\]\\-]/g, "\\$&")}(\\s|$)`);
|
|
611
|
+
return String(r.stdout ?? "").split(/\r?\n/).map((l) => /^\s*(\d+)\s(.*)$/.exec(l)).filter((m) => m && !own.has(Number(m[1])) && Number(m[1]) !== r.pid && pattern.test(m[2])).map((m) => Number(m[1]));
|
|
612
|
+
},
|
|
613
|
+
/**
|
|
614
|
+
* macOS only (null elsewhere: Linux reads /proc/<pid>/environ under the unreadable rule, Windows uses job containment). The
|
|
615
|
+
* live same-user processes the token net cannot judge because `ps eww` shows no environment for them (hidden: an Apple
|
|
616
|
+
* platform binary, or a truly empty environment), plus any `lsof` cannot inspect, classified by `classifyHiddenProcesses`
|
|
617
|
+
* with this gate's anchor. `{ owner, anchorMs, recorded }` as there. Returns `[{ pid, why }]` (held), or throws: a listing
|
|
618
|
+
* that did not finish cleanly is unknown, and the caller stays busy.
|
|
619
|
+
*/
|
|
620
|
+
hiddenHolders({ owner, anchorMs, recorded = [] } = {}) {
|
|
621
|
+
if (platform !== "darwin") return null;
|
|
622
|
+
// Without this process's own user id nothing could be told from another user's (which is skipped): unknown, never "none".
|
|
623
|
+
if (!Number.isInteger(uid)) throw new Error("this process's user id is unknown");
|
|
624
|
+
const C = { LC_ALL: "C", LANG: "C" };
|
|
625
|
+
const own = new Set();
|
|
626
|
+
const listing = (what, r) => { if (Number.isInteger(r.pid)) own.add(r.pid); if (r.error || r.status !== 0) throw new Error(`${what}: ${r.error?.message ?? (String(r.stderr ?? "").trim().split(/\r?\n/).pop() || `exit ${r.status}`)}`); return String(r.stdout ?? ""); };
|
|
627
|
+
const table = listing("ps", run(spawn, "ps", ["-A", "-ww", "-o", "pid=", "-o", "ppid=", "-o", "pgid=", "-o", "uid=", "-o", "stat=", "-o", "lstart=", "-o", "command="], C));
|
|
628
|
+
const withEnv = listing("ps eww", run(spawn, "ps", ["eww", "-A", "-o", "pid=", "-o", "command="], C));
|
|
629
|
+
const lsofOut = listing("lsof", run(spawn, "lsof", ["-n", "-P", "-a", "-u", String(uid), "-Fp"]));
|
|
630
|
+
const procs = [];
|
|
631
|
+
for (const line of table.split(/\r?\n/)) {
|
|
632
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\w{3} \w{3}\s+\d+ \d\d:\d\d:\d\d \d{4})\s+(.*)$/.exec(line);
|
|
633
|
+
if (m) procs.push({ pid: Number(m[1]), ppid: Number(m[2]), group: Number(m[3]), uid: Number(m[4]), state: m[5], startedMs: Date.parse(m[6].replace(/\s+/g, " ")), command: m[7].trim() });
|
|
634
|
+
}
|
|
635
|
+
if (!procs.length) throw new Error("ps listed no process");
|
|
636
|
+
// This gate's own chain (itself and its ancestors) and the listings it just ran are never another owner's children.
|
|
637
|
+
const byPid = new Map(procs.map((p) => [p.pid, p]));
|
|
638
|
+
for (let pid = process.pid; pid > 1 && !own.has(pid); pid = byPid.get(pid)?.ppid ?? 0) own.add(pid);
|
|
639
|
+
const env = new Map();
|
|
640
|
+
for (const line of withEnv.split(/\r?\n/)) { const m = /^\s*(\d+)\s+(.*)$/.exec(line); if (m) env.set(Number(m[1]), m[2].trim()); }
|
|
641
|
+
const inspected = new Set(lsofOut.split(/\r?\n/).filter((l) => l.startsWith("p")).map((l) => Number(l.slice(1))));
|
|
642
|
+
for (const p of procs) {
|
|
643
|
+
// A process shows its environment when its `ps eww` line is its command line followed by more; anything else (no line, the
|
|
644
|
+
// same line, another command line) is hidden. An empty environment prints no more than the command, so it is hidden too.
|
|
645
|
+
const shown = env.get(p.pid);
|
|
646
|
+
p.hidden = !(shown !== undefined && shown.length > p.command.length && shown.startsWith(`${p.command} `));
|
|
647
|
+
p.uninspected = !inspected.has(p.pid);
|
|
648
|
+
}
|
|
649
|
+
// `launchctl blame <pid>` (LC_ALL=C, 5 s): exit 0 and a reason is a launchd job; exit 3 and the "not managed" line is a process
|
|
650
|
+
// launchd does not manage (a reparented orphan) or one that is gone; anything else is unknown (rc null: it could not run).
|
|
651
|
+
const blame = (pid) => {
|
|
652
|
+
const r = spawn("launchctl", ["blame", String(pid)], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", LANG: "C" }, timeout: 5000, maxBuffer: 1 << 16 });
|
|
653
|
+
if (r.error) return { rc: null, line: String(r.error.message ?? r.error) };
|
|
654
|
+
const text = `${String(r.stdout ?? "")}${String(r.stderr ?? "")}`.split(/\r?\n/).find((l) => l.trim() !== "") ?? "";
|
|
655
|
+
return { rc: r.status, line: text.trim() };
|
|
656
|
+
};
|
|
657
|
+
// Re-lists pids after the blame calls (the binding against pid reuse): `Map(pid -> { ppid, startedMs })`; a pid that is gone is absent.
|
|
658
|
+
const reread = (pids) => {
|
|
659
|
+
const r = run(spawn, "ps", ["-o", "pid=", "-o", "ppid=", "-o", "lstart=", "-p", pids.join(",")], C);
|
|
660
|
+
if (r.error || (r.status !== 0 && String(r.stdout ?? "").trim() !== "")) throw new Error(`ps -p: ${r.error?.message ?? `exit ${r.status}`}`);
|
|
661
|
+
const out = new Map();
|
|
662
|
+
for (const line of String(r.stdout ?? "").split(/\r?\n/)) {
|
|
663
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(\w{3} \w{3}\s+\d+ \d\d:\d\d:\d\d \d{4})\s*$/.exec(line);
|
|
664
|
+
if (m) out.set(Number(m[1]), { ppid: Number(m[2]), startedMs: Date.parse(m[3].replace(/\s+/g, " ")) });
|
|
665
|
+
}
|
|
666
|
+
return out;
|
|
667
|
+
};
|
|
668
|
+
return classifyHiddenProcesses({ procs, own, uid, owner, recorded, anchorMs, alive: exists, blame, reread });
|
|
669
|
+
},
|
|
670
|
+
/**
|
|
671
|
+
* Processes using the snapshot `dir` in a way a new session, a scrubbed environment and a working directory elsewhere do not
|
|
672
|
+
* hide, as `[{ pid, how }]` (this gate's own chain left out): its working or root directory, its executable, an open file
|
|
673
|
+
* descriptor or a mapped file inside `dir`, or an argument naming `dir`. Linux: /proc/<pid>/{cwd,root,exe,fd,maps,cmdline}
|
|
674
|
+
* (an unreadable entry of this user's throws: unknown). macOS: `lsof` for this user (cwd, txt, mem and every descriptor) and
|
|
675
|
+
* `ps -ww` arguments. null where neither exists.
|
|
676
|
+
*/
|
|
677
|
+
snapshotUsers(dir, skipBefore) {
|
|
678
|
+
// Only POSIX hosts have these nets; the paths they see are POSIX paths on every host (injected tables run on Windows too).
|
|
679
|
+
const posix = path.posix;
|
|
680
|
+
const roots = [posix.resolve(dir)];
|
|
681
|
+
try { const real = fs.realpathSync(dir); if (!roots.includes(real)) roots.push(real); } catch { /* the directory is gone: only its recorded spelling can be named */ }
|
|
682
|
+
// Outside is exactly `..`, `../x` or an absolute relative path: a directory named `..x` inside the snapshot is inside it.
|
|
683
|
+
const inside = (p) => posix.isAbsolute(p) && roots.some((root) => { const rel = posix.relative(root, p); return !(rel === ".." || rel.startsWith("../") || posix.isAbsolute(rel)); });
|
|
684
|
+
const named = (text) => roots.some((root) => namesDir(text, root));
|
|
685
|
+
if (!procAvailable() && platform !== "darwin") return null;
|
|
686
|
+
const { own } = psList();
|
|
687
|
+
const found = []; const seen = new Set();
|
|
688
|
+
const add = (pid, how) => { if (own.has(pid) || seen.has(pid)) return; seen.add(pid); found.push({ pid, how }); };
|
|
689
|
+
if (procAvailable()) {
|
|
690
|
+
// /proc/<pid>/fd and maps list the files a process holds open or mapped right now; cmdline survives its other changes.
|
|
691
|
+
const targetOf = (file) => { try { return readlink(file); } catch (e) { if (GONE.has(e.code)) return null; throw e; } };
|
|
692
|
+
for (const pid of procPids()) {
|
|
693
|
+
if (own.has(pid)) continue;
|
|
694
|
+
try {
|
|
695
|
+
for (const [link, how] of [["cwd", "working directory"], ["root", "root directory"], ["exe", "executable"]]) {
|
|
696
|
+
const target = targetOf(`${proc}/${pid}/${link}`);
|
|
697
|
+
if (target !== null && inside(target)) { add(pid, how); break; }
|
|
698
|
+
}
|
|
699
|
+
if (!seen.has(pid)) {
|
|
700
|
+
let fds = []; try { fds = readdir(`${proc}/${pid}/fd`); } catch (e) { if (!GONE.has(e.code)) throw e; }
|
|
701
|
+
for (const fd of fds) { const target = targetOf(`${proc}/${pid}/fd/${fd}`); if (target !== null && inside(target)) { add(pid, `open file ${target}`); break; } }
|
|
702
|
+
}
|
|
703
|
+
if (!seen.has(pid)) {
|
|
704
|
+
let maps = ""; try { maps = String(readFile(`${proc}/${pid}/maps`, "utf8")); } catch (e) { if (!GONE.has(e.code)) throw e; }
|
|
705
|
+
for (const line of maps.split("\n")) { const m = /^\S+\s+\S+\s+\S+\s+\S+\s+\d+\s+(\/.*?)(?: \(deleted\))?$/.exec(line); if (m && inside(m[1])) { add(pid, `mapped file ${m[1]}`); break; } }
|
|
706
|
+
}
|
|
707
|
+
if (!seen.has(pid)) {
|
|
708
|
+
let cmdline = ""; try { cmdline = String(readFile(`${proc}/${pid}/cmdline`, "utf8")); } catch (e) { if (!GONE.has(e.code)) throw e; }
|
|
709
|
+
if (named(cmdline)) add(pid, "argument");
|
|
710
|
+
}
|
|
711
|
+
} catch (e) { unreadable(pid, e, skipBefore); }
|
|
712
|
+
}
|
|
713
|
+
return found;
|
|
714
|
+
}
|
|
715
|
+
const r = run(spawn, "lsof", ["-n", "-P", "-a", "-u", String(uid), "-Fpfn"]);
|
|
716
|
+
// Only a clean exit is a complete listing: any other status (even with output) is unknown, so the caller stays busy.
|
|
717
|
+
if (r.error || r.status !== 0) throw new Error(`lsof: ${r.error?.message ?? (String(r.stderr ?? "").trim().split(/\r?\n/).pop() || `exit ${r.status}`)}`);
|
|
718
|
+
if (Number.isInteger(r.pid)) own.add(r.pid);
|
|
719
|
+
let pid = null; let fd = "";
|
|
720
|
+
for (const line of String(r.stdout ?? "").split(/\r?\n/)) {
|
|
721
|
+
if (line.startsWith("p")) { pid = Number(line.slice(1)); fd = ""; }
|
|
722
|
+
else if (line.startsWith("f")) fd = line.slice(1);
|
|
723
|
+
else if (line.startsWith("n") && pid && inside(line.slice(1))) add(pid, fd === "cwd" ? "working directory" : fd === "rtd" ? "root directory" : fd === "txt" ? "executable or text file" : fd === "mem" ? `mapped file ${line.slice(1)}` : `open file ${line.slice(1)}`);
|
|
724
|
+
}
|
|
725
|
+
const a = run(spawn, "ps", ["-A", "-ww", "-o", "pid=", "-o", "command="], { LC_ALL: "C", LANG: "C" });
|
|
726
|
+
if (a.error || a.status !== 0) throw new Error(`ps arguments: ${a.error?.message ?? (String(a.stderr ?? "").trim() || `exit ${a.status}`)}`);
|
|
727
|
+
if (Number.isInteger(a.pid)) own.add(a.pid);
|
|
728
|
+
for (const line of String(a.stdout ?? "").split(/\r?\n/)) { const m = /^\s*(\d+)\s(.*)$/.exec(line); if (m && named(m[2])) add(Number(m[1]), "argument"); }
|
|
729
|
+
return found;
|
|
730
|
+
},
|
|
731
|
+
/** This gate's ancestors, nearest first, as `[{ pid, startedMs }]` (pid 1 and the gate itself left out). */
|
|
732
|
+
ancestors() {
|
|
733
|
+
const { byPid } = psList();
|
|
734
|
+
const chain = [];
|
|
735
|
+
for (let row = byPid.get(process.pid); row && row.ppid > 1 && chain.length < 64; ) {
|
|
736
|
+
row = byPid.get(row.ppid);
|
|
737
|
+
if (row) chain.push({ pid: row.pid, startedMs: Number.isFinite(row.startedMs) ? row.startedMs : null });
|
|
738
|
+
}
|
|
739
|
+
return chain;
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Spawns through `spawn`. A call that detaches the child (POSIX: a new session and process group, which the gate's own group no
|
|
746
|
+
* longer covers) while a build ownership is held records it first: an intent file before the spawn, then the child's group (its
|
|
747
|
+
* pid) once it exists, so a crashed gate's record is kept until that group is empty; a crash between the two leaves the intent,
|
|
748
|
+
* which keeps the record (unknown group). Windows children stay in the gate's job, so nothing is recorded there. Every detached
|
|
749
|
+
* launch on a gate path goes through here (scans, right-release builds, heavy-command).
|
|
750
|
+
*/
|
|
751
|
+
export function spawnUnderOwnership(spawn, command, args, options = {}, { env = process.env, platform = process.platform } = {}) {
|
|
752
|
+
const handle = env[OWNERSHIP_ENV] ?? options?.env?.[OWNERSHIP_ENV];
|
|
753
|
+
if (!options?.detached || platform === "win32" || !handle) return spawn(command, args, options);
|
|
754
|
+
const merged = { ...(options.env ?? {}), ...env };
|
|
755
|
+
const dir = childrenDir(merged[OWNERSHIP_FILE_ENV] || ownershipFile({ platform }), handle);
|
|
756
|
+
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; } // the ownership directory exists while this gate holds it
|
|
757
|
+
const intent = path.join(dir, `intent-${process.pid}-${crypto.randomUUID()}.json`);
|
|
758
|
+
// The child's start lower bound is taken BEFORE it is created, so however long this process pauses before recording the
|
|
759
|
+
// group, every member of it started at or after the bound (a later bound would read a live group as pid reuse).
|
|
760
|
+
const notBefore = Date.now() - START_SLACK_MS;
|
|
761
|
+
fs.writeFileSync(intent, JSON.stringify({ by: process.pid, at: new Date().toISOString(), notBefore, command: String(command) }), { flag: "wx", mode: 0o600 });
|
|
762
|
+
let child;
|
|
763
|
+
try { child = spawn(command, args, options); } catch (e) { fs.rmSync(intent, { force: true }); throw e; }
|
|
764
|
+
if (child?.pid) fs.writeFileSync(path.join(dir, `group-${child.pid}.json`), JSON.stringify({ group: child.pid, startedMs: notBefore, command: String(command) }), { mode: 0o600 });
|
|
765
|
+
fs.rmSync(intent, { force: true });
|
|
766
|
+
return child;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
export const CLEAN_SCHEMA = "rightkit.build-ownership-clean/1";
|
|
770
|
+
/** Beside the owner record: the acquisition time of the last owner whose release proved zero readers. */
|
|
771
|
+
export const cleanMarkerFile = (file) => path.join(path.dirname(file), "last-clean-release.json");
|
|
772
|
+
/** The marker's acquisition time (ISO), or null when absent, unreadable or malformed (no clean release is then known). */
|
|
773
|
+
export function readCleanMarker(file) {
|
|
774
|
+
try {
|
|
775
|
+
const m = JSON.parse(fs.readFileSync(cleanMarkerFile(file), "utf8"));
|
|
776
|
+
return m?.schema === CLEAN_SCHEMA && Number.isFinite(Date.parse(m.acquiredAt)) ? m.acquiredAt : null;
|
|
777
|
+
} catch { return null; }
|
|
778
|
+
}
|
|
779
|
+
function writeCleanMarker(file, owner) {
|
|
780
|
+
const target = cleanMarkerFile(file); const tmp = `${target}.${crypto.randomUUID()}.tmp`;
|
|
781
|
+
try {
|
|
782
|
+
fs.writeFileSync(tmp, JSON.stringify({ schema: CLEAN_SCHEMA, acquiredAt: owner.acquiredAt, releasedAt: new Date().toISOString(), handle: owner.handle }), { flag: "wx", mode: 0o600 });
|
|
783
|
+
fs.renameSync(tmp, target);
|
|
784
|
+
} catch { /* no marker: nothing is skipped, which only holds more */ } finally { fs.rmSync(tmp, { force: true }); }
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
export const ROOT_BIRTH_SCHEMA = "rightkit.snapshot-root-birth/1";
|
|
788
|
+
/** Inside a snapshot root: when this root came into being, and in which boot of the host. */
|
|
789
|
+
export const rootBirthFile = (root) => path.join(root, ".rightkit-root-birth.json");
|
|
790
|
+
/** This boot's identity: Linux boot_id, macOS kern.bootsessionuuid; null where it cannot be read (Windows, a failure): no baseline then. */
|
|
791
|
+
export function hostBootId({ platform = process.platform, readFile = fs.readFileSync, spawn = spawnSync, bootIdFile = "/proc/sys/kernel/random/boot_id" } = {}) {
|
|
792
|
+
try {
|
|
793
|
+
if (platform === "linux") { const id = String(readFile(bootIdFile, "utf8")).trim(); return /^[0-9a-f-]{16,}$/i.test(id) ? id : null; }
|
|
794
|
+
if (platform === "darwin") {
|
|
795
|
+
// kern.bootsessionuuid: one UUID per boot, stable for the whole boot (kern.boottime moves when the wall clock is stepped).
|
|
796
|
+
const r = run(spawn, "sysctl", ["-n", "kern.bootsessionuuid"]);
|
|
797
|
+
const id = String(r.stdout ?? "").trim();
|
|
798
|
+
return !r.error && r.status === 0 && /^[0-9a-f-]{16,}$/i.test(id) ? id : null;
|
|
799
|
+
}
|
|
800
|
+
} catch { /* unknown */ }
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Writes the birth record of a snapshot root that was just created (never over an existing one). Best effort: without it the
|
|
805
|
+
* root has no baseline and build ownership applies its strict rule. Returns whether it wrote.
|
|
806
|
+
*/
|
|
807
|
+
export function recordRootBirth(root, { now = Date.now, bootId = hostBootId() } = {}) {
|
|
808
|
+
try {
|
|
809
|
+
fs.writeFileSync(rootBirthFile(root), JSON.stringify({ schema: ROOT_BIRTH_SCHEMA, createdAt: new Date(now()).toISOString(), bootId }), { flag: "wx", mode: 0o600 });
|
|
810
|
+
return true;
|
|
811
|
+
} catch { return false; }
|
|
812
|
+
}
|
|
813
|
+
/** When `root` was born (epoch ms), or null when there is no readable birth record of THIS boot (missing, malformed, another boot). */
|
|
814
|
+
export function readRootBirth(root, bootId) {
|
|
815
|
+
if (typeof bootId !== "string" || !bootId) return null;
|
|
816
|
+
try {
|
|
817
|
+
const b = JSON.parse(fs.readFileSync(rootBirthFile(root), "utf8"));
|
|
818
|
+
const at = Date.parse(b?.createdAt);
|
|
819
|
+
return b?.schema === ROOT_BIRTH_SCHEMA && b.bootId === bootId && Number.isFinite(at) ? at : null;
|
|
820
|
+
} catch { return null; }
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Migration for a snapshot root with no usable baseline: it predates birth records (an older gate made it), or its birth record
|
|
825
|
+
* names another boot (a reboot happened since). On Linux, one non-dumpable agent of this user (ssh-agent) would otherwise hold
|
|
826
|
+
* every release. A root is a cache, so under THIS process's own ownership, taken on a free host (no owner record existed; never a
|
|
827
|
+
* reclaim, never a nested gate), before this sync's first write, its snapshots are deleted and the root is born anew, with this
|
|
828
|
+
* boot's identity. Why that is sound: after a reboot no process of the old boot is alive, so the old root has no live readers; a
|
|
829
|
+
* free host proves there is no current owner; and the fresh birth makes the strict rule apply only to processes that started after
|
|
830
|
+
* it. Refused (the root stays as it was, strict) when: this process does not hold the only record, or the record is not a free-host
|
|
831
|
+
* acquisition; this boot's identity cannot be read; the root is missing or is a link (a missing root is born by the sync); its birth
|
|
832
|
+
* record is unreadable, or names THIS boot; or anything in the root is not a snapshot artifact (`<16 hex>`, `<16 hex>.lock`,
|
|
833
|
+
* `.state.json`, `.staging`, or the birth record), because a root that is some other directory is never emptied.
|
|
834
|
+
* Returns `{ migrated, why }`.
|
|
835
|
+
*/
|
|
836
|
+
export function migrateBirthlessRoot(root, { env = process.env, file = env[OWNERSHIP_FILE_ENV], bootId = hostBootId() } = {}) {
|
|
837
|
+
const no = (why) => ({ migrated: false, why });
|
|
838
|
+
const handle = env[OWNERSHIP_ENV];
|
|
839
|
+
if (!handle || !file) return no("this process holds no build ownership");
|
|
840
|
+
let record = null; try { record = JSON.parse(fs.readFileSync(file, "utf8")); } catch { record = null; }
|
|
841
|
+
if (record?.handle !== handle || record.pid !== process.pid) return no("this process does not hold the owner record");
|
|
842
|
+
if (record.freeHost !== true) return no("the ownership was not taken on a free host");
|
|
843
|
+
if (typeof bootId !== "string" || !bootId) return no("this boot's identity cannot be read");
|
|
844
|
+
let st = null; try { st = fs.lstatSync(root); } catch { st = null; }
|
|
845
|
+
if (!st?.isDirectory()) return no("the root is missing or is not a plain directory");
|
|
846
|
+
let reason = "the root had no birth record";
|
|
847
|
+
if (fs.existsSync(rootBirthFile(root))) {
|
|
848
|
+
let birth = null; try { birth = JSON.parse(fs.readFileSync(rootBirthFile(root), "utf8")); } catch { birth = null; }
|
|
849
|
+
if (birth?.schema !== ROOT_BIRTH_SCHEMA) return no("the root's birth record cannot be read");
|
|
850
|
+
if (birth.bootId === bootId) return no("the root already has a birth record of this boot");
|
|
851
|
+
reason = `the root's birth record names another boot (${JSON.stringify(birth.bootId)}, not ${JSON.stringify(bootId)})`;
|
|
852
|
+
}
|
|
853
|
+
let entries; try { entries = fs.readdirSync(root); } catch (e) { return no(`the root cannot be listed (${e.code ?? e.message})`); }
|
|
854
|
+
const birthName = path.basename(rootBirthFile(root));
|
|
855
|
+
const stranger = entries.find((n) => n !== birthName && !/^[0-9a-f]{16}(\.[A-Za-z0-9._-]+)?$/.test(n));
|
|
856
|
+
if (stranger !== undefined) return no(`${JSON.stringify(stranger)} in the root is not a snapshot artifact`);
|
|
857
|
+
try { for (const n of entries) fs.rmSync(path.join(root, n), { recursive: true, force: true }); }
|
|
858
|
+
catch (e) { return no(`the root could not be emptied (${e.code ?? e.message}); it stays without a baseline`); }
|
|
859
|
+
return recordRootBirth(root, { bootId }) ? { migrated: true, why: `${reason}; its snapshots were deleted and it was born anew in this boot` } : no("the new birth could not be recorded");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** The holder's last report, or null when absent or unreadable. */
|
|
863
|
+
export function readHolder(file) {
|
|
864
|
+
try { const h = JSON.parse(fs.readFileSync(file, "utf8")); return h?.schema === HOLDER_SCHEMA ? h : null; } catch { return null; }
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* RustRight's lease state for a snapshot & ownership handle (`rustright snapshot-lease status`). Anything but a well-formed
|
|
869
|
+
* answer is UNKNOWN: a missing client, a failed or unknown command, a schema or state the gate does not know.
|
|
870
|
+
*/
|
|
871
|
+
export function brokerLeaseState({ snapshot, handle, env = process.env, platform = process.platform, spawn = spawnSync, client = managedRuntimeClient(env, platform) }) {
|
|
872
|
+
if (!client) return { state: "UNKNOWN", why: "no RustRight client on this host" };
|
|
873
|
+
const r = run(spawn, client, ["snapshot-lease", "status", "--snapshot", snapshot, "--ownership", handle], env);
|
|
874
|
+
if (r.error || r.status !== 0) return { state: "UNKNOWN", why: `rustright snapshot-lease status: ${r.error?.message ?? (String(r.stderr ?? "").trim().split(/\r?\n/).pop() || `exit ${r.status}`)}` };
|
|
875
|
+
let answer = null;
|
|
876
|
+
try { answer = JSON.parse(String(r.stdout ?? "").trim().split(/\r?\n/).pop()); } catch { answer = null; }
|
|
877
|
+
if (answer?.schema !== LEASE_STATUS_SCHEMA || !LEASE_STATES.has(answer.state)) return { state: "UNKNOWN", why: `unrecognised lease status ${JSON.stringify(String(r.stdout ?? "").trim().slice(0, 200))}` };
|
|
878
|
+
// The answer must echo what was asked (RustRight echoes `snapshot` as received and `ownership` as the handle): another pair's answer,
|
|
879
|
+
// or no echo at all, binds nothing to this owner, so it is UNKNOWN and the record is held.
|
|
880
|
+
if (answer.snapshot !== snapshot || answer.ownership !== handle) return { state: "UNKNOWN", why: `the lease status does not echo the queried snapshot and ownership handle (${JSON.stringify(answer.snapshot ?? null)}, ${JSON.stringify(answer.ownership ?? null)})` };
|
|
881
|
+
return { state: answer.state, why: `rustright snapshot-lease status: ${answer.state}` };
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function publish(target, text) {
|
|
885
|
+
if (process.platform === "win32") {
|
|
886
|
+
// Windows: no second name. Linking a temp file and deleting its name left owner.json a file that could not be replaced for
|
|
887
|
+
// seconds (measured on the Dell, rename EPERM with no open handle on it; a plain exclusive create has no such state). The
|
|
888
|
+
// record is created exclusively and written at once; a reader that catches it half-written cannot parse it and stays busy
|
|
889
|
+
// (fail closed), and a failed write removes it.
|
|
890
|
+
let fd;
|
|
891
|
+
try { fd = fs.openSync(target, "wx", 0o600); } catch (e) { if (e.code === "EEXIST") return false; throw e; }
|
|
892
|
+
try { fs.writeSync(fd, text); } catch (e) { try { fs.closeSync(fd); } catch { /* closed below */ } fs.rmSync(target, { force: true }); throw e; }
|
|
893
|
+
fs.closeSync(fd);
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
const tmp = `${target}.${crypto.randomUUID()}.tmp`;
|
|
897
|
+
fs.writeFileSync(tmp, text, { flag: "wx", mode: 0o600 });
|
|
898
|
+
try { fs.linkSync(tmp, target); return true; } catch (e) { if (e.code === "EEXIST") return false; throw e; } finally { fs.rmSync(tmp, { force: true }); }
|
|
899
|
+
}
|
|
900
|
+
const readText = (file) => { try { return fs.readFileSync(file, "utf8"); } catch (e) { if (e.code === "ENOENT") return null; throw e; } };
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Whether group member `m` is work the owner covers. Ancestors and peers share a group without being the gate's children (the
|
|
904
|
+
* shell that started it, `| tee`, a later gate started by the same script), so a member counts only when it started after the
|
|
905
|
+
* ownership began AND its ancestry does not end at a proven peer. The walk covers the member at the owner, at pid 1 (an orphan),
|
|
906
|
+
* at an unknown parent, and at ANY process that predates the ownership except a peer parent: an orphan adopted by a subreaper
|
|
907
|
+
* (systemd --user, WSL's init) has that subreaper as its parent now, and a subreaper is an ancestor of the gate as often as not,
|
|
908
|
+
* so "a pre-existing parent" proves nothing about being the gate's work. A peer parent is the owner's recorded nearest
|
|
909
|
+
* ancestor (the shell that ran `gate | tee`) or a process that shares the member's own process group (a script shell without job
|
|
910
|
+
* control); a descendant of the asking gate is its own. Everything else is covered: a peer under another parent is held only
|
|
911
|
+
* until it exits, never cleared by guesswork.
|
|
912
|
+
*/
|
|
913
|
+
function coveredMember(m, owner, { byPid, own }) {
|
|
914
|
+
const since = Date.parse(owner.acquiredAt) - START_SLACK_MS;
|
|
915
|
+
const before = (p) => !Number.isFinite(p.startedMs) || p.startedMs < since;
|
|
916
|
+
if (own.has(m.pid) || before(m)) return false;
|
|
917
|
+
// The owner's own ancestors (recorded at acquisition) are never its children, however recently they started.
|
|
918
|
+
if (Array.isArray(owner.ancestors) && owner.ancestors.some((a) => a?.pid === m.pid && sameStart(a.startedMs, m.startedMs))) return false;
|
|
919
|
+
const parent = Array.isArray(owner.ancestors) ? owner.ancestors[0] : null;
|
|
920
|
+
const peerParent = (p) => (Number.isInteger(p.group) && p.group === m.group) || (!!parent && p.pid === parent.pid && sameStart(p.startedMs, parent.startedMs));
|
|
921
|
+
const seen = new Set([m.pid]);
|
|
922
|
+
for (let p = byPid.get(m.ppid); p; p = byPid.get(p.ppid)) {
|
|
923
|
+
if (p.pid === owner.pid && sameStart(p.startedMs, owner.startedMs)) return true;
|
|
924
|
+
if (p.pid <= 1 || seen.has(p.pid)) return true;
|
|
925
|
+
seen.add(p.pid);
|
|
926
|
+
if (p.pid === process.pid) return false;
|
|
927
|
+
if (before(p) || own.has(p.pid)) return !peerParent(p);
|
|
928
|
+
}
|
|
929
|
+
return true;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Whether the work `owner` covers, other than the process `self` (the live gate at release, null at reclaim), is proven gone.
|
|
934
|
+
* Returns the proofs, or throws a reason that is busy.
|
|
935
|
+
*/
|
|
936
|
+
function coveredWorkGone(owner, { probe, lease, file, selfPid = null }) {
|
|
937
|
+
const proofs = [];
|
|
938
|
+
let rebooted = false;
|
|
939
|
+
// A reboot is only a question for a dead owner; a live gate releasing its own record has not outlived one.
|
|
940
|
+
if (selfPid === null) { try { rebooted = probe.bootTime() > Date.parse(owner.acquiredAt); } catch { rebooted = false; } }
|
|
941
|
+
if (rebooted) proofs.push("the host rebooted after the record was written");
|
|
942
|
+
const c = owner.containment ?? {};
|
|
943
|
+
if (!rebooted && c.kind === "job" && c.contained === false && selfPid === null) {
|
|
944
|
+
throw new Error(`job ${c.name} never held its gate's children (the gate's launcher lets children leave its job silently, so they left this one too), so their exit cannot be proven; it stays held until the host reboots`);
|
|
945
|
+
}
|
|
946
|
+
if (!rebooted && c.kind === "job" && selfPid !== null) {
|
|
947
|
+
// A live release is fenced exactly like a crash: work that was never proven inside the job cannot be proven gone by it.
|
|
948
|
+
if (c.contained !== true) throw new Error(`job ${c.name} was never proven to hold this gate's children (contained: ${c.contained}), so their exit cannot be proven; it stays held until the host reboots`);
|
|
949
|
+
// Anything this gate spawned to look at the job would sit in the job itself, so the count comes from the holder (outside the
|
|
950
|
+
// job), asked afresh now (a nonce it must echo after counting), never its last report. Children that just exited get a moment.
|
|
951
|
+
const until = Date.now() + 3000;
|
|
952
|
+
let answer;
|
|
953
|
+
for (;;) {
|
|
954
|
+
try { answer = probe.queryHolder(holderFile(file, owner.handle)); } catch (e) { throw new Error(`job ${c.name}'s holder gave no fresh count (${e.message})`); }
|
|
955
|
+
if (!Number.isInteger(answer?.active) || answer.active < 1) throw new Error(`job ${c.name}'s holder counted ${answer?.active} process(es), but this live gate is one of them`);
|
|
956
|
+
if (answer.active === 1 || Date.now() >= until) break;
|
|
957
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
958
|
+
}
|
|
959
|
+
if (answer.active > 1) throw new Error(`job ${c.name} still holds ${answer.active} process(es) (this gate is one)`);
|
|
960
|
+
proofs.push(`job ${c.name} holds only this gate (holder ${answer.holder} counted it at ${answer.at} for query ${answer.nonce})`);
|
|
961
|
+
} else if (!rebooted && c.kind === "job") {
|
|
962
|
+
let state;
|
|
963
|
+
try { state = probe.jobState(c.name); } catch (e) { throw new Error(`job ${c.name} cannot be read (${e.message})`); }
|
|
964
|
+
if (state.open) {
|
|
965
|
+
const allowed = selfPid === null ? 0 : 1;
|
|
966
|
+
if (state.active > allowed) throw new Error(`job ${c.name} still holds ${state.active} process(es)${selfPid === null ? "" : " (this gate is one)"}`);
|
|
967
|
+
proofs.push(`job ${c.name} holds ${state.active === 0 ? "no process" : "only this gate"}`);
|
|
968
|
+
} else {
|
|
969
|
+
const holder = readHolder(holderFile(file, owner.handle));
|
|
970
|
+
if (holder?.state === "drained") proofs.push(`job ${c.name} drained (holder pid ${holder.holder} at ${holder.at})`);
|
|
971
|
+
else if (holder?.state === "failed") proofs.push(`the gate never ran inside job ${c.name} (holder failed: ${holder.why})`);
|
|
972
|
+
else throw new Error(`job ${c.name} is gone but its holder recorded no drain (${holder?.state ?? "no holder record"})`);
|
|
973
|
+
}
|
|
974
|
+
} else if (!rebooted && c.kind === "group") {
|
|
975
|
+
let snap;
|
|
976
|
+
try { snap = probe.groupSnapshot(c.group); } catch (e) { throw new Error(`process group ${c.group} cannot be listed (${e.message})`); }
|
|
977
|
+
const children = snap.members.filter((m) => m.pid !== selfPid && coveredMember(m, owner, snap)).map((m) => m.pid);
|
|
978
|
+
if (children.length) throw new Error(`process group ${c.group} still has ${children.length} member(s) started under this ownership: ${children.join(", ")}`);
|
|
979
|
+
proofs.push(`process group ${c.group} has no member started under this ownership`);
|
|
980
|
+
// Children the gate started detached lead groups of their own: each recorded group must be empty too.
|
|
981
|
+
const registry = childrenDir(file, owner.handle);
|
|
982
|
+
let entries = [];
|
|
983
|
+
try { entries = fs.readdirSync(registry); } catch (e) { if (e.code !== "ENOENT") throw new Error(`the detached-child record ${registry} cannot be read (${e.message})`); }
|
|
984
|
+
const intents = entries.filter((n) => n.startsWith("intent-"));
|
|
985
|
+
if (intents.length) throw new Error(`a detached spawn was in flight when its gate stopped (${intents[0]}), so its process group is unknown`);
|
|
986
|
+
const groups = entries.filter((n) => /^group-\d+\.json$/.test(n));
|
|
987
|
+
const recorded = [];
|
|
988
|
+
for (const name of groups) {
|
|
989
|
+
let entry;
|
|
990
|
+
try { entry = JSON.parse(fs.readFileSync(path.join(registry, name), "utf8")); } catch { throw new Error(`the detached-child entry ${name} cannot be read`); }
|
|
991
|
+
if (!Number.isInteger(entry?.group) || !Number.isFinite(entry?.startedMs)) throw new Error(`the detached-child entry ${name} cannot be read`);
|
|
992
|
+
recorded.push({ group: entry.group, command: entry.command });
|
|
993
|
+
let snapshot;
|
|
994
|
+
try { snapshot = probe.groupSnapshot(entry.group); } catch (e) { throw new Error(`detached process group ${entry.group} cannot be listed (${e.message})`); }
|
|
995
|
+
const live = snapshot.members.filter((m) => m.pid !== selfPid && m.startedMs >= entry.startedMs - START_SLACK_MS).map((m) => m.pid);
|
|
996
|
+
if (live.length) throw new Error(`detached process group ${entry.group} (${entry.command}) still has ${live.length} member(s): ${live.join(", ")}`);
|
|
997
|
+
}
|
|
998
|
+
if (groups.length) proofs.push(`the ${groups.length} detached process group(s) it started are empty`);
|
|
999
|
+
// See `unreadable` in processProbe. The baseline is the later of (1) the acquisition of the last owner whose release proved zero
|
|
1000
|
+
// readers and (2) the snapshot root's birth in this boot, capped at this ownership's own acquisition (a process the gate
|
|
1001
|
+
// may have started is never baseline). NaN (neither) skips nothing.
|
|
1002
|
+
let birth = NaN;
|
|
1003
|
+
if (owner.snapshot) {
|
|
1004
|
+
try { const b = readRootBirth(path.dirname(owner.snapshot), probe.bootId?.()); if (b !== null) birth = Math.min(b, Date.parse(owner.acquiredAt)); } catch { birth = NaN; }
|
|
1005
|
+
}
|
|
1006
|
+
const baselines = [Date.parse(owner.cleanBefore ?? ""), birth].filter(Number.isFinite);
|
|
1007
|
+
// With no baseline (no clean previous release, no birth of the root in this boot, an unreadable boot id) the anchor is this record's
|
|
1008
|
+
// own acquisition: nothing that started before this ownership began is the unproven work of any owner (every record is published
|
|
1009
|
+
// on a free host after a release that proved zero readers, or by a reclaim that proved the previous work gone). Never "no anchor".
|
|
1010
|
+
const skipBefore = baselines.length ? Math.max(...baselines) : Date.parse(owner.acquiredAt);
|
|
1011
|
+
let carriers;
|
|
1012
|
+
try { carriers = probe.tokenHolders(owner.handle, skipBefore); } catch (e) { throw new Error(`processes carrying ${OWNERSHIP_ENV} cannot be listed (${e.message})`); }
|
|
1013
|
+
if (carriers) {
|
|
1014
|
+
const others = carriers.filter((pid) => pid !== selfPid);
|
|
1015
|
+
if (others.length) throw new Error(`process(es) ${others.join(", ")} still carry ${OWNERSHIP_ENV}=${owner.handle}`);
|
|
1016
|
+
proofs.push(`no process carries ${OWNERSHIP_ENV}=${owner.handle}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (owner.snapshot) {
|
|
1019
|
+
let users;
|
|
1020
|
+
try { users = probe.snapshotUsers(owner.snapshot, skipBefore); } catch (e) { throw new Error(`processes using the snapshot cannot be listed (${e.message})`); }
|
|
1021
|
+
if (users) {
|
|
1022
|
+
const others = users.filter((u) => u.pid !== selfPid);
|
|
1023
|
+
if (others.length) throw new Error(`process(es) ${others.map((u) => `${u.pid} (${u.how})`).join(", ")} still use the snapshot ${owner.snapshot}`);
|
|
1024
|
+
proofs.push(`no process has a working directory, executable, open or mapped file or argument inside or naming ${owner.snapshot}`);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
// macOS: a process whose environment `ps eww` does not show is held after the anchor unless its lineage clears it (row 5.24a).
|
|
1028
|
+
if (typeof probe.hiddenHolders === "function") {
|
|
1029
|
+
let hidden;
|
|
1030
|
+
try { hidden = probe.hiddenHolders({ owner, anchorMs: skipBefore, recorded }); } catch (e) { throw new Error(`processes whose environment is hidden cannot be classified (${e.message})`); }
|
|
1031
|
+
if (hidden) {
|
|
1032
|
+
const others = hidden.filter((h) => h.pid !== selfPid);
|
|
1033
|
+
if (others.length) {
|
|
1034
|
+
const shown = others.slice(0, 8);
|
|
1035
|
+
throw new Error(`${others.length} process(es) may still be this ownership's work (hidden environment, held after the anchor unless its lineage clears it): ${shown.map((h) => `${h.pid}: ${h.why}`).join("; ")}${others.length > shown.length ? `; and ${others.length - shown.length} more (pids ${others.slice(shown.length).map((h) => h.pid).join(", ")})` : ""}`);
|
|
1036
|
+
}
|
|
1037
|
+
proofs.push("every live process of this user whose environment is hidden started before the anchor or descends from a process that did");
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
} else if (!rebooted) {
|
|
1041
|
+
throw new Error(`the record names no containment (${JSON.stringify(c)})`);
|
|
1042
|
+
}
|
|
1043
|
+
if (owner.broker?.submitted) {
|
|
1044
|
+
const status = lease({ snapshot: owner.snapshot, handle: owner.handle });
|
|
1045
|
+
if (status.state !== "REAPED") throw new Error(`broker work may exist under this handle and its lease is ${status.state} (${status.why})`);
|
|
1046
|
+
proofs.push(`broker lease for ${owner.handle} is REAPED`);
|
|
1047
|
+
}
|
|
1048
|
+
return proofs;
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Why the record `observed` may be reclaimed (`{ owner, proofs }`), or an OwnershipBusy throw naming what is still live or
|
|
1053
|
+
* unknown. Pure apart from the probes.
|
|
1054
|
+
*/
|
|
1055
|
+
export function judgeOwner(observed, { file, probe, lease, host = os.hostname() }) {
|
|
1056
|
+
let owner = null;
|
|
1057
|
+
try { owner = JSON.parse(observed); } catch { owner = null; }
|
|
1058
|
+
const busy = (why, detail = {}) => new OwnershipBusy(`build ownership ${file}: ${why}`, { owner, ...detail });
|
|
1059
|
+
if (owner?.schema !== OWNERSHIP_SCHEMA || typeof owner.handle !== "string" || !Number.isInteger(owner.pid) || owner.pid <= 0 || owner.pid > 0x7fffffff || !Number.isFinite(Date.parse(owner.acquiredAt))) {
|
|
1060
|
+
throw busy("the record cannot be read as an owner record; remove it only when no build is running on this host");
|
|
1061
|
+
}
|
|
1062
|
+
if (owner.host !== host) throw busy(`held by pid ${owner.pid} on ${owner.host}`);
|
|
1063
|
+
let self;
|
|
1064
|
+
try { self = probe.identity(owner.pid); } catch (e) { throw busy(`owner pid ${owner.pid} cannot be proven gone (${e.message})`); }
|
|
1065
|
+
if (self.alive && (!Number.isFinite(owner.startedMs) || !Number.isFinite(self.startedMs) || sameStart(self.startedMs, owner.startedMs))) {
|
|
1066
|
+
throw busy(`held by pid ${owner.pid} (${owner.stage ?? "stage"} of ${owner.commit ?? "a commit"}) since ${owner.acquiredAt}`);
|
|
1067
|
+
}
|
|
1068
|
+
const proofs = [self.alive ? `owner pid ${owner.pid} now runs another process (started ${new Date(self.startedMs).toISOString()}, owner started ${new Date(owner.startedMs).toISOString()})` : `owner pid ${owner.pid} is gone`];
|
|
1069
|
+
try { proofs.push(...coveredWorkGone(owner, { probe, lease, file })); } catch (e) { throw busy(`owner pid ${owner.pid} is gone but ${e.message}`); }
|
|
1070
|
+
return { owner, proofs };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Acquires the host's build ownership (or joins it, when RIGHTKIT_BUILD_OWNERSHIP names the current record). Returns
|
|
1075
|
+
* `{ handle, file, joined, reclaimed, owner, markBrokerSubmit(), held(), release() }`. Throws OwnershipBusy while another build
|
|
1076
|
+
* owns the host, its liveness is unknown, or this gate cannot be contained.
|
|
1077
|
+
*/
|
|
1078
|
+
export function acquireBuildOwnership({
|
|
1079
|
+
file = ownershipFile(),
|
|
1080
|
+
stage,
|
|
1081
|
+
snapshot,
|
|
1082
|
+
commit,
|
|
1083
|
+
platform = process.platform,
|
|
1084
|
+
env = process.env,
|
|
1085
|
+
probe = processProbe({ platform }),
|
|
1086
|
+
lease = (q) => brokerLeaseState({ ...q, env, platform }),
|
|
1087
|
+
exportEnv = true,
|
|
1088
|
+
hooks = {},
|
|
1089
|
+
managedRoot,
|
|
1090
|
+
device,
|
|
1091
|
+
} = {}) {
|
|
1092
|
+
// First, before the environment, the record or any write: the managed root exists (decision D4). `managedRoot` and `device` are
|
|
1093
|
+
// fixtures' seams; a production caller passes neither.
|
|
1094
|
+
guardManagedRoot(file, { platform, managedRoot, device });
|
|
1095
|
+
ensureOwnershipDir(path.dirname(file), { platform });
|
|
1096
|
+
const inherited = env[OWNERSHIP_ENV];
|
|
1097
|
+
if (inherited) {
|
|
1098
|
+
const current = readText(file);
|
|
1099
|
+
let owner = null; try { owner = JSON.parse(current ?? ""); } catch { owner = null; }
|
|
1100
|
+
if (owner?.handle === inherited) {
|
|
1101
|
+
// A nested gate joins the LIVE owner's record (row 5.20): the exact handle of a well-formed record of this host whose owner is
|
|
1102
|
+
// still running as the process that wrote it. A handle left in the environment of a survivor of a dead owner names a record
|
|
1103
|
+
// nobody holds any more; joining it would run a build under an ownership no live gate answers for.
|
|
1104
|
+
if (owner.schema !== OWNERSHIP_SCHEMA || !Number.isInteger(owner.pid) || owner.pid <= 0 || owner.host !== os.hostname()) {
|
|
1105
|
+
throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} names a record that is not a well-formed owner record of this host; a nested gate joins only a live owner's record`);
|
|
1106
|
+
}
|
|
1107
|
+
let live;
|
|
1108
|
+
try { live = probe.identity(owner.pid); } catch (e) { throw new OwnershipBusy(`build ownership ${file}: owner pid ${owner.pid} cannot be proven alive (${e.message}), so a nested gate cannot join it`); }
|
|
1109
|
+
if (!live.alive || (Number.isFinite(owner.startedMs) && Number.isFinite(live.startedMs) && !sameStart(live.startedMs, owner.startedMs))) {
|
|
1110
|
+
throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} names a record whose owner pid ${owner.pid} is no longer the process that took it; a nested gate joins only a live owner's record`);
|
|
1111
|
+
}
|
|
1112
|
+
return { handle: inherited, file, joined: true, reclaimed: null, owner, markBrokerSubmit: () => markBrokerSubmit(file, inherited), held: () => holds(file, inherited), release: () => ({ released: false, joined: true }) };
|
|
1113
|
+
}
|
|
1114
|
+
throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} is not the current owner; a nested gate never takes ownership of its own`);
|
|
1115
|
+
}
|
|
1116
|
+
let me;
|
|
1117
|
+
try { me = probe.identity(process.pid); } catch (e) { throw new OwnershipBusy(`build ownership ${file}: this gate's own start identity cannot be read (${e.message})`); }
|
|
1118
|
+
if (!me.alive || !Number.isFinite(me.startedMs)) throw new OwnershipBusy(`build ownership ${file}: this gate's own start identity cannot be read`);
|
|
1119
|
+
if (platform !== "win32" && !(Number.isInteger(me.group) && me.group > 1)) throw new OwnershipBusy(`build ownership ${file}: this gate's process group cannot be read`);
|
|
1120
|
+
// The gate's ancestors, so a later gate can tell a peer of the owner (the shell it ran under) from an orphan a subreaper adopted.
|
|
1121
|
+
let ancestors = null;
|
|
1122
|
+
try { ancestors = probe.ancestors?.() ?? null; } catch { ancestors = null; }
|
|
1123
|
+
const handle = crypto.randomUUID();
|
|
1124
|
+
const record = {
|
|
1125
|
+
schema: OWNERSHIP_SCHEMA,
|
|
1126
|
+
handle,
|
|
1127
|
+
host: os.hostname(),
|
|
1128
|
+
platform,
|
|
1129
|
+
pid: process.pid,
|
|
1130
|
+
startedMs: me.startedMs,
|
|
1131
|
+
ancestors,
|
|
1132
|
+
containment: platform === "win32" ? { kind: "job", name: jobName(handle) } : { kind: "group", group: me.group },
|
|
1133
|
+
stage: stage ?? null,
|
|
1134
|
+
snapshot: snapshot ?? null,
|
|
1135
|
+
commit: commit ?? null,
|
|
1136
|
+
acquiredAt: new Date().toISOString(),
|
|
1137
|
+
// Set only for an acquisition on a free host after a release that proved zero readers (a reclaim never has it).
|
|
1138
|
+
cleanBefore: readCleanMarker(file),
|
|
1139
|
+
// True when this ownership was published on a free host (no owner record anywhere); a reclaim sets it false.
|
|
1140
|
+
freeHost: true,
|
|
1141
|
+
broker: { submitted: false },
|
|
1142
|
+
};
|
|
1143
|
+
const body = JSON.stringify(record);
|
|
1144
|
+
const release = () => {
|
|
1145
|
+
if (!holds(file, handle)) return { released: false, why: "no longer the owner" };
|
|
1146
|
+
let proofs;
|
|
1147
|
+
// The record as it is now (a broker submit may have been marked since it was written).
|
|
1148
|
+
try { proofs = coveredWorkGone(JSON.parse(readText(file)), { probe, lease, file, selfPid: process.pid }); }
|
|
1149
|
+
catch (e) { return { released: false, why: `left for the next gate to reclaim: ${e.message}` }; }
|
|
1150
|
+
finally { if (exportEnv && process.env[OWNERSHIP_ENV] === handle) { delete process.env[OWNERSHIP_ENV]; delete process.env[OWNERSHIP_FILE_ENV]; } }
|
|
1151
|
+
// Only a release that proved zero readers leaves the marker; a held one leaves none (and keeps the record).
|
|
1152
|
+
writeCleanMarker(file, record);
|
|
1153
|
+
fs.rmSync(file, { force: true });
|
|
1154
|
+
fs.rmSync(childrenDir(file, handle), { recursive: true, force: true });
|
|
1155
|
+
return { released: true, proofs };
|
|
1156
|
+
};
|
|
1157
|
+
const done = (reclaimed) => {
|
|
1158
|
+
// Windows: the gate enters its job before it returns, so every child it starts from here on is contained.
|
|
1159
|
+
if (platform === "win32") {
|
|
1160
|
+
let held;
|
|
1161
|
+
try { held = probe.contain({ name: record.containment.name, pid: process.pid, file: holderFile(file, handle) }); }
|
|
1162
|
+
catch (e) { if (holds(file, handle)) fs.rmSync(file, { force: true }); throw new OwnershipBusy(`build ownership ${file}: ${e.message}`); }
|
|
1163
|
+
// A gate the self-check did not find in its job would start work nothing can later prove gone, whether it exits or crashes.
|
|
1164
|
+
// No work has started yet (the self-check child has exited), so it refuses now and leaves no record, instead of running and
|
|
1165
|
+
// leaving the host held until a reboot.
|
|
1166
|
+
if (held?.contained !== true) {
|
|
1167
|
+
if (holds(file, handle)) fs.rmSync(file, { force: true });
|
|
1168
|
+
throw new OwnershipBusy(`build ownership ${file}: this gate's children would not stay in job ${record.containment.name} (its launcher lets children leave their job silently, e.g. a Node parent's job), so their exit could never be proven; start the gate from a shell or PowerShell, not under such a launcher`);
|
|
1169
|
+
}
|
|
1170
|
+
record.containment = { ...record.containment, contained: true };
|
|
1171
|
+
// Fail closed: a record this gate published but cannot finish is removed, never left held by a gate that is not running.
|
|
1172
|
+
try { rewriteRecord(file, handle, (r) => ({ ...r, containment: record.containment })); }
|
|
1173
|
+
catch (e) { if (holds(file, handle)) fs.rmSync(file, { force: true }); throw new OwnershipBusy(`build ownership ${file}: the containment could not be recorded (${e.code ?? e.message}: ${e.message})`); }
|
|
1174
|
+
}
|
|
1175
|
+
if (exportEnv) { process.env[OWNERSHIP_ENV] = handle; process.env[OWNERSHIP_FILE_ENV] = file; }
|
|
1176
|
+
return { handle, file, joined: false, reclaimed, owner: record, contained: platform === "win32" ? record.containment.contained === true : true, markBrokerSubmit: () => markBrokerSubmit(file, handle), held: () => holds(file, handle), release };
|
|
1177
|
+
};
|
|
1178
|
+
if (publish(file, body)) return done(null);
|
|
1179
|
+
const observed = readText(file);
|
|
1180
|
+
if (observed === null) { if (publish(file, body)) return done(null); throw new OwnershipBusy(`build ownership ${file} was taken by another process`); }
|
|
1181
|
+
const judged = judgeOwner(observed, { file, probe, lease });
|
|
1182
|
+
hooks.afterJudge?.();
|
|
1183
|
+
// A reclaim means the previous release was not clean (a crash, or a release that ended held): nothing unreadable is skipped.
|
|
1184
|
+
record.cleanBefore = null;
|
|
1185
|
+
record.freeHost = false;
|
|
1186
|
+
const reclaimBody = JSON.stringify(record);
|
|
1187
|
+
const reclaim = `${file}.reclaim`;
|
|
1188
|
+
if (!publish(reclaim, reclaimBody)) throw new OwnershipBusy(`build ownership ${file} is being reclaimed by another process (remove ${reclaim} only if no gate is running)`);
|
|
1189
|
+
try {
|
|
1190
|
+
if (readText(file) !== observed) throw new OwnershipBusy(`build ownership ${file} changed before it could be reclaimed`);
|
|
1191
|
+
fs.rmSync(file, { force: true });
|
|
1192
|
+
fs.rmSync(holderFile(file, judged.owner.handle), { force: true });
|
|
1193
|
+
fs.rmSync(holderScriptFile(holderFile(file, judged.owner.handle)), { force: true });
|
|
1194
|
+
fs.rmSync(childrenDir(file, judged.owner.handle), { recursive: true, force: true });
|
|
1195
|
+
if (!publish(file, reclaimBody)) throw new OwnershipBusy(`build ownership ${file} was taken by another process`);
|
|
1196
|
+
} finally {
|
|
1197
|
+
fs.rmSync(reclaim, { force: true });
|
|
1198
|
+
}
|
|
1199
|
+
return done({ from: judged.owner.handle, pid: judged.owner.pid, proofs: judged.proofs });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** Replaces the record atomically while it is still `handle`'s. */
|
|
1203
|
+
function rewriteRecord(file, handle, change) {
|
|
1204
|
+
const text = readText(file);
|
|
1205
|
+
let owner = null; try { owner = JSON.parse(text ?? ""); } catch { owner = null; }
|
|
1206
|
+
if (owner?.handle !== handle) throw new OwnershipBusy(`build ownership ${file} is no longer held by ${handle}`);
|
|
1207
|
+
const tmp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
1208
|
+
fs.writeFileSync(tmp, JSON.stringify(change(owner)), { flag: "wx", mode: 0o600 });
|
|
1209
|
+
try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function holds(file, handle) {
|
|
1213
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")).handle === handle; } catch { return false; }
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Records, before the first broker submit, that broker work may exist under this handle; from then on the record is reclaimed
|
|
1218
|
+
* only on the broker's REAPED. Replaces the record atomically and only while it is still this handle's.
|
|
1219
|
+
*/
|
|
1220
|
+
function markBrokerSubmit(file, handle) {
|
|
1221
|
+
const text = readText(file);
|
|
1222
|
+
let owner = null; try { owner = JSON.parse(text ?? ""); } catch { owner = null; }
|
|
1223
|
+
if (owner?.handle !== handle) throw new OwnershipBusy(`build ownership ${file} is no longer held by ${handle}; no broker request may be submitted`);
|
|
1224
|
+
if (owner.broker?.submitted) return owner;
|
|
1225
|
+
const next = { ...owner, broker: { ...(owner.broker ?? {}), submitted: true, at: new Date().toISOString() } };
|
|
1226
|
+
const tmp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
1227
|
+
fs.writeFileSync(tmp, JSON.stringify(next), { flag: "wx", mode: 0o600 });
|
|
1228
|
+
try { fs.renameSync(tmp, file); } finally { fs.rmSync(tmp, { force: true }); }
|
|
1229
|
+
if (!holds(file, handle)) throw new OwnershipBusy(`build ownership ${file} changed while recording a broker submit`);
|
|
1230
|
+
return next;
|
|
1231
|
+
}
|