@autodevjapan/godd-mcp-alpha 2.9.3 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -3
- package/assets/native/godd-native-aarch64-apple-darwin +0 -0
- package/assets/native/godd-native-aarch64-apple-darwin.sha256 +1 -1
- package/assets/native/godd-native-x86_64-apple-darwin +0 -0
- package/assets/native/godd-native-x86_64-apple-darwin.sha256 +1 -1
- package/assets/native/godd-native-x86_64-pc-windows-msvc.exe +0 -0
- package/assets/native/godd-native-x86_64-pc-windows-msvc.exe.sha256 +1 -1
- package/assets/native/godd-native-x86_64-unknown-linux-musl +0 -0
- package/assets/native/godd-native-x86_64-unknown-linux-musl.sha256 +1 -1
- package/dist/godd.cjs +212 -206
- package/godd-tk-assets.json +26 -0
- package/native-assets.json +12 -12
- package/package.json +4 -1
- package/scripts/atomic-binary-install.js +679 -0
- package/scripts/godd-tk-assets.js +154 -0
- package/scripts/native-assets.js +3 -2
- package/scripts/postinstall-godd-tk.js +297 -77
- package/scripts/postinstall.js +30 -1
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
constants,
|
|
5
|
+
existsSync,
|
|
6
|
+
fstatSync,
|
|
7
|
+
linkSync,
|
|
8
|
+
lstatSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
openSync,
|
|
12
|
+
opendirSync,
|
|
13
|
+
readSync,
|
|
14
|
+
renameSync,
|
|
15
|
+
rmdirSync,
|
|
16
|
+
rmSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const LOCK_SCHEMA_VERSION = 1;
|
|
23
|
+
const MAX_LOCK_BYTES = 512;
|
|
24
|
+
const MAX_LOCK_BYTES_BIGINT = BigInt(MAX_LOCK_BYTES);
|
|
25
|
+
const RECOVERY_SCHEMA_VERSION = 1;
|
|
26
|
+
const MAX_RECOVERY_BYTES = 1024;
|
|
27
|
+
const MAX_RECOVERY_BYTES_BIGINT = BigInt(MAX_RECOVERY_BYTES);
|
|
28
|
+
const RECOVERY_RECORD_NAME = "recovery.json";
|
|
29
|
+
const RETIRED_RECOVERY_SUFFIX = ".retired";
|
|
30
|
+
const MAX_ACQUIRE_ATTEMPTS = 8;
|
|
31
|
+
const OPERATION_ID_PATTERN =
|
|
32
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
33
|
+
|
|
34
|
+
/** Deterministic test seam representing termination that cannot run rollback/finally. */
|
|
35
|
+
export class AtomicInstallInterruption extends Error {}
|
|
36
|
+
|
|
37
|
+
/** Publish failed and the previous verified state could not be restored. */
|
|
38
|
+
export class AtomicInstallRollbackError extends Error {}
|
|
39
|
+
|
|
40
|
+
/** The published bytes failed trusted verification after reaching the final path. */
|
|
41
|
+
export class AtomicInstallIntegrityError extends Error {}
|
|
42
|
+
|
|
43
|
+
/** Final bytes are verified, but the previous backup could not be removed. */
|
|
44
|
+
export class AtomicInstallCleanupError extends Error {}
|
|
45
|
+
|
|
46
|
+
function defaultIsProcessAlive(pid) {
|
|
47
|
+
try {
|
|
48
|
+
process.kill(pid, 0);
|
|
49
|
+
return true;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return !(error && typeof error === "object" && error.code === "ESRCH");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readOwner(path) {
|
|
56
|
+
const pathStat = lstatSync(path, { bigint: true });
|
|
57
|
+
if (
|
|
58
|
+
pathStat.isSymbolicLink() ||
|
|
59
|
+
!pathStat.isFile() ||
|
|
60
|
+
pathStat.ino === 0n ||
|
|
61
|
+
pathStat.size > MAX_LOCK_BYTES_BIGINT
|
|
62
|
+
) {
|
|
63
|
+
throw new Error("INSTALL_LOCK_INVALID");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
67
|
+
const descriptor = openSync(path, constants.O_RDONLY | noFollow);
|
|
68
|
+
let bytes;
|
|
69
|
+
try {
|
|
70
|
+
const handleStat = fstatSync(descriptor, { bigint: true });
|
|
71
|
+
if (
|
|
72
|
+
!handleStat.isFile() ||
|
|
73
|
+
handleStat.ino === 0n ||
|
|
74
|
+
handleStat.size > MAX_LOCK_BYTES_BIGINT ||
|
|
75
|
+
handleStat.dev !== pathStat.dev ||
|
|
76
|
+
handleStat.ino !== pathStat.ino
|
|
77
|
+
) {
|
|
78
|
+
throw new Error("INSTALL_LOCK_INVALID");
|
|
79
|
+
}
|
|
80
|
+
const boundedBuffer = Buffer.allocUnsafe(MAX_LOCK_BYTES + 1);
|
|
81
|
+
const byteLength = readSync(
|
|
82
|
+
descriptor,
|
|
83
|
+
boundedBuffer,
|
|
84
|
+
0,
|
|
85
|
+
boundedBuffer.byteLength,
|
|
86
|
+
0,
|
|
87
|
+
);
|
|
88
|
+
bytes = boundedBuffer.subarray(0, byteLength);
|
|
89
|
+
} finally {
|
|
90
|
+
closeSync(descriptor);
|
|
91
|
+
}
|
|
92
|
+
if (bytes.byteLength > MAX_LOCK_BYTES) {
|
|
93
|
+
throw new Error("INSTALL_LOCK_INVALID");
|
|
94
|
+
}
|
|
95
|
+
const owner = JSON.parse(bytes.toString("utf8"));
|
|
96
|
+
if (
|
|
97
|
+
!owner ||
|
|
98
|
+
typeof owner !== "object" ||
|
|
99
|
+
Array.isArray(owner) ||
|
|
100
|
+
JSON.stringify(Object.keys(owner).sort()) !==
|
|
101
|
+
JSON.stringify(["operationId", "pid", "schemaVersion", "tempName"]) ||
|
|
102
|
+
owner.schemaVersion !== LOCK_SCHEMA_VERSION ||
|
|
103
|
+
!Number.isSafeInteger(owner.pid) ||
|
|
104
|
+
owner.pid <= 0 ||
|
|
105
|
+
typeof owner.operationId !== "string" ||
|
|
106
|
+
!OPERATION_ID_PATTERN.test(owner.operationId) ||
|
|
107
|
+
typeof owner.tempName !== "string" ||
|
|
108
|
+
!new RegExp(`^\\.godd-tk-${owner.operationId}-[A-Za-z0-9_-]{1,64}$`).test(
|
|
109
|
+
owner.tempName,
|
|
110
|
+
)
|
|
111
|
+
) {
|
|
112
|
+
throw new Error("INSTALL_LOCK_INVALID");
|
|
113
|
+
}
|
|
114
|
+
return owner;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isAlreadyExists(error) {
|
|
118
|
+
return (
|
|
119
|
+
error &&
|
|
120
|
+
typeof error === "object" &&
|
|
121
|
+
["EEXIST", "EPERM"].includes(error.code)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function ownersEqual(left, right) {
|
|
126
|
+
return (
|
|
127
|
+
left.schemaVersion === right.schemaVersion &&
|
|
128
|
+
left.pid === right.pid &&
|
|
129
|
+
left.operationId === right.operationId &&
|
|
130
|
+
left.tempName === right.tempName
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function readBoundedDirectoryEntries(directoryPath) {
|
|
135
|
+
const gateStat = lstatSync(directoryPath, { bigint: true });
|
|
136
|
+
if (gateStat.isSymbolicLink() || !gateStat.isDirectory()) {
|
|
137
|
+
throw new Error("INSTALL_RECOVERY_INVALID");
|
|
138
|
+
}
|
|
139
|
+
const directory = opendirSync(directoryPath);
|
|
140
|
+
const entries = [];
|
|
141
|
+
try {
|
|
142
|
+
for (let index = 0; index < 2; index += 1) {
|
|
143
|
+
const entry = directory.readSync();
|
|
144
|
+
if (!entry) break;
|
|
145
|
+
entries.push(entry.name);
|
|
146
|
+
}
|
|
147
|
+
} finally {
|
|
148
|
+
directory.closeSync();
|
|
149
|
+
}
|
|
150
|
+
return entries.sort();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readRecoveryRecord(recoveryPath) {
|
|
154
|
+
const entries = readBoundedDirectoryEntries(recoveryPath);
|
|
155
|
+
if (
|
|
156
|
+
entries.length !== 1 ||
|
|
157
|
+
entries[0] !== RECOVERY_RECORD_NAME
|
|
158
|
+
) {
|
|
159
|
+
throw new Error("INSTALL_RECOVERY_ENTRIES_INVALID");
|
|
160
|
+
}
|
|
161
|
+
const recordPath = join(recoveryPath, RECOVERY_RECORD_NAME);
|
|
162
|
+
const pathStat = lstatSync(recordPath, { bigint: true });
|
|
163
|
+
if (
|
|
164
|
+
pathStat.isSymbolicLink() ||
|
|
165
|
+
!pathStat.isFile() ||
|
|
166
|
+
pathStat.ino === 0n ||
|
|
167
|
+
pathStat.size > MAX_RECOVERY_BYTES_BIGINT
|
|
168
|
+
) {
|
|
169
|
+
throw new Error("INSTALL_RECOVERY_INVALID");
|
|
170
|
+
}
|
|
171
|
+
const descriptor = openSync(
|
|
172
|
+
recordPath,
|
|
173
|
+
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
|
|
174
|
+
);
|
|
175
|
+
let bytes;
|
|
176
|
+
try {
|
|
177
|
+
const handleStat = fstatSync(descriptor, { bigint: true });
|
|
178
|
+
if (
|
|
179
|
+
!handleStat.isFile() ||
|
|
180
|
+
handleStat.ino === 0n ||
|
|
181
|
+
handleStat.size > MAX_RECOVERY_BYTES_BIGINT ||
|
|
182
|
+
handleStat.dev !== pathStat.dev ||
|
|
183
|
+
handleStat.ino !== pathStat.ino
|
|
184
|
+
) {
|
|
185
|
+
throw new Error("INSTALL_RECOVERY_INVALID");
|
|
186
|
+
}
|
|
187
|
+
const boundedBuffer = Buffer.allocUnsafe(MAX_RECOVERY_BYTES + 1);
|
|
188
|
+
const byteLength = readSync(
|
|
189
|
+
descriptor,
|
|
190
|
+
boundedBuffer,
|
|
191
|
+
0,
|
|
192
|
+
boundedBuffer.byteLength,
|
|
193
|
+
0,
|
|
194
|
+
);
|
|
195
|
+
bytes = boundedBuffer.subarray(0, byteLength);
|
|
196
|
+
} finally {
|
|
197
|
+
closeSync(descriptor);
|
|
198
|
+
}
|
|
199
|
+
if (bytes.byteLength > MAX_RECOVERY_BYTES) {
|
|
200
|
+
throw new Error("INSTALL_RECOVERY_INVALID");
|
|
201
|
+
}
|
|
202
|
+
const record = JSON.parse(bytes.toString("utf8"));
|
|
203
|
+
if (
|
|
204
|
+
!record ||
|
|
205
|
+
typeof record !== "object" ||
|
|
206
|
+
Array.isArray(record) ||
|
|
207
|
+
JSON.stringify(Object.keys(record).sort()) !==
|
|
208
|
+
JSON.stringify(["owner", "phase", "retiredName", "schemaVersion"]) ||
|
|
209
|
+
record.schemaVersion !== RECOVERY_SCHEMA_VERSION ||
|
|
210
|
+
record.phase !== "owner-release" ||
|
|
211
|
+
typeof record.retiredName !== "string" ||
|
|
212
|
+
!record.owner ||
|
|
213
|
+
typeof record.owner !== "object" ||
|
|
214
|
+
Array.isArray(record.owner) ||
|
|
215
|
+
JSON.stringify(Object.keys(record.owner).sort()) !==
|
|
216
|
+
JSON.stringify(["operationId", "pid", "schemaVersion", "tempName"]) ||
|
|
217
|
+
record.owner.schemaVersion !== LOCK_SCHEMA_VERSION ||
|
|
218
|
+
!Number.isSafeInteger(record.owner.pid) ||
|
|
219
|
+
record.owner.pid <= 0 ||
|
|
220
|
+
typeof record.owner.operationId !== "string" ||
|
|
221
|
+
!OPERATION_ID_PATTERN.test(record.owner.operationId) ||
|
|
222
|
+
typeof record.owner.tempName !== "string" ||
|
|
223
|
+
!new RegExp(
|
|
224
|
+
`^\\.godd-tk-${record.owner.operationId}-[A-Za-z0-9_-]{1,64}$`,
|
|
225
|
+
).test(record.owner.tempName)
|
|
226
|
+
) {
|
|
227
|
+
throw new Error("INSTALL_RECOVERY_INVALID");
|
|
228
|
+
}
|
|
229
|
+
return record;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function cleanupRetiredRecoveryGate(
|
|
233
|
+
retiredRecoveryPath,
|
|
234
|
+
{
|
|
235
|
+
unlink = unlinkSync,
|
|
236
|
+
rmdir = rmdirSync,
|
|
237
|
+
} = {},
|
|
238
|
+
) {
|
|
239
|
+
const entries = readBoundedDirectoryEntries(retiredRecoveryPath);
|
|
240
|
+
if (
|
|
241
|
+
entries.length > 1 ||
|
|
242
|
+
(entries.length === 1 && entries[0] !== RECOVERY_RECORD_NAME)
|
|
243
|
+
) {
|
|
244
|
+
throw new Error("INSTALL_RECOVERY_ENTRIES_INVALID");
|
|
245
|
+
}
|
|
246
|
+
if (entries.length === 1) {
|
|
247
|
+
unlink(join(retiredRecoveryPath, RECOVERY_RECORD_NAME));
|
|
248
|
+
}
|
|
249
|
+
rmdir(retiredRecoveryPath);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Explicit operator recovery for a process that exited after retiring its
|
|
254
|
+
* stable owner lock. Unknown or mismatched artifacts are never removed.
|
|
255
|
+
*/
|
|
256
|
+
export function recoverInterruptedAtomicFinish({
|
|
257
|
+
binaryPath,
|
|
258
|
+
isProcessAlive = defaultIsProcessAlive,
|
|
259
|
+
operations = {},
|
|
260
|
+
} = {}) {
|
|
261
|
+
const lockPath = `${binaryPath}.install-lock`;
|
|
262
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
263
|
+
const retiredRecoveryPath = `${recoveryPath}${RETIRED_RECOVERY_SUFFIX}`;
|
|
264
|
+
const activeGateExists = existsSync(recoveryPath);
|
|
265
|
+
const retiredGateExists = existsSync(retiredRecoveryPath);
|
|
266
|
+
if (!activeGateExists && !retiredGateExists) return { status: "not-needed" };
|
|
267
|
+
if (activeGateExists && retiredGateExists) {
|
|
268
|
+
return { status: "blocked", reason: "recovery-gate-ambiguous" };
|
|
269
|
+
}
|
|
270
|
+
const gatePath = activeGateExists ? recoveryPath : retiredRecoveryPath;
|
|
271
|
+
let entries;
|
|
272
|
+
try {
|
|
273
|
+
entries = readBoundedDirectoryEntries(gatePath);
|
|
274
|
+
} catch {
|
|
275
|
+
return { status: "blocked", reason: "recovery-record-invalid" };
|
|
276
|
+
}
|
|
277
|
+
if (entries.length === 0) {
|
|
278
|
+
if (activeGateExists) {
|
|
279
|
+
return { status: "blocked", reason: "recovery-record-invalid" };
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
(operations.rmdir ?? rmdirSync)(retiredRecoveryPath);
|
|
283
|
+
return { status: "recovered" };
|
|
284
|
+
} catch {
|
|
285
|
+
return { status: "blocked", reason: "recovery-cleanup-failed" };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (entries.length !== 1 || entries[0] !== RECOVERY_RECORD_NAME) {
|
|
289
|
+
return { status: "blocked", reason: "recovery-gate-entries-invalid" };
|
|
290
|
+
}
|
|
291
|
+
let record;
|
|
292
|
+
try {
|
|
293
|
+
record = readRecoveryRecord(gatePath);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (
|
|
296
|
+
error instanceof Error &&
|
|
297
|
+
error.message === "INSTALL_RECOVERY_ENTRIES_INVALID"
|
|
298
|
+
) {
|
|
299
|
+
return { status: "blocked", reason: "recovery-gate-entries-invalid" };
|
|
300
|
+
}
|
|
301
|
+
return { status: "blocked", reason: "recovery-record-invalid" };
|
|
302
|
+
}
|
|
303
|
+
const expectedRetiredName = `${basename(lockPath)}.release-${record.owner.operationId}`;
|
|
304
|
+
if (record.retiredName !== expectedRetiredName) {
|
|
305
|
+
return { status: "blocked", reason: "recovery-record-mismatch" };
|
|
306
|
+
}
|
|
307
|
+
const resolvedRetiredOwnerPath = join(dirname(lockPath), record.retiredName);
|
|
308
|
+
const stableExists = existsSync(lockPath);
|
|
309
|
+
const retiredExists = existsSync(resolvedRetiredOwnerPath);
|
|
310
|
+
if (stableExists && retiredExists) {
|
|
311
|
+
return { status: "blocked", reason: "recovery-owner-ambiguous" };
|
|
312
|
+
}
|
|
313
|
+
if (!stableExists && !retiredExists && activeGateExists) {
|
|
314
|
+
return { status: "blocked", reason: "recovery-owner-mismatch" };
|
|
315
|
+
}
|
|
316
|
+
if (
|
|
317
|
+
(stableExists || retiredExists) &&
|
|
318
|
+
isProcessAlive(record.owner.pid)
|
|
319
|
+
) {
|
|
320
|
+
return { status: "blocked", reason: "recovery-owner-live" };
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (stableExists) {
|
|
324
|
+
if (!ownersEqual(readOwner(lockPath), record.owner)) {
|
|
325
|
+
return { status: "blocked", reason: "recovery-owner-mismatch" };
|
|
326
|
+
}
|
|
327
|
+
} else if (retiredExists) {
|
|
328
|
+
if (!ownersEqual(readOwner(resolvedRetiredOwnerPath), record.owner)) {
|
|
329
|
+
return { status: "blocked", reason: "recovery-owner-mismatch" };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (activeGateExists) {
|
|
333
|
+
(operations.rename ?? renameSync)(recoveryPath, retiredRecoveryPath);
|
|
334
|
+
}
|
|
335
|
+
if (retiredExists) {
|
|
336
|
+
(operations.unlink ?? unlinkSync)(resolvedRetiredOwnerPath);
|
|
337
|
+
}
|
|
338
|
+
cleanupRetiredRecoveryGate(retiredRecoveryPath, operations);
|
|
339
|
+
return { status: "recovered" };
|
|
340
|
+
} catch {
|
|
341
|
+
return { status: "blocked", reason: "recovery-cleanup-failed" };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* A complete owner record is hard-linked into the stable lock path. Unlike
|
|
347
|
+
* mkdir-then-write, there is no observable lock whose owner is only partially
|
|
348
|
+
* written when the process is interrupted.
|
|
349
|
+
*/
|
|
350
|
+
export function beginAtomicBinaryInstall({
|
|
351
|
+
installDir,
|
|
352
|
+
binaryPath,
|
|
353
|
+
operationId = randomUUID(),
|
|
354
|
+
ownerPid = process.pid,
|
|
355
|
+
isProcessAlive = defaultIsProcessAlive,
|
|
356
|
+
makeTempDir = (prefix) => mkdtempSync(prefix),
|
|
357
|
+
onRecoveryTransition = () => {},
|
|
358
|
+
} = {}) {
|
|
359
|
+
if (!OPERATION_ID_PATTERN.test(operationId) || !Number.isSafeInteger(ownerPid) || ownerPid <= 0) {
|
|
360
|
+
throw new Error("INSTALL_OWNER_INVALID");
|
|
361
|
+
}
|
|
362
|
+
mkdirSync(installDir, { recursive: true });
|
|
363
|
+
const tempDir = makeTempDir(join(installDir, `.godd-tk-${operationId}-`));
|
|
364
|
+
const ownerCandidatePath = join(tempDir, "owner.json");
|
|
365
|
+
const lockPath = `${binaryPath}.install-lock`;
|
|
366
|
+
const backupPath = `${binaryPath}.install-backup`;
|
|
367
|
+
const recoveryPath = `${lockPath}.recovery`;
|
|
368
|
+
const retiredRecoveryPath = `${recoveryPath}${RETIRED_RECOVERY_SUFFIX}`;
|
|
369
|
+
const owner = {
|
|
370
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
371
|
+
pid: ownerPid,
|
|
372
|
+
operationId,
|
|
373
|
+
tempName: basename(tempDir),
|
|
374
|
+
};
|
|
375
|
+
writeFileSync(ownerCandidatePath, `${JSON.stringify(owner)}\n`, {
|
|
376
|
+
encoding: "utf8",
|
|
377
|
+
flag: "wx",
|
|
378
|
+
mode: 0o600,
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt += 1) {
|
|
382
|
+
if (existsSync(recoveryPath) || existsSync(retiredRecoveryPath)) {
|
|
383
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
384
|
+
return { status: "blocked", reason: "recovery-lock-present" };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
linkSync(ownerCandidatePath, lockPath);
|
|
389
|
+
if (existsSync(recoveryPath) || existsSync(retiredRecoveryPath)) {
|
|
390
|
+
try {
|
|
391
|
+
const current = readOwner(lockPath);
|
|
392
|
+
if (ownersEqual(current, owner)) unlinkSync(lockPath);
|
|
393
|
+
} catch {
|
|
394
|
+
// The recovery owner controls this boundary. Never remove an unknown lock.
|
|
395
|
+
}
|
|
396
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
397
|
+
return { status: "blocked", reason: "recovery-lock-present" };
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
status: "acquired",
|
|
401
|
+
binaryPath,
|
|
402
|
+
backupPath,
|
|
403
|
+
lockPath,
|
|
404
|
+
owner,
|
|
405
|
+
recoveryPath,
|
|
406
|
+
retiredRecoveryPath,
|
|
407
|
+
tempDir,
|
|
408
|
+
};
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (!isAlreadyExists(error)) {
|
|
411
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
let incumbent;
|
|
417
|
+
try {
|
|
418
|
+
incumbent = readOwner(lockPath);
|
|
419
|
+
} catch {
|
|
420
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
421
|
+
return { status: "blocked", reason: "lock-invalid" };
|
|
422
|
+
}
|
|
423
|
+
if (isProcessAlive(incumbent.pid)) {
|
|
424
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
425
|
+
return { status: "busy" };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
mkdirSync(recoveryPath);
|
|
430
|
+
onRecoveryTransition("recovery-locked");
|
|
431
|
+
} catch (error) {
|
|
432
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
433
|
+
if (isAlreadyExists(error)) {
|
|
434
|
+
return { status: "blocked", reason: "recovery-lock-present" };
|
|
435
|
+
}
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const staleOwnerPath = join(tempDir, `stale-owner-${attempt}.json`);
|
|
440
|
+
let releaseRecovery = true;
|
|
441
|
+
try {
|
|
442
|
+
const current = readOwner(lockPath);
|
|
443
|
+
if (!ownersEqual(current, incumbent)) {
|
|
444
|
+
rmdirSync(recoveryPath);
|
|
445
|
+
releaseRecovery = false;
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (isProcessAlive(current.pid)) {
|
|
449
|
+
rmdirSync(recoveryPath);
|
|
450
|
+
releaseRecovery = false;
|
|
451
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
452
|
+
return { status: "busy" };
|
|
453
|
+
}
|
|
454
|
+
renameSync(lockPath, staleOwnerPath);
|
|
455
|
+
const retired = readOwner(staleOwnerPath);
|
|
456
|
+
if (!ownersEqual(retired, incumbent)) throw new Error("INSTALL_LOCK_CHANGED");
|
|
457
|
+
rmSync(join(installDir, incumbent.tempName), { recursive: true, force: true });
|
|
458
|
+
unlinkSync(staleOwnerPath);
|
|
459
|
+
onRecoveryTransition("stale-retired");
|
|
460
|
+
try {
|
|
461
|
+
linkSync(ownerCandidatePath, lockPath);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
rmdirSync(recoveryPath);
|
|
464
|
+
releaseRecovery = false;
|
|
465
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
466
|
+
if (isAlreadyExists(error)) {
|
|
467
|
+
return { status: "blocked", reason: "lock-contention" };
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
rmdirSync(recoveryPath);
|
|
473
|
+
releaseRecovery = false;
|
|
474
|
+
} catch {
|
|
475
|
+
return { status: "blocked", reason: "recovery-lock-stuck" };
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
status: "acquired",
|
|
479
|
+
binaryPath,
|
|
480
|
+
backupPath,
|
|
481
|
+
lockPath,
|
|
482
|
+
owner,
|
|
483
|
+
recoveryPath,
|
|
484
|
+
retiredRecoveryPath,
|
|
485
|
+
tempDir,
|
|
486
|
+
};
|
|
487
|
+
} catch (error) {
|
|
488
|
+
if (existsSync(staleOwnerPath) && !existsSync(lockPath)) {
|
|
489
|
+
try {
|
|
490
|
+
renameSync(staleOwnerPath, lockPath);
|
|
491
|
+
} catch {
|
|
492
|
+
// A competing recovery may already own the stable lock. Never remove it.
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (error && typeof error === "object" && error.code === "ENOENT") continue;
|
|
496
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
497
|
+
throw error;
|
|
498
|
+
} finally {
|
|
499
|
+
if (releaseRecovery && existsSync(recoveryPath)) {
|
|
500
|
+
try {
|
|
501
|
+
rmdirSync(recoveryPath);
|
|
502
|
+
} catch {
|
|
503
|
+
// A stuck recovery gate is intentionally fail-closed.
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
510
|
+
return { status: "blocked", reason: "lock-contention" };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Resolve the only two interrupted publish states:
|
|
515
|
+
* backup-only restores the previous binary; binary+backup keeps the candidate
|
|
516
|
+
* only when it still passes the caller's trusted digest verifier.
|
|
517
|
+
*/
|
|
518
|
+
export function recoverAtomicBinaryInstall(transaction, verify, operations = {}) {
|
|
519
|
+
const rename = operations.rename ?? renameSync;
|
|
520
|
+
const unlink = operations.unlink ?? unlinkSync;
|
|
521
|
+
if (!existsSync(transaction.backupPath)) return;
|
|
522
|
+
if (!existsSync(transaction.binaryPath)) {
|
|
523
|
+
verify(transaction.backupPath);
|
|
524
|
+
rename(transaction.backupPath, transaction.binaryPath);
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
try {
|
|
528
|
+
verify(transaction.binaryPath);
|
|
529
|
+
} catch {
|
|
530
|
+
verify(transaction.backupPath);
|
|
531
|
+
unlink(transaction.binaryPath);
|
|
532
|
+
rename(transaction.backupPath, transaction.binaryPath);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
unlink(transaction.backupPath);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function publishAtomicBinary(
|
|
539
|
+
transaction,
|
|
540
|
+
candidatePath,
|
|
541
|
+
verify,
|
|
542
|
+
onTransition = () => {},
|
|
543
|
+
operations = {},
|
|
544
|
+
) {
|
|
545
|
+
const pathExists = operations.exists ?? existsSync;
|
|
546
|
+
const rename = operations.rename ?? renameSync;
|
|
547
|
+
const unlink = operations.unlink ?? unlinkSync;
|
|
548
|
+
verify(candidatePath);
|
|
549
|
+
if (pathExists(transaction.backupPath)) {
|
|
550
|
+
throw new Error("INSTALL_BACKUP_UNRESOLVED");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
let originalMoved = false;
|
|
554
|
+
let candidatePublished = false;
|
|
555
|
+
let postPublishIntegrityFailure = false;
|
|
556
|
+
let publishedVerified = false;
|
|
557
|
+
try {
|
|
558
|
+
if (pathExists(transaction.binaryPath)) {
|
|
559
|
+
rename(transaction.binaryPath, transaction.backupPath);
|
|
560
|
+
originalMoved = true;
|
|
561
|
+
onTransition("backup-created");
|
|
562
|
+
}
|
|
563
|
+
rename(candidatePath, transaction.binaryPath);
|
|
564
|
+
candidatePublished = true;
|
|
565
|
+
onTransition("candidate-published");
|
|
566
|
+
try {
|
|
567
|
+
verify(transaction.binaryPath);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
postPublishIntegrityFailure = true;
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
publishedVerified = true;
|
|
573
|
+
if (originalMoved) unlink(transaction.backupPath);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error instanceof AtomicInstallInterruption) throw error;
|
|
576
|
+
if (publishedVerified) {
|
|
577
|
+
throw new AtomicInstallCleanupError("INSTALL_PUBLISH_CLEANUP_PENDING", {
|
|
578
|
+
cause: error,
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
if (candidatePublished && pathExists(transaction.binaryPath)) {
|
|
583
|
+
unlink(transaction.binaryPath);
|
|
584
|
+
}
|
|
585
|
+
if (originalMoved && pathExists(transaction.backupPath)) {
|
|
586
|
+
rename(transaction.backupPath, transaction.binaryPath);
|
|
587
|
+
}
|
|
588
|
+
} catch {
|
|
589
|
+
throw new AtomicInstallRollbackError("INSTALL_ROLLBACK_FAILED", {
|
|
590
|
+
cause: error,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
if (postPublishIntegrityFailure) {
|
|
594
|
+
throw new AtomicInstallIntegrityError("INSTALL_POST_PUBLISH_INTEGRITY_FAILED", {
|
|
595
|
+
cause: error,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
throw error;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function finishAtomicBinaryInstall(
|
|
603
|
+
transaction,
|
|
604
|
+
{
|
|
605
|
+
removeTree = (path) => rmSync(path, { recursive: true, force: true }),
|
|
606
|
+
onReleaseTransition = () => {},
|
|
607
|
+
recoveryOperations = {},
|
|
608
|
+
} = {},
|
|
609
|
+
) {
|
|
610
|
+
try {
|
|
611
|
+
mkdirSync(transaction.recoveryPath);
|
|
612
|
+
} catch {
|
|
613
|
+
return { ok: false, reason: "cleanup-failed" };
|
|
614
|
+
}
|
|
615
|
+
const retiredOwnerPath = `${transaction.lockPath}.release-${transaction.owner.operationId}`;
|
|
616
|
+
const recoveryRecordPath = join(transaction.recoveryPath, RECOVERY_RECORD_NAME);
|
|
617
|
+
try {
|
|
618
|
+
writeFileSync(
|
|
619
|
+
recoveryRecordPath,
|
|
620
|
+
`${JSON.stringify({
|
|
621
|
+
schemaVersion: RECOVERY_SCHEMA_VERSION,
|
|
622
|
+
phase: "owner-release",
|
|
623
|
+
owner: transaction.owner,
|
|
624
|
+
retiredName: basename(retiredOwnerPath),
|
|
625
|
+
})}\n`,
|
|
626
|
+
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
627
|
+
);
|
|
628
|
+
} catch {
|
|
629
|
+
try {
|
|
630
|
+
rmdirSync(transaction.recoveryPath);
|
|
631
|
+
} catch {
|
|
632
|
+
// An unremovable gate remains fail-closed.
|
|
633
|
+
}
|
|
634
|
+
return { ok: false, reason: "cleanup-failed" };
|
|
635
|
+
}
|
|
636
|
+
try {
|
|
637
|
+
removeTree(transaction.tempDir);
|
|
638
|
+
} catch {
|
|
639
|
+
try {
|
|
640
|
+
(recoveryOperations.rename ?? renameSync)(
|
|
641
|
+
transaction.recoveryPath,
|
|
642
|
+
transaction.retiredRecoveryPath,
|
|
643
|
+
);
|
|
644
|
+
cleanupRetiredRecoveryGate(
|
|
645
|
+
transaction.retiredRecoveryPath,
|
|
646
|
+
recoveryOperations,
|
|
647
|
+
);
|
|
648
|
+
} catch {
|
|
649
|
+
// A complete recovery record remains for explicit operator recovery.
|
|
650
|
+
}
|
|
651
|
+
return { ok: false, reason: "cleanup-failed" };
|
|
652
|
+
}
|
|
653
|
+
let cleanupFailed = false;
|
|
654
|
+
try {
|
|
655
|
+
renameSync(transaction.lockPath, retiredOwnerPath);
|
|
656
|
+
onReleaseTransition("owner-retired");
|
|
657
|
+
const owner = readOwner(retiredOwnerPath);
|
|
658
|
+
if (
|
|
659
|
+
!ownersEqual(owner, transaction.owner) ||
|
|
660
|
+
existsSync(transaction.lockPath)
|
|
661
|
+
) {
|
|
662
|
+
return { ok: false, reason: "cleanup-failed" };
|
|
663
|
+
}
|
|
664
|
+
readRecoveryRecord(transaction.recoveryPath);
|
|
665
|
+
(recoveryOperations.rename ?? renameSync)(
|
|
666
|
+
transaction.recoveryPath,
|
|
667
|
+
transaction.retiredRecoveryPath,
|
|
668
|
+
);
|
|
669
|
+
(recoveryOperations.unlink ?? unlinkSync)(retiredOwnerPath);
|
|
670
|
+
cleanupRetiredRecoveryGate(
|
|
671
|
+
transaction.retiredRecoveryPath,
|
|
672
|
+
recoveryOperations,
|
|
673
|
+
);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
if (error instanceof AtomicInstallInterruption) throw error;
|
|
676
|
+
cleanupFailed = true;
|
|
677
|
+
}
|
|
678
|
+
return cleanupFailed ? { ok: false, reason: "cleanup-failed" } : { ok: true };
|
|
679
|
+
}
|