@rightkit/release 0.2.38 → 0.2.40
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/build-invocation-contract.test.mjs +33 -5
- package/build-release.mjs +79 -47
- package/build-release.test.mjs +44 -0
- package/cache-command.mjs +73 -0
- package/cache-command.test.mjs +73 -0
- package/cache-policy.mjs +423 -0
- package/cache-policy.test.mjs +263 -0
- package/cargo-contract.mjs +8 -1
- package/cli/right-release.mjs +6 -0
- package/package.json +1 -1
- package/release-invocation.mjs +21 -7
- package/release-state.mjs +26 -2
- package/release-state.test.mjs +50 -0
- package/release.mjs +27 -1
- package/right-suite-contract.test.mjs +78 -11
- package/rightkit-versions.json +1 -1
package/cache-policy.mjs
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
lstatSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
openSync,
|
|
7
|
+
readdirSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
realpathSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
statSync,
|
|
13
|
+
statfsSync,
|
|
14
|
+
unlinkSync,
|
|
15
|
+
writeFileSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
export const CACHE_SCHEMA = 1;
|
|
23
|
+
export const DEFAULT_TARGET_MAX_BYTES = 12 * 1024 ** 3;
|
|
24
|
+
export const DEFAULT_SCCACHE_MAX_BYTES = 8 * 1024 ** 3;
|
|
25
|
+
export const DEFAULT_DESIRED_FREE_BYTES = 60 * 1024 ** 3;
|
|
26
|
+
export const DEFAULT_HARD_FREE_BYTES = 25 * 1024 ** 3;
|
|
27
|
+
// Any build holds release -> suite slot -> GC -> entry lease; prune holds only GC.
|
|
28
|
+
export const CACHE_LOCK_ORDER = ["release", "suite-build-slot", "gc", "entry-lease"];
|
|
29
|
+
|
|
30
|
+
const MARKER = ".rightkit-cache-entry.json";
|
|
31
|
+
|
|
32
|
+
export function resolveSharedCacheRoot({ platform = process.platform, env = process.env, home = os.homedir(), xdgCacheHome } = {}) {
|
|
33
|
+
const override = env.RIGHT_RELEASE_CACHE_ROOT;
|
|
34
|
+
if (override) {
|
|
35
|
+
if (!isAbsoluteForPlatform(override, platform)) throw new Error("RIGHT_RELEASE_CACHE_ROOT must be an absolute path");
|
|
36
|
+
return resolveForPlatform(override, platform);
|
|
37
|
+
}
|
|
38
|
+
if (platform === "darwin" || platform === "mac") return path.join(home, "Library", "Caches", "RightSuite", "release");
|
|
39
|
+
if (platform === "win32" || platform === "win") {
|
|
40
|
+
const local = env.LOCALAPPDATA;
|
|
41
|
+
if (!local || !isAbsoluteForPlatform(local, platform)) throw new Error("LOCALAPPDATA must be an absolute path for the RightSuite cache");
|
|
42
|
+
return path.win32.resolve(local, "RightSuite", "Cache", "release");
|
|
43
|
+
}
|
|
44
|
+
return path.resolve(xdgCacheHome || env.XDG_CACHE_HOME || path.join(home, ".cache"), "rightsuite", "release");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function resolveCacheLayout({ cacheRoot, platform, architecture, app, fingerprint, kind = "release" } = {}) {
|
|
48
|
+
if (!cacheRoot || !path.isAbsolute(cacheRoot)) throw new Error("cacheRoot must be an absolute path");
|
|
49
|
+
if (kind !== "release" && kind !== "test") throw new Error(`invalid cache target kind: ${kind}`);
|
|
50
|
+
for (const [name, value] of Object.entries({ platform, architecture, app, fingerprint })) {
|
|
51
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(value || ""))) throw new Error(`invalid cache ${name}`);
|
|
52
|
+
}
|
|
53
|
+
const root = path.resolve(cacheRoot);
|
|
54
|
+
const targetRoot = path.join(root, kind === "release" ? "targets" : "test-targets");
|
|
55
|
+
const targetDir = path.join(targetRoot, platform, app, fingerprint);
|
|
56
|
+
assertLexicalInside(targetRoot, targetDir);
|
|
57
|
+
const id = `${kind === "release" ? "target" : "test-target"}:${platform}:${app}:${fingerprint}`;
|
|
58
|
+
return {
|
|
59
|
+
cacheRoot: root,
|
|
60
|
+
targetRoot,
|
|
61
|
+
targetDir,
|
|
62
|
+
markerPath: path.join(targetDir, MARKER),
|
|
63
|
+
cargoHome: path.join(root, "cargo-home"),
|
|
64
|
+
sccacheDir: path.join(root, "sccache"),
|
|
65
|
+
leasesDir: path.join(root, "leases"),
|
|
66
|
+
leasePath: path.join(root, "leases", `${encodeURIComponent(id)}.json`),
|
|
67
|
+
suiteSlotPath: path.join(root, "leases", "suite-build-slot.json"),
|
|
68
|
+
gcLockPath: path.join(root, "gc", "gc.lock.json"),
|
|
69
|
+
migrationDir: path.join(root, "migration"),
|
|
70
|
+
platform,
|
|
71
|
+
architecture,
|
|
72
|
+
app,
|
|
73
|
+
fingerprint,
|
|
74
|
+
kind,
|
|
75
|
+
id,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function readCachePolicy(env = process.env) {
|
|
80
|
+
return {
|
|
81
|
+
targetMaxBytes: readPositive(env.RIGHT_RELEASE_TARGET_MAX_BYTES, DEFAULT_TARGET_MAX_BYTES),
|
|
82
|
+
sccacheMaxBytes: readPositive(env.RIGHT_RELEASE_SCCACHE_MAX_BYTES, DEFAULT_SCCACHE_MAX_BYTES),
|
|
83
|
+
desiredFreeBytes: readPositive(env.RIGHT_RELEASE_DESIRED_FREE_BYTES, DEFAULT_DESIRED_FREE_BYTES),
|
|
84
|
+
hardFreeBytes: readPositive(env.RIGHT_RELEASE_HARD_FREE_BYTES, DEFAULT_HARD_FREE_BYTES),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveSharedCacheIdentity({ cargoLockPath, cargoTomlPath, rustcVerbose, targetTriple, profile = "release", features } = {}) {
|
|
89
|
+
const rustc = rustcVerbose ?? readRustcVerbose();
|
|
90
|
+
const host = rustc.match(/^host:\s*(.+)$/m)?.[1];
|
|
91
|
+
if (!host) throw new Error("rustc -vV did not report a host target triple");
|
|
92
|
+
const target = targetTriple || host;
|
|
93
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(target)) throw new Error("invalid Cargo target triple");
|
|
94
|
+
const architecture = target.split("-")[0];
|
|
95
|
+
const cargoLockSha256 = cargoLockPath && existsSync(cargoLockPath) ? createHash("sha256").update(readFileSync(cargoLockPath)).digest("hex") : "none";
|
|
96
|
+
const nativeFeatures = features ?? cargoNativeFeatures(cargoTomlPath);
|
|
97
|
+
const payload = JSON.stringify({ cargoLockSha256, rustc, target, architecture, profile, features: [...nativeFeatures].sort() });
|
|
98
|
+
return { cargoLockSha256, rustc, target, architecture, profile, features: [...nativeFeatures].sort(), fingerprint: createHash("sha256").update(payload).digest("hex").slice(0, 16) };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function ensureCacheEntry({ layout, metadata = {}, now = new Date() } = {}) {
|
|
102
|
+
requireLayout(layout);
|
|
103
|
+
const root = resolvedRoot(layout.cacheRoot);
|
|
104
|
+
const targetDir = safeEntryDirectory(root, layout);
|
|
105
|
+
mkdirSafeDescendant(root, targetDir);
|
|
106
|
+
assertPhysicalInside(path.join(root, layout.kind === "release" ? "targets" : "test-targets"), targetDir);
|
|
107
|
+
const markerPath = path.join(targetDir, MARKER);
|
|
108
|
+
let entry = readJson(markerPath);
|
|
109
|
+
if (entry && !validEntry(entry, layout)) throw new Error(`invalid cache entry marker: ${markerPath}`);
|
|
110
|
+
if (!entry) {
|
|
111
|
+
const at = iso(now);
|
|
112
|
+
entry = {
|
|
113
|
+
schema: CACHE_SCHEMA, id: layout.id, kind: layout.kind, platform: layout.platform,
|
|
114
|
+
architecture: layout.architecture, app: layout.app, fingerprint: layout.fingerprint,
|
|
115
|
+
createdAt: at, lastUsedAt: at, lastSuccessfulBuildAt: null,
|
|
116
|
+
toolchain: metadata.toolchain ?? { cargo: "unknown", rustc: "unknown", host: "unknown" },
|
|
117
|
+
};
|
|
118
|
+
atomicJson(markerPath, entry);
|
|
119
|
+
}
|
|
120
|
+
return entry;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function acquireCacheLease({ layout, pid = process.pid, argv = process.argv, now = new Date() } = {}) {
|
|
124
|
+
requireLayout(layout);
|
|
125
|
+
const payload = { schema: CACHE_SCHEMA, id: layout.id, pid: Number(pid), argv: [...argv], createdAt: iso(now) };
|
|
126
|
+
const root = resolvedRoot(layout.cacheRoot);
|
|
127
|
+
const gcPayload = { schema: CACHE_SCHEMA, pid: Number(pid), argv: [...argv], createdAt: iso(now), order: CACHE_LOCK_ORDER };
|
|
128
|
+
acquireAtomicLock(path.join(root, "gc", "gc.lock.json"), gcPayload, "global GC lock", now);
|
|
129
|
+
try {
|
|
130
|
+
ensureCacheEntry({ layout, now });
|
|
131
|
+
acquireAtomicLock(layout.leasePath, payload, "cache entry lease", now);
|
|
132
|
+
touchEntry(layout, now);
|
|
133
|
+
return releaseFor(layout.leasePath, payload);
|
|
134
|
+
} finally {
|
|
135
|
+
releaseFor(path.join(root, "gc", "gc.lock.json"), gcPayload).release();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function acquireSuiteBuildSlot({ layout, pid = process.pid, argv = process.argv, waitMs = 900_000, now = new Date(), sleep = defaultSleep } = {}) {
|
|
140
|
+
requireLayout(layout);
|
|
141
|
+
const started = Date.now();
|
|
142
|
+
const payload = { schema: CACHE_SCHEMA, pid: Number(pid), argv: [...argv], createdAt: iso(now) };
|
|
143
|
+
for (;;) {
|
|
144
|
+
try {
|
|
145
|
+
acquireAtomicLock(layout.suiteSlotPath, payload, "suite build slot", new Date());
|
|
146
|
+
return releaseFor(layout.suiteSlotPath, payload);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (!/active/i.test(error.message)) throw error;
|
|
149
|
+
if (Date.now() - started >= waitMs) throw new Error(`${error.message}; suite build slot timed out after ${waitMs}ms without alternate-target fallback`);
|
|
150
|
+
sleep(Math.min(250, Math.max(1, waitMs - (Date.now() - started))));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function markCacheEntrySuccessful({ layout, now = new Date() } = {}) {
|
|
156
|
+
requireLayout(layout);
|
|
157
|
+
const entry = readJson(layout.markerPath);
|
|
158
|
+
if (!validEntry(entry, layout)) throw new Error(`invalid cache entry marker: ${layout.markerPath}`);
|
|
159
|
+
entry.lastUsedAt = iso(now);
|
|
160
|
+
entry.lastSuccessfulBuildAt = iso(now);
|
|
161
|
+
atomicJson(layout.markerPath, entry);
|
|
162
|
+
return entry;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function inspectCache({ cacheRoot, statfs = statfsSync, now = new Date() } = {}) {
|
|
166
|
+
const root = readonlyRoot(cacheRoot);
|
|
167
|
+
if (!root) return { cacheRoot: path.resolve(cacheRoot), entries: [], targetBytes: 0, cacheFreeBytes: 0, device: undefined, inspectedAt: iso(now) };
|
|
168
|
+
const entries = [];
|
|
169
|
+
for (const kind of ["targets", "test-targets"]) {
|
|
170
|
+
const kindRoot = path.join(root, kind);
|
|
171
|
+
if (!existsSync(kindRoot) || lstatSync(kindRoot).isSymbolicLink()) continue;
|
|
172
|
+
for (const platformEntry of safeDirEntries(kindRoot)) for (const appEntry of safeDirEntries(platformEntry.path)) for (const fingerprintEntry of safeDirEntries(appEntry.path)) {
|
|
173
|
+
const dir = fingerprintEntry.path;
|
|
174
|
+
const markerPath = path.join(dir, MARKER);
|
|
175
|
+
const marker = readJson(markerPath);
|
|
176
|
+
const expectedKind = kind === "targets" ? "release" : "test";
|
|
177
|
+
const layout = marker ? resolveCacheLayout({ cacheRoot: root, platform: marker.platform, architecture: marker.architecture, app: marker.app, fingerprint: marker.fingerprint, kind: expectedKind }) : null;
|
|
178
|
+
if (!layout || layout.targetDir !== dir || !validEntry(marker, layout)) continue;
|
|
179
|
+
const leasePath = path.join(root, "leases", `${encodeURIComponent(marker.id)}.json`);
|
|
180
|
+
const lease = readJson(leasePath);
|
|
181
|
+
const live = isLiveLease(lease, now);
|
|
182
|
+
entries.push({ id: marker.id, dir, marker, bytes: directoryBytes(dir), leased: live, lease: live ? lease : null });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const fs = statfs(root, { bigint: false });
|
|
186
|
+
return { cacheRoot: root, entries, targetBytes: entries.filter((x) => x.marker.kind === "release").reduce((sum, x) => sum + x.bytes, 0), cacheFreeBytes: freeBytes(fs), device: fs.dev ?? safeDevice(root), inspectedAt: iso(now) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function inspectWriteVolumes({ repoRoot, vaultRoot, cacheRoot, statfs = statfsSync } = {}) {
|
|
190
|
+
const paths = [
|
|
191
|
+
{ role: "repo", path: path.resolve(repoRoot || vaultRoot) },
|
|
192
|
+
{ role: "cache", path: path.resolve(cacheRoot) },
|
|
193
|
+
];
|
|
194
|
+
const volumes = [];
|
|
195
|
+
for (const item of paths) {
|
|
196
|
+
const fs = statfs(item.path, { bigint: false });
|
|
197
|
+
const device = fs.dev ?? safeDevice(item.path) ?? `${fs.type}:${item.path}`;
|
|
198
|
+
const existing = volumes.find((volume) => String(volume.device) === String(device));
|
|
199
|
+
if (existing) { existing.roles.push(item.role); existing.paths.push(item.path); continue; }
|
|
200
|
+
volumes.push({ device, roles: [item.role], paths: [item.path], freeBytes: freeBytes(fs) });
|
|
201
|
+
}
|
|
202
|
+
return volumes;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function assertWriteVolumeFloors({ volumes, policy = readCachePolicy(), configuredRepoMinimumBytes = DEFAULT_HARD_FREE_BYTES } = {}) {
|
|
206
|
+
for (const volume of volumes ?? []) {
|
|
207
|
+
const repoRequired = volume.roles.includes("repo") ? Math.max(configuredRepoMinimumBytes, DEFAULT_HARD_FREE_BYTES) : 0;
|
|
208
|
+
const cacheRequired = volume.roles.includes("cache") ? policy.hardFreeBytes : 0;
|
|
209
|
+
const required = Math.max(repoRequired, cacheRequired);
|
|
210
|
+
if (volume.freeBytes < required) throw new Error(`insufficient free space on ${volume.roles.join("+")} volume ${volume.device}: ${(volume.freeBytes / 1024 ** 3).toFixed(1)} GiB free, ${(required / 1024 ** 3).toFixed(1)} GiB required`);
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function planCachePrune({ snapshot, policy = readCachePolicy(), protectedEntryIds = new Set() } = {}) {
|
|
216
|
+
const protectedIds = new Set(protectedEntryIds);
|
|
217
|
+
const candidates = (snapshot.entries ?? [])
|
|
218
|
+
.filter((entry) => entry.marker?.kind === "release" && !entry.leased && !protectedIds.has(entry.id))
|
|
219
|
+
.sort((a, b) => String(a.marker.lastSuccessfulBuildAt ?? a.marker.lastUsedAt ?? a.marker.createdAt).localeCompare(String(b.marker.lastSuccessfulBuildAt ?? b.marker.lastUsedAt ?? b.marker.createdAt)) || a.id.localeCompare(b.id));
|
|
220
|
+
let bytes = snapshot.targetBytes ?? 0;
|
|
221
|
+
let free = snapshot.cacheFreeBytes ?? Infinity;
|
|
222
|
+
const selected = [];
|
|
223
|
+
for (const entry of candidates) {
|
|
224
|
+
if (bytes <= policy.targetMaxBytes && free >= policy.desiredFreeBytes) break;
|
|
225
|
+
selected.push({ id: entry.id, dir: entry.dir, bytes: entry.bytes, reason: bytes > policy.targetMaxBytes ? "target-cap" : "free-space" });
|
|
226
|
+
bytes -= entry.bytes; free += entry.bytes;
|
|
227
|
+
}
|
|
228
|
+
return { schema: CACHE_SCHEMA, cacheRoot: snapshot.cacheRoot, candidateIds: selected.map((entry) => entry.id), candidates: selected, estimatedReclaimedBytes: selected.reduce((sum, entry) => sum + entry.bytes, 0), targetBytesAfter: bytes, cacheFreeBytesAfter: free };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function applyCachePrune({ plan, cacheRoot, dryRun = true, beforeDelete } = {}) {
|
|
232
|
+
const result = { candidateIds: (plan?.candidates ?? []).map((entry) => entry.id), removedIds: [], reclaimedBytes: 0, dryRun };
|
|
233
|
+
if (dryRun) return result;
|
|
234
|
+
const root = resolvedRoot(cacheRoot || plan?.cacheRoot);
|
|
235
|
+
const lockPath = path.join(root, "gc", "gc.lock.json");
|
|
236
|
+
const lockPayload = { schema: CACHE_SCHEMA, pid: process.pid, argv: process.argv, createdAt: iso(new Date()), order: CACHE_LOCK_ORDER };
|
|
237
|
+
acquireAtomicLock(lockPath, lockPayload, "global GC lock", new Date());
|
|
238
|
+
try {
|
|
239
|
+
for (const candidate of plan?.candidates ?? []) {
|
|
240
|
+
if (!safePruneCandidate(root, candidate)) continue;
|
|
241
|
+
beforeDelete?.(candidate);
|
|
242
|
+
if (!safePruneCandidate(root, candidate)) continue;
|
|
243
|
+
if (!dryRun) {
|
|
244
|
+
rmSync(realpathSync(candidate.dir), { recursive: true, force: false, maxRetries: 2 });
|
|
245
|
+
result.removedIds.push(candidate.id); result.reclaimedBytes += candidate.bytes ?? 0;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} finally {
|
|
249
|
+
releaseFor(lockPath, lockPayload).release();
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function migrateLegacyCache({ legacyRoot, legacyTargetRoot, legacyCargoHome, legacyReleaseLockPath, layout, app, dryRun = true, waitMs = 0, onPostMove, writeMigrationReceipt = atomicJson } = {}) {
|
|
255
|
+
requireLayout(layout);
|
|
256
|
+
if (app !== layout.app) throw new Error("legacy cache app must match cache layout");
|
|
257
|
+
const root = dryRun ? path.resolve(layout.cacheRoot) : resolvedRoot(layout.cacheRoot);
|
|
258
|
+
const receipt = path.join(root, "migration", `${app}.json`);
|
|
259
|
+
const existing = readJson(receipt);
|
|
260
|
+
if (existing) return { ...existing, resumed: true, dryRun };
|
|
261
|
+
const linkPath = path.resolve(legacyRoot);
|
|
262
|
+
const target = layout.targetDir;
|
|
263
|
+
const initial = resolveLegacyMigrationSource({ linkPath, legacyTargetRoot, layout });
|
|
264
|
+
const outcome = { schema: CACHE_SCHEMA, app, legacyRoot: initial.source, target, dryRun, moved: false, coldCache: false, cargoHomeMoved: false, deferredLegacyCargoHome: null, createdAt: iso(new Date()) };
|
|
265
|
+
if (initial.coldCache) return persistMigrationOutcome({ outcome: { ...outcome, coldCache: true }, receipt, root, dryRun });
|
|
266
|
+
if (pathEntryExists(target)) throw new Error(`cache migration target already exists: ${target}`);
|
|
267
|
+
if (dryRun) return outcome;
|
|
268
|
+
|
|
269
|
+
let suiteSlot;
|
|
270
|
+
let gcExclusion;
|
|
271
|
+
try {
|
|
272
|
+
suiteSlot = acquireSuiteBuildSlot({ layout, pid: process.pid, argv: process.argv, waitMs });
|
|
273
|
+
gcExclusion = acquireMigrationGcExclusion({ layout });
|
|
274
|
+
assertMigrationIdle({ legacyReleaseLockPath, layout });
|
|
275
|
+
const { source } = resolveLegacyMigrationSource({ linkPath, legacyTargetRoot, layout });
|
|
276
|
+
const actualTarget = safeEntryDirectory(root, layout);
|
|
277
|
+
const targetParent = path.dirname(actualTarget);
|
|
278
|
+
mkdirSafeDescendant(root, targetParent);
|
|
279
|
+
const sourceDevice = safeDevice(source);
|
|
280
|
+
if (sourceDevice !== undefined && sourceDevice !== safeDevice(targetParent)) return persistMigrationOutcome({ outcome: { ...outcome, coldCache: true }, receipt, root, dryRun });
|
|
281
|
+
|
|
282
|
+
let movedTarget = false;
|
|
283
|
+
let createdMarker = false;
|
|
284
|
+
let movedCargoHome = false;
|
|
285
|
+
let replacedEmptyCargoHome = false;
|
|
286
|
+
const sharedCargoHome = path.join(root, "cargo-home");
|
|
287
|
+
try {
|
|
288
|
+
renameSync(source, actualTarget); movedTarget = true;
|
|
289
|
+
onPostMove?.("after-target-move");
|
|
290
|
+
createdMarker = !existsSync(path.join(actualTarget, MARKER));
|
|
291
|
+
ensureCacheEntry({ layout });
|
|
292
|
+
onPostMove?.("after-entry-marker");
|
|
293
|
+
if (legacyCargoHome && existsSync(legacyCargoHome)) {
|
|
294
|
+
if (lstatSync(legacyCargoHome).isSymbolicLink()) throw new Error(`legacy Cargo home migration refuses symlink source: ${legacyCargoHome}`);
|
|
295
|
+
if (!existsSync(sharedCargoHome)) {
|
|
296
|
+
renameSync(legacyCargoHome, sharedCargoHome); outcome.cargoHomeMoved = movedCargoHome = true;
|
|
297
|
+
} else if (isEmptyDirectory(sharedCargoHome)) {
|
|
298
|
+
rmSync(sharedCargoHome, { recursive: true, force: false }); replacedEmptyCargoHome = true;
|
|
299
|
+
renameSync(legacyCargoHome, sharedCargoHome); outcome.cargoHomeMoved = movedCargoHome = true;
|
|
300
|
+
} else outcome.deferredLegacyCargoHome = path.resolve(legacyCargoHome);
|
|
301
|
+
}
|
|
302
|
+
onPostMove?.("after-cargo-home-move");
|
|
303
|
+
mkdirSafeDescendant(root, path.join(root, "migration"));
|
|
304
|
+
onPostMove?.("before-receipt");
|
|
305
|
+
writeMigrationReceipt(receipt, { ...outcome, moved: true, dryRun: false, completedAt: iso(new Date()) });
|
|
306
|
+
outcome.moved = true;
|
|
307
|
+
if (linkPath !== source) { try { if (lstatSync(linkPath).isSymbolicLink()) unlinkSync(linkPath); } catch { /* already absent */ } }
|
|
308
|
+
} catch (error) {
|
|
309
|
+
if (movedCargoHome && existsSync(sharedCargoHome) && !existsSync(legacyCargoHome)) renameSync(sharedCargoHome, legacyCargoHome);
|
|
310
|
+
if (replacedEmptyCargoHome && !existsSync(sharedCargoHome)) mkdirSafeDescendant(root, sharedCargoHome);
|
|
311
|
+
if (movedTarget && existsSync(actualTarget) && !existsSync(source)) {
|
|
312
|
+
if (createdMarker) safeUnlink(path.join(actualTarget, MARKER));
|
|
313
|
+
renameSync(actualTarget, source);
|
|
314
|
+
}
|
|
315
|
+
throw error;
|
|
316
|
+
}
|
|
317
|
+
return outcome;
|
|
318
|
+
} finally {
|
|
319
|
+
gcExclusion?.release();
|
|
320
|
+
suiteSlot?.release();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function persistMigrationOutcome({ outcome, receipt, root, dryRun }) { if (!dryRun) { mkdirSafeDescendant(root, path.join(root, "migration")); atomicJson(receipt, { ...outcome, dryRun: false, completedAt: iso(new Date()) }); } return outcome; }
|
|
325
|
+
|
|
326
|
+
function resolveLegacyMigrationSource({ linkPath, legacyTargetRoot, layout }) {
|
|
327
|
+
if (!pathEntryExists(linkPath)) return { source: linkPath, coldCache: true };
|
|
328
|
+
if (!lstatSync(linkPath).isSymbolicLink()) return { source: linkPath, coldCache: false };
|
|
329
|
+
if (!legacyTargetRoot) throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`);
|
|
330
|
+
const declared = path.resolve(legacyTargetRoot);
|
|
331
|
+
const legacyCargoTargetRoot = path.resolve(declared, "..", "..");
|
|
332
|
+
const exactFingerprintDir = path.join(legacyCargoTargetRoot, layout.platform, layout.fingerprint);
|
|
333
|
+
if (declared !== exactFingerprintDir) throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`);
|
|
334
|
+
let physical;
|
|
335
|
+
let expected;
|
|
336
|
+
let legacyRoot;
|
|
337
|
+
try {
|
|
338
|
+
physical = realpathSync(linkPath);
|
|
339
|
+
expected = realpathSync(exactFingerprintDir);
|
|
340
|
+
legacyRoot = realpathSync(legacyCargoTargetRoot);
|
|
341
|
+
} catch { throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`); }
|
|
342
|
+
try { assertLexicalInside(legacyRoot, expected); } catch { throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`); }
|
|
343
|
+
if (physical !== expected) throw new Error(`legacy cache migration refuses unexpected target link: ${linkPath}`);
|
|
344
|
+
return { source: physical, coldCache: false };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function acquireMigrationGcExclusion({ layout }) {
|
|
348
|
+
const payload = { schema: CACHE_SCHEMA, pid: process.pid, argv: process.argv, createdAt: iso(new Date()), order: CACHE_LOCK_ORDER };
|
|
349
|
+
acquireAtomicLock(layout.gcLockPath, payload, "global GC lock", new Date());
|
|
350
|
+
return releaseFor(layout.gcLockPath, payload);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function assertMigrationIdle({ legacyReleaseLockPath, layout }) {
|
|
354
|
+
if (legacyReleaseLockPath && pathEntryExists(legacyReleaseLockPath)) throw new Error(`cache migrate refused: legacy release lock is live: ${legacyReleaseLockPath}`);
|
|
355
|
+
for (const entry of safeLeaseEntries(layout.leasesDir)) {
|
|
356
|
+
if (entry.name === path.basename(layout.suiteSlotPath)) continue;
|
|
357
|
+
if (entry.symbolicLink) throw new Error(`cache migrate refused: unsafe shared lease path: ${entry.path}`);
|
|
358
|
+
if (!entry.file) continue;
|
|
359
|
+
const lease = readJson(entry.path);
|
|
360
|
+
if (isLiveLease(lease)) throw new Error(`cache migrate refused: shared entry lease is live: pid ${lease.pid}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function requireLayout(layout) { if (!layout?.targetDir || !layout?.cacheRoot) throw new Error("cache layout is required"); }
|
|
365
|
+
function readPositive(value, fallback) { if (value === undefined || value === "") return fallback; const number = Number(value); if (!Number.isFinite(number) || number <= 0) throw new Error("cache policy values must be positive byte counts"); return number; }
|
|
366
|
+
function iso(value) { return new Date(value).toISOString(); }
|
|
367
|
+
function pathEntryExists(file) { try { lstatSync(file); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } }
|
|
368
|
+
function resolvedRoot(cacheRoot) { if (!cacheRoot || !path.isAbsolute(cacheRoot)) throw new Error("cacheRoot must be an absolute path"); if (existsSync(cacheRoot) && lstatSync(cacheRoot).isSymbolicLink()) throw new Error(`unsafe symlink cache root: ${cacheRoot}`); mkdirSync(cacheRoot, { recursive: true }); if (lstatSync(cacheRoot).isSymbolicLink()) throw new Error(`unsafe symlink cache root: ${cacheRoot}`); return realpathSync(cacheRoot); }
|
|
369
|
+
function readonlyRoot(cacheRoot) { if (!cacheRoot || !path.isAbsolute(cacheRoot)) throw new Error("cacheRoot must be an absolute path"); if (!existsSync(cacheRoot)) return null; if (lstatSync(cacheRoot).isSymbolicLink()) throw new Error(`unsafe symlink cache root: ${cacheRoot}`); return realpathSync(cacheRoot); }
|
|
370
|
+
function atomicJson(file, value) { mkdirSync(path.dirname(file), { recursive: true }); const temp = `${file}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`; writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); renameSync(temp, file); }
|
|
371
|
+
function readJson(file) { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return null; } }
|
|
372
|
+
function validEntry(entry, layout) { return Boolean(entry && entry.schema === CACHE_SCHEMA && entry.id === layout.id && entry.kind === layout.kind && entry.platform === layout.platform && entry.architecture === layout.architecture && entry.app === layout.app && entry.fingerprint === layout.fingerprint && typeof entry.createdAt === "string" && typeof entry.lastUsedAt === "string" && Object.prototype.hasOwnProperty.call(entry, "lastSuccessfulBuildAt") && entry.toolchain && typeof entry.toolchain === "object"); }
|
|
373
|
+
function assertLexicalInside(parent, child) { const relative = path.relative(path.resolve(parent), path.resolve(child)); if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("cache path escapes its root"); }
|
|
374
|
+
function assertPhysicalInside(parent, child) { const parentReal = realpathSync(parent); const childReal = realpathSync(child); assertLexicalInside(parentReal, childReal); }
|
|
375
|
+
function safeDirEntries(dir) { try { return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.isSymbolicLink()).map((entry) => ({ name: entry.name, path: path.join(dir, entry.name) })); } catch { return []; } }
|
|
376
|
+
function safeLeaseEntries(dir) { try { return readdirSync(dir, { withFileTypes: true }).map((entry) => ({ name: entry.name, path: path.join(dir, entry.name), file: entry.isFile(), symbolicLink: entry.isSymbolicLink() })); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } }
|
|
377
|
+
function directoryBytes(dir) { let bytes = 0; const walk = (item) => { const stat = lstatSync(item); if (stat.isSymbolicLink()) return; if (stat.isDirectory()) for (const child of readdirSync(item)) walk(path.join(item, child)); else bytes += stat.size; }; try { walk(dir); } catch { return 0; } return bytes; }
|
|
378
|
+
function freeBytes(fs) { return Number(fs.bavail) * Number(fs.bsize); }
|
|
379
|
+
function safeDevice(dir) { try { return statSync(dir).dev; } catch { return undefined; } }
|
|
380
|
+
function isEmptyDirectory(dir) { try { return readdirSync(dir).length === 0; } catch { return false; } }
|
|
381
|
+
function isLiveLease(lease) { return Boolean(lease && Number.isInteger(Number(lease.pid)) && Number(lease.pid) > 0 && processIsAlive(lease.pid)); }
|
|
382
|
+
function processIsAlive(pid) { try { process.kill(Number(pid), 0); return true; } catch (error) { return error?.code === "EPERM"; } }
|
|
383
|
+
function safeUnlink(file) { try { unlinkSync(file); } catch { /* already gone */ } }
|
|
384
|
+
function acquireAtomicLock(file, payload, label, now) {
|
|
385
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
386
|
+
try { const fd = openSync(file, "wx", 0o600); writeFileSync(fd, `${JSON.stringify(payload)}\n`); closeSync(fd); return; } catch (error) {
|
|
387
|
+
if (error.code !== "EEXIST") throw error;
|
|
388
|
+
const holder = readJson(file);
|
|
389
|
+
if (!isLiveLease(holder, now)) { safeUnlink(file); return acquireAtomicLock(file, payload, label, now); }
|
|
390
|
+
throw new Error(`${label} active: pid ${holder.pid}`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function releaseFor(file, payload) { let released = false; return { release() { if (released) return; released = true; const current = readJson(file); if (current?.pid === payload.pid && current?.createdAt === payload.createdAt) safeUnlink(file); } }; }
|
|
394
|
+
function touchEntry(layout, now) { const entry = readJson(layout.markerPath); if (!validEntry(entry, layout)) throw new Error(`invalid cache entry marker: ${layout.markerPath}`); entry.lastUsedAt = iso(now); atomicJson(layout.markerPath, entry); }
|
|
395
|
+
function defaultSleep(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
396
|
+
function safePruneCandidate(root, candidate) {
|
|
397
|
+
if (!candidate?.dir || path.resolve(candidate.dir) === root) return false;
|
|
398
|
+
if (!existsSync(candidate.dir) || lstatSync(candidate.dir).isSymbolicLink()) return false;
|
|
399
|
+
const dir = realpathSync(candidate.dir); const targets = [path.join(root, "targets"), path.join(root, "test-targets")];
|
|
400
|
+
const targetRoot = targets.find((item) => { try { assertLexicalInside(item, dir); return true; } catch { return false; } });
|
|
401
|
+
if (!targetRoot) return false;
|
|
402
|
+
try {
|
|
403
|
+
assertPhysicalInside(targetRoot, dir);
|
|
404
|
+
const marker = readJson(path.join(dir, MARKER));
|
|
405
|
+
const kind = targetRoot.endsWith(`${path.sep}targets`) ? "release" : "test";
|
|
406
|
+
const layout = marker && resolveCacheLayout({ cacheRoot: root, platform: marker.platform, architecture: marker.architecture, app: marker.app, fingerprint: marker.fingerprint, kind });
|
|
407
|
+
return Boolean(layout && layout.targetDir === dir && marker.id === candidate.id && validEntry(marker, layout) && !isLiveLease(readJson(layout.leasePath), new Date()));
|
|
408
|
+
} catch { return false; }
|
|
409
|
+
}
|
|
410
|
+
function safeEntryDirectory(root, layout) { return path.join(root, layout.kind === "release" ? "targets" : "test-targets", layout.platform, layout.app, layout.fingerprint); }
|
|
411
|
+
function mkdirSafeDescendant(root, dir) {
|
|
412
|
+
assertLexicalInside(root, dir);
|
|
413
|
+
let current = root;
|
|
414
|
+
for (const part of path.relative(root, dir).split(path.sep)) {
|
|
415
|
+
current = path.join(current, part);
|
|
416
|
+
if (existsSync(current)) { if (lstatSync(current).isSymbolicLink()) throw new Error(`unsafe symlink cache ancestor: ${current}`); }
|
|
417
|
+
else mkdirSync(current);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function isAbsoluteForPlatform(value, platform) { return platform === "win" || platform === "win32" ? path.win32.isAbsolute(value) : path.isAbsolute(value); }
|
|
421
|
+
function resolveForPlatform(value, platform) { return platform === "win" || platform === "win32" ? path.win32.resolve(value) : path.resolve(value); }
|
|
422
|
+
function readRustcVerbose() { const result = spawnSync("rustc", ["-vV"], { encoding: "utf8", windowsHide: true }); if (result.status !== 0) throw new Error(`rustc -vV failed: ${(result.stderr || result.error?.message || `exit ${result.status}`).trim()}`); return result.stdout.trim(); }
|
|
423
|
+
function cargoNativeFeatures(cargoTomlPath) { if (!cargoTomlPath || !existsSync(cargoTomlPath)) return []; return [...readFileSync(cargoTomlPath, "utf8").matchAll(/features\s*=\s*\[([^\]]+)\]/g)].flatMap((match) => match[1].match(/"([^"]+)"/g) ?? []).map((value) => value.slice(1, -1)).filter((value) => /sqlcipher|openssl/i.test(value)); }
|