@rightkit/release 0.6.16 → 0.6.21
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 +7 -3
- package/admission-host.mjs +9 -5
- package/app-closure.mjs +9 -3
- package/bin/darwin/rightkit-swap +0 -0
- package/bin/rightkit-gate.mjs +481 -103
- package/bin/rightkit-linux-setup.mjs +15 -0
- package/broker-admission.mjs +184 -0
- package/broker-wire.mjs +15 -3
- package/build-launch.mjs +4 -1
- package/build-ownership.mjs +125 -36
- package/build-release.mjs +39 -7
- package/cargo-contract.mjs +4 -1
- package/cargo-guard.mjs +25 -5
- package/cargo-target-shim.mjs +56 -7
- package/clippy-route.mjs +8 -7
- package/crate-graph.mjs +5 -1
- package/credential-provider.mjs +174 -0
- package/deterministic-scans.mjs +253 -42
- package/development.mjs +8 -0
- package/disk-policy.mjs +2 -2
- package/gate-executor.mjs +5 -3
- package/gate-stages.mjs +34 -3
- package/gradle-lock.mjs +206 -0
- package/heavy-command.mjs +33 -2
- package/linux-artifacts.mjs +2 -1
- package/linux-setup.mjs +106 -0
- package/local-publish.mjs +21 -10
- package/local-qualify.mjs +9 -3
- package/managed-cargo.mjs +54 -18
- package/ownership-lockfiles.mjs +14 -3
- package/ownership.mjs +5 -1
- package/package.json +17 -11
- package/protected-credentials.mjs +13 -5
- package/publication-gate.mjs +9 -4
- package/python-lock.mjs +209 -0
- package/qa-admission-policy.json +32 -0
- package/qa-layout.mjs +229 -0
- package/qa-runner.mjs +156 -0
- package/release-artifact-path.mjs +29 -3
- package/release-config.mjs +1 -1
- package/release-qa-command.mjs +58 -8
- package/release-qa-policy.mjs +5 -3
- package/release-source-identity.mjs +1 -0
- package/release.mjs +10 -2
- package/rightkit-ownership.json +1465 -79
- package/rightkit-registry-resolver.mjs +7 -3
- package/rightkit-versions.json +173 -36
- package/runtime-budget-classes.json +4 -0
- package/runtime-budgets.mjs +5 -1
- package/shell-contract.mjs +1 -1
- package/snapshot-broker.mjs +114 -9
- package/snapshot-check.mjs +6 -1
- package/snapshot-request.mjs +20 -3
- package/snapshot.mjs +11 -4
- package/stage-commands.mjs +2 -1
- package/stage-ledger.mjs +15 -0
- package/stage-sidecar-command.mjs +7 -1
- package/stage-sidecar.mjs +17 -3
- package/suite-signing.mjs +40 -4
- package/swift-lock.mjs +334 -0
- package/test-command.mjs +13 -3
- package/tool-build.mjs +156 -0
- package/unit-gate.mjs +9 -2
- package/windows-identity-plan.mjs +119 -0
- package/windows-identity.mjs +136 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// rightkit-linux-setup [--root <app>] [--dry-run]: provision a Linux host (cloud container, VM, WSL2) for `rightkit-host-gate`.
|
|
3
|
+
import { spawnSync } from "../background-process.mjs";
|
|
4
|
+
import { setup } from "../linux-setup.mjs";
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
if (args.includes("--version")) { console.log("rightkit-linux-setup (@rightkit/release)"); process.exit(0); }
|
|
8
|
+
if (process.platform !== "linux") { console.error("rightkit-linux-setup runs on Linux only"); process.exit(2); }
|
|
9
|
+
const at = args.indexOf("--root");
|
|
10
|
+
const run = (cmd, a, o) => spawnSync(cmd, a, { encoding: "utf8", stdio: ["ignore", "pipe", "inherit"], ...o });
|
|
11
|
+
const fetchText = (url) => { const r = run("curl", ["-fsSL", url]); if (r.status !== 0) throw new Error(`fetch ${url} failed`); return r.stdout; };
|
|
12
|
+
try {
|
|
13
|
+
const r = setup({ root: at >= 0 ? args[at + 1] : process.cwd(), run, fetchText, dryRun: args.includes("--dry-run"), log: (m) => console.error(`[linux-setup] ${m}`) });
|
|
14
|
+
if (args.includes("--dry-run")) console.log(r.steps.join("\n"));
|
|
15
|
+
} catch (e) { console.error(`rightkit-linux-setup: ${e.message}`); process.exit(1); }
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The live place of a carried scan in the broker's queue (rightkit#1367 step 2).
|
|
2
|
+
//
|
|
3
|
+
// The broker, not the gate, decides how many builds run (Adrian, 2026-11-01), so a carried scan can wait in the broker's queue. That wait is not the
|
|
4
|
+
// scan's own run, so the scan's run limit is a stopwatch that does not tick while the broker holds every request of the scan QUEUED. The gate asks the
|
|
5
|
+
// broker (`rustright status <id>`: BROKER-CONTRACT BR7 reports live broker state in every status answer, state QUEUED until a permit is held, then RUNNING)
|
|
6
|
+
// about the requests the scan has made. It gives queue time no allowance of its own: how long a job may stay queued is the broker's rule (BR7 reports a queued job past
|
|
7
|
+
// its deadline with the holder that blocks it). The one bound the gate does add is a wall-clock ceiling (QUEUED_CEILING_FACTOR x the scan's limit) that is not a build
|
|
8
|
+
// count: a safety stop for a wedged broker queue (the broker's job deadlines are off by default, so nothing else would end a scan stuck QUEUED).
|
|
9
|
+
//
|
|
10
|
+
// What the client does not offer: its operation deadline (RUSTRIGHT_OPERATION_DEADLINE_MS, client.rs, BROKER-CONTRACT GB-52) is an absolute deadline from the
|
|
11
|
+
// client's start, so it would charge queue time to the scan. A watched scan therefore sends no operation deadline; at the end of its own run time the gate
|
|
12
|
+
// cancels the requests by id (`rustright cancel <id>`) and stops the process tree only after a short grace.
|
|
13
|
+
//
|
|
14
|
+
// Fail closed: a request the gate cannot read (no request id yet, a status that fails or is malformed, a state other than QUEUED) is counted as running, so
|
|
15
|
+
// an unreadable broker never makes a scan immortal.
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import { spawnBrokerClient } from "./broker-client.mjs";
|
|
18
|
+
import { diagnoseControlReply } from "./broker-wire.mjs";
|
|
19
|
+
import { readRequestRef } from "./snapshot-request.mjs";
|
|
20
|
+
|
|
21
|
+
/** How long a cancelled carried scan may take to end by itself before its process tree is stopped. */
|
|
22
|
+
export const CARRIED_CANCEL_GRACE_MS = 15_000;
|
|
23
|
+
/** How often the gate asks the broker for the state of its requests (the first read comes after 1 s, then every 5 s). */
|
|
24
|
+
export const ADMISSION_POLL_MS = 5_000;
|
|
25
|
+
const FIRST_POLL_MS = 1_000;
|
|
26
|
+
/** How many `rustright status` reads the gate has in flight at once, however many scans it watches. */
|
|
27
|
+
export const STATUS_POLL_CONCURRENCY = 2;
|
|
28
|
+
/**
|
|
29
|
+
* A watched scan is stopped when its WALL-CLOCK time (run time plus queue time) reaches its own run limit times this factor. It is a safety stop for a wedged broker queue,
|
|
30
|
+
* not a count: the broker decides how many builds run and in what order, and its job deadlines are off by default, so without this a scan stuck QUEUED would wait forever.
|
|
31
|
+
*/
|
|
32
|
+
export const QUEUED_CEILING_FACTOR = 4;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A run stopwatch: `onExpire` fires once, when `limitMs` of RUNNING time has passed. `pause()` stops it (the scan is queued at the broker) and `resume()`
|
|
36
|
+
* restarts it with the time left; `stop()` ends it for good. Time spent paused is `queuedMs`.
|
|
37
|
+
* With `ceilingMs` (a wall-clock bound that includes the paused time) `onCeiling` fires once when it passes before the stopwatch expired or stopped, and `ceilingHit` is true.
|
|
38
|
+
*/
|
|
39
|
+
export function createRunClock({ limitMs, onExpire, ceilingMs = null, onCeiling = () => {}, setTimer = setTimeout, clearTimer = clearTimeout, now = Date.now }) {
|
|
40
|
+
let remaining = Math.max(0, Number(limitMs) || 0);
|
|
41
|
+
let handle = null; let since = 0; let pausedAt = null; let ceilingHandle = null;
|
|
42
|
+
let expired = false; let stopped = false; let queuedMs = 0; let ceilingHit = false;
|
|
43
|
+
const clearCeiling = () => { if (ceilingHandle !== null) { clearTimer(ceilingHandle); ceilingHandle = null; } };
|
|
44
|
+
const arm = () => {
|
|
45
|
+
since = now();
|
|
46
|
+
handle = setTimer(() => { handle = null; expired = true; clearCeiling(); onExpire(); }, remaining);
|
|
47
|
+
};
|
|
48
|
+
arm();
|
|
49
|
+
if (ceilingMs !== null && Number.isFinite(ceilingMs) && ceilingMs > 0) {
|
|
50
|
+
ceilingHandle = setTimer(() => {
|
|
51
|
+
ceilingHandle = null;
|
|
52
|
+
if (stopped || expired) return;
|
|
53
|
+
ceilingHit = true; expired = true;
|
|
54
|
+
if (handle !== null) { clearTimer(handle); handle = null; }
|
|
55
|
+
if (pausedAt !== null) { queuedMs += now() - pausedAt; pausedAt = null; }
|
|
56
|
+
onCeiling();
|
|
57
|
+
}, ceilingMs);
|
|
58
|
+
ceilingHandle?.unref?.();
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
pause() { if (stopped || expired || handle === null) return; clearTimer(handle); handle = null; remaining = Math.max(0, remaining - (now() - since)); pausedAt = now(); },
|
|
62
|
+
resume() { if (stopped || expired || handle !== null) return; if (pausedAt !== null) { queuedMs += now() - pausedAt; pausedAt = null; } arm(); },
|
|
63
|
+
stop() { if (handle !== null) { clearTimer(handle); handle = null; } clearCeiling(); if (pausedAt !== null) { queuedMs += now() - pausedAt; pausedAt = null; } stopped = true; },
|
|
64
|
+
get expired() { return expired; },
|
|
65
|
+
get ceilingHit() { return ceilingHit; },
|
|
66
|
+
get queuedMs() { return queuedMs + (pausedAt !== null ? now() - pausedAt : 0); },
|
|
67
|
+
get running() { return handle !== null; },
|
|
68
|
+
get remainingMs() { return handle !== null ? Math.max(0, remaining - (now() - since)) : remaining; },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The live state word (`QUEUED`, `RUNNING`, ...) of one `rustright status` reply, or null when it is not a valid STATUS of this request. */
|
|
73
|
+
export function brokerStateOf(stdout, requestId) {
|
|
74
|
+
let doc;
|
|
75
|
+
try { doc = JSON.parse(String(stdout)); } catch { return null; }
|
|
76
|
+
const diagnosis = diagnoseControlReply(doc, requestId);
|
|
77
|
+
return diagnosis.kind === "live" || diagnosis.kind === "terminal" ? diagnosis.state : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The gate's reader of the broker's live state, through the RustRight client with an ASYNCHRONOUS spawn (nothing here blocks the gate's event loop):
|
|
82
|
+
* `state(id)` is the request's state word or null; `cancel(id)` is `rustright cancel <id>`.
|
|
83
|
+
*/
|
|
84
|
+
export function createBrokerLive({ client, env = process.env, spawn, platform = process.platform }) {
|
|
85
|
+
const call = async (args) => {
|
|
86
|
+
try {
|
|
87
|
+
const r = await spawnBrokerClient(spawn, client, args, { encoding: "utf8", env, timeout: 30_000, maxBuffer: 1 << 24, windowsHide: true }, { platform });
|
|
88
|
+
return r && !r.error ? r : null;
|
|
89
|
+
} catch { return null; }
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
async state(requestId) { const r = await call(["status", requestId]); return r ? brokerStateOf(r.stdout, requestId) : null; },
|
|
93
|
+
async cancel(requestId) { await call(["cancel", requestId]); },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The request ids the reference files name (a file not written yet, or already removed, names none). */
|
|
98
|
+
export function requestIdsOf(files) {
|
|
99
|
+
const ids = [];
|
|
100
|
+
for (const file of files) {
|
|
101
|
+
try { if (fs.existsSync(file)) ids.push(readRequestRef(file).requestId); } catch { /* not a complete reference yet */ }
|
|
102
|
+
}
|
|
103
|
+
return [...new Set(ids)];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* ONE reader of the broker's live state per gate run, shared by every watched scan (rightkit#1367 step 2): a scan registers a member (`ids()` lists its request ids, `apply(states)` takes
|
|
108
|
+
* the answers) and the poller asks for each distinct id with at most `concurrency` reads in flight, the first read `firstMs` after the first member arrives and then every `intervalMs`
|
|
109
|
+
* while any member is registered. Passes never overlap. `pass(extra)` is one read pass (a member not registered can be passed in `extra`; tests use it). Status reads go to the client
|
|
110
|
+
* directly: they are not builds and are never recorded as such.
|
|
111
|
+
*/
|
|
112
|
+
export function createStatusPoller({ live, intervalMs = ADMISSION_POLL_MS, firstMs = Math.min(FIRST_POLL_MS, intervalMs), concurrency = STATUS_POLL_CONCURRENCY, setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
113
|
+
const members = new Set();
|
|
114
|
+
let timer = null; let tail = Promise.resolve(); let passes = 0;
|
|
115
|
+
const width = Math.max(1, Math.trunc(Number(concurrency)) || 1);
|
|
116
|
+
async function readAll(extra) {
|
|
117
|
+
const snapshot = [...new Set([...members, ...extra])];
|
|
118
|
+
const wanted = [...new Set(snapshot.flatMap((m) => m.ids()))];
|
|
119
|
+
const states = new Map();
|
|
120
|
+
const queue = [...wanted];
|
|
121
|
+
await Promise.all(Array.from({ length: Math.min(width, queue.length) }, async () => {
|
|
122
|
+
while (queue.length) {
|
|
123
|
+
const id = queue.shift();
|
|
124
|
+
states.set(id, await Promise.resolve().then(() => live.state(id)).catch(() => null));
|
|
125
|
+
}
|
|
126
|
+
}));
|
|
127
|
+
for (const member of snapshot) if (members.has(member) || extra.includes(member)) member.apply(member.ids().map((id) => (states.has(id) ? states.get(id) : null)));
|
|
128
|
+
}
|
|
129
|
+
function pass(extra = []) {
|
|
130
|
+
passes += 1;
|
|
131
|
+
const run = tail.then(() => readAll(extra));
|
|
132
|
+
tail = run.catch(() => {}).finally(() => { passes -= 1; });
|
|
133
|
+
return run;
|
|
134
|
+
}
|
|
135
|
+
const schedule = (ms) => {
|
|
136
|
+
timer = setTimer(() => { timer = null; pass().catch(() => {}).finally(() => { if (members.size > 0 && timer === null) schedule(intervalMs); }); }, ms);
|
|
137
|
+
timer?.unref?.();
|
|
138
|
+
};
|
|
139
|
+
return {
|
|
140
|
+
pass,
|
|
141
|
+
/** Resolves when the passes started so far have ended. */
|
|
142
|
+
idle: () => tail,
|
|
143
|
+
/** Adds a member; returns the function that removes it. The poller stops its own timer when no member is left. */
|
|
144
|
+
register(member) {
|
|
145
|
+
members.add(member);
|
|
146
|
+
if (timer === null && passes === 0) schedule(firstMs);
|
|
147
|
+
return () => { members.delete(member); if (members.size === 0 && timer !== null) { clearTimer(timer); timer = null; } };
|
|
148
|
+
},
|
|
149
|
+
get members() { return members.size; },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Watches the requests one scan makes and keeps its `clock` honest: paused while every request the scan has is QUEUED at the broker, running otherwise.
|
|
155
|
+
* `refFiles()` lists the request reference files (RUSTRIGHT_REQUEST_ID_FILE, written by the client at submit) the scan's carried calls have; `live` is a
|
|
156
|
+
* createBrokerLive (or any object with `state(id)` and `cancel(id)`). The reads come from a shared `poller` (createStatusPoller, one per gate run); with none a private one is made
|
|
157
|
+
* from `live`. `poll()` is one pass (tests call it directly) and resolves to this scan's states; `start()` registers with the poller until `stop()`.
|
|
158
|
+
* `cancelKnown()` cancels, by id, every request seen so far.
|
|
159
|
+
*/
|
|
160
|
+
export function watchBrokerAdmission({ clock, refFiles, live, poller = null, intervalMs = ADMISSION_POLL_MS, firstMs = Math.min(FIRST_POLL_MS, intervalMs), setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
161
|
+
let stopped = false; let unregister = null; let last = [];
|
|
162
|
+
const known = new Set();
|
|
163
|
+
const own = poller ?? createStatusPoller({ live, intervalMs, firstMs, setTimer, clearTimer });
|
|
164
|
+
const member = {
|
|
165
|
+
ids() { const ids = requestIdsOf(refFiles()); for (const id of ids) known.add(id); return ids; },
|
|
166
|
+
apply(states) {
|
|
167
|
+
last = states;
|
|
168
|
+
if (stopped) return;
|
|
169
|
+
if (states.length === 0) { clock.resume(); return; }
|
|
170
|
+
if (states.every((state) => state === "QUEUED")) clock.pause(); else clock.resume();
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return {
|
|
174
|
+
async poll() { if (stopped) return null; await own.pass([member]); return last; },
|
|
175
|
+
done: () => own.idle(),
|
|
176
|
+
start() { if (!stopped && unregister === null) unregister = own.register(member); },
|
|
177
|
+
stop() { stopped = true; if (unregister) { unregister(); unregister = null; } },
|
|
178
|
+
known: () => [...known],
|
|
179
|
+
async cancelKnown() {
|
|
180
|
+
for (const id of requestIdsOf(refFiles())) known.add(id);
|
|
181
|
+
await Promise.allSettled([...known].map((id) => live.cancel(id)));
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
package/broker-wire.mjs
CHANGED
|
@@ -33,11 +33,22 @@ export function overCapProblem(event) {
|
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** The broker's event types are upper-case words (`RESULT`, `JOB_EVENT`, `FRAME_OVER_CAP`, ...). */
|
|
37
|
+
const PROTOCOL_TYPE = /^[A-Z][A-Z0-9_]*$/;
|
|
38
|
+
|
|
39
|
+
/** Whether one line body is a broker protocol frame: a JSON object whose `type` is a protocol event type. */
|
|
40
|
+
function isProtocolFrame(body) {
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(body);
|
|
43
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && typeof value.type === "string" && PROTOCOL_TYPE.test(value.type);
|
|
44
|
+
} catch { return false; }
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
/**
|
|
37
48
|
* Parses captured client output as RustRight's client reads the wire: every complete newline-terminated frame is drained and checked against the limit FIRST, and
|
|
38
49
|
* only then is the one incomplete frame still waiting for its newline bounded (a frame already at the limit without a newline cannot fit it). A limit-sized frame
|
|
39
50
|
* followed by the start of the next one in the same read is valid. Returns `{ frames: [text], overCap: [event], error: string | null }`; lines that are not JSON
|
|
40
|
-
* objects (cargo's own output) are kept as frames and never judged.
|
|
51
|
+
* objects with a protocol `type` (cargo's own output, JSON included) are kept as frames and never judged.
|
|
41
52
|
*/
|
|
42
53
|
export function drainFrames(text, limit = MAX_BROKER_LINE_BYTES) {
|
|
43
54
|
const frames = []; const overCap = [];
|
|
@@ -47,8 +58,9 @@ export function drainFrames(text, limit = MAX_BROKER_LINE_BYTES) {
|
|
|
47
58
|
if (at < 0) break;
|
|
48
59
|
const line = rest.subarray(0, at + 1);
|
|
49
60
|
rest = rest.subarray(at + 1);
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
// Only a protocol frame is judged: a JSON object whose `type` names a protocol event. A tool's own output line is never a wire frame, even when it is
|
|
62
|
+
// JSON: `cargo metadata` prints its whole document as one `{"packages":...}` line, megabytes on a large workspace (rightkit#1351).
|
|
63
|
+
if (line.length > limit && line[0] === 0x7b && isProtocolFrame(line.subarray(0, line.length - 1).toString("utf8"))) return { frames, overCap, error: `a frame of ${line.length} bytes (newline included) exceeds the ${limit}-byte limit` };
|
|
52
64
|
const body = line.subarray(0, line.length - 1).toString("utf8");
|
|
53
65
|
frames.push(body);
|
|
54
66
|
if (body.startsWith("{")) { try { const event = JSON.parse(body); if (overCapProblem(event)) overCap.push(event); } catch { /* not a protocol frame */ } }
|
package/build-launch.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
|
41
41
|
import path from "node:path";
|
|
42
42
|
import { fileURLToPath } from "node:url";
|
|
43
43
|
import { resolveExecutor } from "./gate-executor.mjs";
|
|
44
|
+
import { gateNoBroker } from "./managed-cargo.mjs";
|
|
44
45
|
import { resolveExecutable } from "./admission-host.mjs";
|
|
45
46
|
import { CARRIED_CARGO_MAP_ENV as CARRIED_MAP_ENV, carriedCargoMapOf, runCarriedAdapter } from "./carried-cargo.mjs";
|
|
46
47
|
import { spawnSync } from "./background-process.mjs";
|
|
@@ -176,7 +177,9 @@ export function buildLaunch(command, args = [], host = {}) {
|
|
|
176
177
|
const lookup = (cmd) => { const found = resolveExecutable(cmd, { env, hostPlatform: platform, exists: exists ?? (() => false) }); return found ? [found] : []; };
|
|
177
178
|
// A snapshot stage never installs into the snapshot (the gate did): the step is a logged no-op, on every host.
|
|
178
179
|
if (snapshotInstallSkip(command, args, env)) return { command: process.execPath, args: [CARRIED_ADAPTER_RUN, "--skip-install", ...args.map(String)] };
|
|
179
|
-
|
|
180
|
+
// Inside a gate run outside the broker by Adrian's recorded exception (managed-cargo.mjs NO_BROKER_ENV, set only for that live door run's
|
|
181
|
+
// children), a child step (web-build's tsc, a native helper) takes the same direct route as the gate's own cargo children, not a refusal.
|
|
182
|
+
const executor = resolveExecutor({ env, platform, exists, which: host.which ?? lookup, readFile: host.readFile ?? ((file) => readFileSync(file, "utf8")), forceDirect: gateNoBroker(env) });
|
|
180
183
|
if (executor.kind === "refuse") refuse(`${name(command)} ${args.join(" ")}: ${executor.problem}`);
|
|
181
184
|
// A direct (Linux) host: the real toolchain, RustRight shim directories off PATH (`env`, to merge into the launch env); a configured
|
|
182
185
|
// `rustright cargo|rustc|rustdoc …` runs that tool directly; any other `rustright` command has no direct form and is refused.
|
package/build-ownership.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Per-repository build ownership (docs/GATE-CONTRACT.md §1 "Commit-only build", "Broker lease"; joint plan step 4, G1; rightkit#1367).
|
|
2
2
|
//
|
|
3
|
-
// The gate acquires the
|
|
4
|
-
// request can replace
|
|
5
|
-
// `<managed root>/build-ownership
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// The gate acquires the build ownership of the repository it is building BEFORE it syncs that repository's snapshot and holds it
|
|
4
|
+
// until the stage completes, so no other request can replace that snapshot while anything reads it. One owner per repository, at
|
|
5
|
+
// one fixed location per repository (`ownershipFile`: `<managed root>/build-ownership/<repository key>/owner.json` on Mac & Dell,
|
|
6
|
+
// `~/.local/state/rightkit/build-ownership/<repository key>/owner.json` on Linux), where the key is the snapshot directory's own
|
|
7
|
+
// key (`snapshot.repositoryOf(root).key`); never derived from the snapshot root or from an environment variable. Two repositories
|
|
8
|
+
// never contend: how many builds run, and in what order, is the broker's alone. The snapshot's own sync lock only covers the sync
|
|
9
|
+
// itself. `build-ownership/` and each repository directory are mode 0700 and refused when group- or other-accessible (`ensureOwnershipDir`).
|
|
8
10
|
//
|
|
9
11
|
// The owner record is written whole (create-exclusive) and names a fresh handle (the `ownership.handle` a broker request carries),
|
|
10
12
|
// the gate's pid & start identity, the stage, snapshot & commit, how the gate's children are contained, and whether a broker
|
|
@@ -93,31 +95,36 @@ const sameStart = (a, b) => Number.isFinite(a) && Number.isFinite(b) && Math.abs
|
|
|
93
95
|
/** The directory beside the host's snapshots (or beside any injected root) that holds the owner record. */
|
|
94
96
|
export const OWNERSHIP_DIRNAME = "build-ownership";
|
|
95
97
|
|
|
98
|
+
/** A repository key is one path segment: the snapshot directory's name (a hex digest), never a path. */
|
|
99
|
+
const REPOSITORY_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
100
|
+
|
|
96
101
|
/**
|
|
97
|
-
* Where
|
|
98
|
-
*
|
|
99
|
-
* choose different snapshot roots (XDG_STATE_HOME, a configured root) must still contend for one record, so nothing
|
|
100
|
-
* `process.env`, and nothing derives from where snapshots live.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
102
|
+
* Where a repository's owner record lives (GC:24 "the repository's build ownership"; item 5 row 5.17): ONE fixed location per
|
|
103
|
+
* repository, independent of the snapshot root and of every environment variable. Two gates for the same repository whose
|
|
104
|
+
* environments choose different snapshot roots (XDG_STATE_HOME, a configured root) must still contend for one record, so nothing
|
|
105
|
+
* here reads `process.env`, and nothing derives from where snapshots live. The only input besides the platform is `repository`,
|
|
106
|
+
* the repository key the snapshot directory uses (`snapshot.repositoryOf(root).key`); it is required.
|
|
107
|
+
* Mac & Dell: `<managed root>/build-ownership/<repository key>/owner.json` (the host's fixed managed root,
|
|
108
|
+
* `/Volumes/D/.rightkit-managed`, `D:\.rightkit-managed`).
|
|
109
|
+
* Linux (WSL2 included): `<account home>/.local/state/rightkit/build-ownership/<repository key>/owner.json`, the account home
|
|
110
|
+
* taken from the passwd database (`os.userInfo()`), never from $HOME.
|
|
111
|
+
* `home` replaces the Linux account home and `beside` replaces the whole location (`<dirname(beside)>/build-ownership/<key>/owner.json`);
|
|
106
112
|
* both are injection points for fixtures and tests only. A production caller passes neither: the root a caller derived from the
|
|
107
113
|
* environment must never be passed as `beside`, because that is the defect this row removes.
|
|
108
114
|
*/
|
|
109
|
-
export function ownershipFile({ platform = process.platform, home, beside } = {}) {
|
|
115
|
+
export function ownershipFile({ platform = process.platform, home, beside, repository } = {}) {
|
|
116
|
+
if (typeof repository !== "string" || !REPOSITORY_KEY.test(repository)) throw new TypeError(`ownershipFile: repository must be the repository key (one path segment), not ${JSON.stringify(repository)}`);
|
|
110
117
|
const paths = platform === "win32" ? path.win32 : path.posix;
|
|
111
118
|
if (beside !== undefined) {
|
|
112
119
|
if (typeof beside !== "string" || beside === "") throw new TypeError(`ownershipFile: beside must be a path (${JSON.stringify(beside)})`);
|
|
113
|
-
return paths.join(paths.dirname(beside), OWNERSHIP_DIRNAME, "owner.json");
|
|
120
|
+
return paths.join(paths.dirname(beside), OWNERSHIP_DIRNAME, repository, "owner.json");
|
|
114
121
|
}
|
|
115
|
-
if (platform === "darwin" || platform === "win32") return paths.join(resolveManagedRoots({ env: {}, platform, home: "" }).dataRoot, OWNERSHIP_DIRNAME, "owner.json");
|
|
122
|
+
if (platform === "darwin" || platform === "win32") return paths.join(resolveManagedRoots({ env: {}, platform, home: "" }).dataRoot, OWNERSHIP_DIRNAME, repository, "owner.json");
|
|
116
123
|
let base = home;
|
|
117
124
|
if (base === undefined) {
|
|
118
|
-
try { base = os.userInfo().homedir; } catch (e) { throw new OwnershipBusy(`build ownership: this account's home directory cannot be read (${e.message}), so the
|
|
125
|
+
try { base = os.userInfo().homedir; } catch (e) { throw new OwnershipBusy(`build ownership: this account's home directory cannot be read (${e.message}), so the repository's owner record has no location`); }
|
|
119
126
|
}
|
|
120
|
-
return paths.join(base, ".local", "state", "rightkit", OWNERSHIP_DIRNAME, "owner.json");
|
|
127
|
+
return paths.join(base, ".local", "state", "rightkit", OWNERSHIP_DIRNAME, repository, "owner.json");
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
/**
|
|
@@ -140,13 +147,15 @@ function guardManagedRoot(file, { platform, managedRoot, device }) {
|
|
|
140
147
|
}
|
|
141
148
|
|
|
142
149
|
/**
|
|
143
|
-
* 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):
|
|
150
|
+
* Creates the ownership directory mode 0700 (one level per directory on Mac & Dell) and refuses an existing POSIX one that grants group or other access (item 5 row 5.22):
|
|
144
151
|
* another user's unreadable process is skipped by the nets on the premise that it cannot read this user's managed root, and a
|
|
145
152
|
* directory other users could write would let them forge or remove the record. Windows has no mode bits; its directory ACL is the
|
|
146
153
|
* same trust the record itself has.
|
|
147
154
|
*/
|
|
148
155
|
export function ensureOwnershipDir(dir, { platform = process.platform } = {}) {
|
|
149
|
-
//
|
|
156
|
+
// A repository directory sits inside `build-ownership/`, which gets the same mode rules (created first, then checked).
|
|
157
|
+
if (path.basename(path.dirname(dir)) === OWNERSHIP_DIRNAME) ensureOwnershipDir(path.dirname(dir), { platform });
|
|
158
|
+
// Mac & Dell: exactly ONE level per call, `build-ownership/` (then `<key>/`) inside a managed root that already exists (decision D4: the gate never
|
|
150
159
|
// creates the managed root, and a recursive creation here would). Linux: the durable state directory under the account home is
|
|
151
160
|
// created whole (GC:63).
|
|
152
161
|
if (platform === "linux") fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -406,7 +415,7 @@ export function classifyHiddenProcesses({ procs, own = new Set(), uid, owner, re
|
|
|
406
415
|
* The host's process facts the ownership rules rely on. Each answer is a fact or a throw; a throw means "unknown" and the
|
|
407
416
|
* caller treats it as busy.
|
|
408
417
|
*/
|
|
409
|
-
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?.() } = {}) {
|
|
418
|
+
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?.(), sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) } = {}) {
|
|
410
419
|
const exists = (pid) => {
|
|
411
420
|
try { kill(pid, 0); return true; } catch (e) { if (e.code === "ESRCH") return false; if (e.code === "EPERM") return true; throw e; }
|
|
412
421
|
};
|
|
@@ -556,6 +565,24 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
|
|
|
556
565
|
if (startMs + START_SLACK_MS < skipBefore) return;
|
|
557
566
|
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)`);
|
|
558
567
|
};
|
|
568
|
+
// A process in the middle of execve is briefly non-dumpable (its new address space has no dumpable flag until the exec commits), so
|
|
569
|
+
// /proc/<pid>/{environ,cwd,fd,...} answer EACCES for a few milliseconds while its comm is still the parent's (`node-MainThread`)
|
|
570
|
+
// and its start is the fork's. Under concurrent test shards or any busy host, a scan meets such a process: it is no reader and not
|
|
571
|
+
// unknown, only not yet readable. So a read that would HOLD (a young, unreadable, live process of this user) is retried, bounded
|
|
572
|
+
// (SETTLE_TRIES x SETTLE_MS), before the hold stands; a process that stays unreadable (really non-dumpable) is held exactly as
|
|
573
|
+
// before, and a process that is old, gone, a zombie or another user's is judged at once, with no wait.
|
|
574
|
+
const SETTLE_TRIES = 20; const SETTLE_MS = 25;
|
|
575
|
+
/** `{ value }` of `op`, or `{ skipped: true }` when its failure is a process no reader (`unreadable` returned); throws what `unreadable` throws once the retries are spent. */
|
|
576
|
+
const settled = (pid, skipBefore, op) => {
|
|
577
|
+
for (let attempt = 0; ; attempt++) {
|
|
578
|
+
try { return { value: op() }; }
|
|
579
|
+
catch (e) {
|
|
580
|
+
if (!GONE.has(e.code) && !["EACCES", "EPERM"].includes(e.code)) throw e;
|
|
581
|
+
try { unreadable(pid, e, skipBefore); return { skipped: true }; }
|
|
582
|
+
catch (held) { if (attempt >= SETTLE_TRIES) throw held; sleep(SETTLE_MS); }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
};
|
|
559
586
|
return {
|
|
560
587
|
platform,
|
|
561
588
|
/** `{ alive, startedMs, group }` from `ps` (C locale). */
|
|
@@ -600,8 +627,9 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
|
|
|
600
627
|
const needle = Buffer.from(`${token}\0`);
|
|
601
628
|
const found = [];
|
|
602
629
|
for (const pid of procPids()) {
|
|
603
|
-
|
|
604
|
-
|
|
630
|
+
const got = settled(pid, skipBefore, () => readFile(`${proc}/${pid}/environ`));
|
|
631
|
+
if (got.skipped) continue;
|
|
632
|
+
const environ = got.value;
|
|
605
633
|
if (Buffer.concat([environ, Buffer.from("\0")]).includes(needle)) found.push(pid);
|
|
606
634
|
}
|
|
607
635
|
return found;
|
|
@@ -694,7 +722,7 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
|
|
|
694
722
|
const targetOf = (file) => { try { return readlink(file); } catch (e) { if (GONE.has(e.code)) return null; throw e; } };
|
|
695
723
|
for (const pid of procPids()) {
|
|
696
724
|
if (own.has(pid)) continue;
|
|
697
|
-
|
|
725
|
+
settled(pid, skipBefore, () => {
|
|
698
726
|
for (const [link, how] of [["cwd", "working directory"], ["root", "root directory"], ["exe", "executable"]]) {
|
|
699
727
|
const target = targetOf(`${proc}/${pid}/${link}`);
|
|
700
728
|
if (target !== null && inside(target)) { add(pid, how); break; }
|
|
@@ -711,7 +739,7 @@ export function processProbe({ platform = process.platform, spawn = spawnSync, s
|
|
|
711
739
|
let cmdline = ""; try { cmdline = String(readFile(`${proc}/${pid}/cmdline`, "utf8")); } catch (e) { if (!GONE.has(e.code)) throw e; }
|
|
712
740
|
if (named(cmdline)) add(pid, "argument");
|
|
713
741
|
}
|
|
714
|
-
}
|
|
742
|
+
});
|
|
715
743
|
}
|
|
716
744
|
return found;
|
|
717
745
|
}
|
|
@@ -755,7 +783,10 @@ export function spawnUnderOwnership(spawn, command, args, options = {}, { env =
|
|
|
755
783
|
const handle = env[OWNERSHIP_ENV] ?? options?.env?.[OWNERSHIP_ENV];
|
|
756
784
|
if (!options?.detached || platform === "win32" || !handle) return spawn(command, args, options);
|
|
757
785
|
const merged = { ...(options.env ?? {}), ...env };
|
|
758
|
-
|
|
786
|
+
// The handle names a per-repository record: its file variable is required, and there is no host-wide default to fall back to.
|
|
787
|
+
const ownedFile = merged[OWNERSHIP_FILE_ENV];
|
|
788
|
+
if (!ownedFile) throw new OwnershipBusy(`build ownership: ${OWNERSHIP_ENV} is set but ${OWNERSHIP_FILE_ENV} is not, so the repository's ownership record is unknown and the detached child is not started`);
|
|
789
|
+
const dir = childrenDir(ownedFile, handle);
|
|
759
790
|
try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (e.code !== "EEXIST") throw e; } // the ownership directory exists while this gate holds it
|
|
760
791
|
const intent = path.join(dir, `intent-${process.pid}-${crypto.randomUUID()}.json`);
|
|
761
792
|
// The child's start lower bound is taken BEFORE it is created, so however long this process pauses before recording the
|
|
@@ -830,13 +861,13 @@ export function readRootBirth(root, bootId) {
|
|
|
830
861
|
* reclaim, never a nested gate), before this sync's first write, its snapshots are deleted and the root is born anew, with this
|
|
831
862
|
* 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
|
|
832
863
|
* free host proves there is no current owner; and the fresh birth makes the strict rule apply only to processes that started after
|
|
833
|
-
* 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
|
|
864
|
+
* it. Only this repository's own entries are deleted (see the body). 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
|
|
834
865
|
* 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
|
|
835
866
|
* record is unreadable, or names THIS boot; or anything in the root is not a snapshot artifact (`<16 hex>`, `<16 hex>.lock`,
|
|
836
867
|
* `.state.json`, `.staging`, or the birth record), because a root that is some other directory is never emptied.
|
|
837
868
|
* Returns `{ migrated, why }`.
|
|
838
869
|
*/
|
|
839
|
-
export function migrateBirthlessRoot(root, { env = process.env, file = env[OWNERSHIP_FILE_ENV], bootId = hostBootId() } = {}) {
|
|
870
|
+
export function migrateBirthlessRoot(root, { env = process.env, file = env[OWNERSHIP_FILE_ENV], bootId = hostBootId(), hooks = {} } = {}) {
|
|
840
871
|
const no = (why) => ({ migrated: false, why });
|
|
841
872
|
const handle = env[OWNERSHIP_ENV];
|
|
842
873
|
if (!handle || !file) return no("this process holds no build ownership");
|
|
@@ -857,9 +888,45 @@ export function migrateBirthlessRoot(root, { env = process.env, file = env[OWNER
|
|
|
857
888
|
const birthName = path.basename(rootBirthFile(root));
|
|
858
889
|
const stranger = entries.find((n) => n !== birthName && !/^[0-9a-f]{16}(\.[A-Za-z0-9._-]+)?$/.test(n));
|
|
859
890
|
if (stranger !== undefined) return no(`${JSON.stringify(stranger)} in the root is not a snapshot artifact`);
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
891
|
+
// The root is shared by every repository, and a free host for THIS repository says nothing about another one. So this
|
|
892
|
+
// migration removes only THIS repository's own entries (`<key>` and `<key>.*`: the lock, the state file, the staging directory)
|
|
893
|
+
// and never another repository's, under any condition: a second repository can take its snapshot between any check made here
|
|
894
|
+
// and any delete, so no check can make a cross-repository delete safe. The root's birth is written only while no other
|
|
895
|
+
// repository holds or reclaims ownership (a decision, checked again right before the write); otherwise the root stays without a
|
|
896
|
+
// baseline and strict. Another repository's snapshots in the root are left to that repository's own migration.
|
|
897
|
+
const mine = path.basename(path.dirname(file));
|
|
898
|
+
const others = otherHolder(file);
|
|
899
|
+
if (others) return no(others);
|
|
900
|
+
hooks.afterHolderCheck?.();
|
|
901
|
+
const own = entries.filter((n) => n === mine || n.startsWith(`${mine}.`));
|
|
902
|
+
try { for (const n of own) fs.rmSync(path.join(root, n), { recursive: true, force: true }); }
|
|
903
|
+
catch (e) { return no(`this repository's snapshot could not be removed (${e.code ?? e.message}); the root stays without a baseline`); }
|
|
904
|
+
const late = otherHolder(file);
|
|
905
|
+
if (late) return no(`${late}; this repository's snapshot entries were removed and the root's birth was not written`);
|
|
906
|
+
if (fs.existsSync(rootBirthFile(root))) { try { fs.rmSync(rootBirthFile(root), { force: true }); } catch (e) { return no(`the old birth record could not be replaced (${e.code ?? e.message})`); } }
|
|
907
|
+
return recordRootBirth(root, { bootId }) ? { migrated: true, why: `${reason}; this repository's snapshot (${own.length} entr${own.length === 1 ? "y" : "ies"}) was deleted and the root was born anew in this boot` } : no("the new birth could not be recorded");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* Whether anything other than the repository owning `file` holds build ownership or is reclaiming it: another repository's owner
|
|
912
|
+
* record or standing reclaim claim, or an older gate's host-wide `build-ownership/owner.json` (which holds EVERY repository, live or
|
|
913
|
+
* not). Returns the reason, or null. A listing or read failure is a reason (fail closed).
|
|
914
|
+
*/
|
|
915
|
+
function otherHolder(file) {
|
|
916
|
+
try {
|
|
917
|
+
const parent = path.dirname(path.dirname(file)); const mine = path.basename(path.dirname(file));
|
|
918
|
+
for (const d of fs.readdirSync(parent, { withFileTypes: true })) {
|
|
919
|
+
if (!d.isDirectory()) {
|
|
920
|
+
if (d.name === "owner.json" || d.name.startsWith("owner.json.")) return `an older gate's host-wide record (${path.join(parent, d.name)}) stands`;
|
|
921
|
+
if (d.name.endsWith(".reclaim")) return "a reclaim claim stands under the ownership directory";
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
const names = fs.readdirSync(path.join(parent, d.name));
|
|
925
|
+
if (names.some((n) => n.endsWith(".reclaim"))) return `a reclaim claim stands for repository ${d.name}`;
|
|
926
|
+
if (d.name !== mine && names.includes("owner.json")) return `another repository (${d.name}) holds build ownership`;
|
|
927
|
+
}
|
|
928
|
+
} catch (e) { return `other repositories' ownership cannot be listed (${e.code ?? e.message})`; }
|
|
929
|
+
return null;
|
|
863
930
|
}
|
|
864
931
|
|
|
865
932
|
/** The holder's last report, or null when absent or unreadable. */
|
|
@@ -1025,6 +1092,12 @@ function coveredWorkGone(owner, { probe, lease, file, selfPid = null, waitMs = 0
|
|
|
1025
1092
|
recorded.push({ group: entry.group, command: entry.command });
|
|
1026
1093
|
let snapshot;
|
|
1027
1094
|
try { snapshot = probe.groupSnapshot(entry.group); } catch (e) { throw new Error(`detached process group ${entry.group} cannot be listed (${e.message})`); }
|
|
1095
|
+
// The entry is written after the spawn returns, so the group's own leader (pid == group) started before the file's mtime. A
|
|
1096
|
+
// leader that started later is a new process that took the id: the kernel reuses a group id only once that group is empty.
|
|
1097
|
+
let writtenMs = NaN;
|
|
1098
|
+
try { writtenMs = fs.statSync(path.join(registry, name)).mtimeMs; } catch { /* unknown: judged as before */ }
|
|
1099
|
+
const leader = snapshot.members.find((m) => m.pid === entry.group);
|
|
1100
|
+
if (leader && Number.isFinite(writtenMs) && leader.startedMs > writtenMs + START_SLACK_MS) continue;
|
|
1028
1101
|
const live = snapshot.members.filter((m) => m.pid !== selfPid && m.startedMs >= entry.startedMs - START_SLACK_MS).map((m) => m.pid);
|
|
1029
1102
|
if (live.length) throw new Error(`detached process group ${entry.group} (${entry.command}) still has ${live.length} member(s): ${live.join(", ")}`);
|
|
1030
1103
|
}
|
|
@@ -1146,12 +1219,12 @@ export function judgeOwner(observed, { file, probe, lease, host = os.hostname()
|
|
|
1146
1219
|
}
|
|
1147
1220
|
|
|
1148
1221
|
/**
|
|
1149
|
-
* Acquires
|
|
1222
|
+
* Acquires a repository's build ownership (or joins it, when RIGHTKIT_BUILD_OWNERSHIP names the current record). Returns
|
|
1150
1223
|
* `{ handle, file, joined, reclaimed, owner, markBrokerSubmit(callHandle), held(), release() }`. Throws OwnershipBusy while another build
|
|
1151
|
-
* owns
|
|
1224
|
+
* owns this repository, its liveness is unknown, or this gate cannot be contained.
|
|
1152
1225
|
*/
|
|
1153
1226
|
export function acquireBuildOwnership({
|
|
1154
|
-
file
|
|
1227
|
+
file,
|
|
1155
1228
|
stage,
|
|
1156
1229
|
snapshot,
|
|
1157
1230
|
commit,
|
|
@@ -1166,9 +1239,19 @@ export function acquireBuildOwnership({
|
|
|
1166
1239
|
} = {}) {
|
|
1167
1240
|
// First, before the environment, the record or any write: the managed root exists (decision D4). `managedRoot` and `device` are
|
|
1168
1241
|
// fixtures' seams; a production caller passes neither.
|
|
1242
|
+
if (typeof file !== "string" || file === "") throw new TypeError("acquireBuildOwnership: file is required (the path ownershipFile returns for a repository key)");
|
|
1169
1243
|
guardManagedRoot(file, { platform, managedRoot, device });
|
|
1170
1244
|
ensureOwnershipDir(path.dirname(file), { platform });
|
|
1171
1245
|
const inherited = env[OWNERSHIP_ENV];
|
|
1246
|
+
// An older pinned gate on this host writes ONE host-wide record, `build-ownership/owner.json`, a plain file beside the repository
|
|
1247
|
+
// directories. It holds every repository: this code never joins it, reclaims it or deletes it.
|
|
1248
|
+
const olderFile = path.join(path.dirname(path.dirname(file)), "owner.json");
|
|
1249
|
+
let older = null;
|
|
1250
|
+
try { older = readText(olderFile); } catch (e) { throw new OwnershipBusy(`build ownership ${olderFile}: an older gate's host-wide record cannot be read (${e.code ?? e.message}); nothing runs while it may stand`); }
|
|
1251
|
+
if (older !== null && inherited) {
|
|
1252
|
+
let o = null; try { o = JSON.parse(older); } catch { o = null; }
|
|
1253
|
+
if (o?.handle === inherited) throw new OwnershipBusy(`build ownership ${olderFile}: ${OWNERSHIP_ENV}=${inherited} names the host-wide record of an older gate; the parent is an older gate, and a nested gate never joins it`);
|
|
1254
|
+
}
|
|
1172
1255
|
if (inherited) {
|
|
1173
1256
|
const current = readText(file);
|
|
1174
1257
|
let owner = null; try { owner = JSON.parse(current ?? ""); } catch { owner = null; }
|
|
@@ -1188,6 +1271,12 @@ export function acquireBuildOwnership({
|
|
|
1188
1271
|
}
|
|
1189
1272
|
throw new OwnershipBusy(`build ownership ${file}: ${OWNERSHIP_ENV}=${inherited} is not the current owner; a nested gate never takes ownership of its own`);
|
|
1190
1273
|
}
|
|
1274
|
+
if (older !== null) {
|
|
1275
|
+
// Judged exactly as the older code judged it, to word the refusal; never reclaimed from here, whatever the verdict.
|
|
1276
|
+
try { judgeOwner(older, { file: olderFile, probe, lease }); }
|
|
1277
|
+
catch (e) { if (!(e instanceof OwnershipBusy)) throw e; throw new OwnershipBusy(`${e.message}; this is an older gate's host-wide record and it holds every repository: let that gate finish or clear it (an older gate or a person), this gate does not`, e.detail ?? {}); }
|
|
1278
|
+
throw new OwnershipBusy(`build ownership ${olderFile}: an older gate's host-wide record stands (its owner is gone, but only an older gate or a person clears it; this gate never reclaims it), and it holds every repository; nothing ran`);
|
|
1279
|
+
}
|
|
1191
1280
|
let me;
|
|
1192
1281
|
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})`); }
|
|
1193
1282
|
if (!me.alive || !Number.isFinite(me.startedMs)) throw new OwnershipBusy(`build ownership ${file}: this gate's own start identity cannot be read`);
|