nawabari 0.1.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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +298 -0
- package/dist/cli.js.map +1 -0
- package/dist/domain/doctor.d.ts +20 -0
- package/dist/domain/doctor.js +167 -0
- package/dist/domain/doctor.js.map +1 -0
- package/dist/domain/errors.d.ts +29 -0
- package/dist/domain/errors.js +42 -0
- package/dist/domain/errors.js.map +1 -0
- package/dist/domain/session-backend.d.ts +24 -0
- package/dist/domain/session-backend.js +206 -0
- package/dist/domain/session-backend.js.map +1 -0
- package/dist/domain/session.d.ts +89 -0
- package/dist/domain/session.js +47 -0
- package/dist/domain/session.js.map +1 -0
- package/dist/errors.d.ts +8 -0
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -0
- package/dist/git.d.ts +33 -0
- package/dist/git.js +157 -0
- package/dist/git.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/presentation.d.ts +9 -0
- package/dist/presentation.js +61 -0
- package/dist/presentation.js.map +1 -0
- package/dist/registry/atomic.d.ts +20 -0
- package/dist/registry/atomic.js +128 -0
- package/dist/registry/atomic.js.map +1 -0
- package/dist/registry/errors.d.ts +8 -0
- package/dist/registry/errors.js +30 -0
- package/dist/registry/errors.js.map +1 -0
- package/dist/registry/lock.d.ts +60 -0
- package/dist/registry/lock.js +727 -0
- package/dist/registry/lock.js.map +1 -0
- package/dist/registry/store.d.ts +38 -0
- package/dist/registry/store.js +156 -0
- package/dist/registry/store.js.map +1 -0
- package/dist/registry/types.d.ts +18 -0
- package/dist/registry/types.js +3 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/registry.d.ts +5 -0
- package/dist/registry.js +6 -0
- package/dist/registry.js.map +1 -0
- package/dist/session-id.d.ts +7 -0
- package/dist/session-id.js +32 -0
- package/dist/session-id.js.map +1 -0
- package/dist/session-registry.d.ts +169 -0
- package/dist/session-registry.js +1190 -0
- package/dist/session-registry.js.map +1 -0
- package/package.json +57 -0
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import { hostname as getHostname } from "node:os";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import { writeJsonAtomically, writeJsonAtomicallySync } from "./atomic.js";
|
|
7
|
+
import { RegistryError } from "./errors.js";
|
|
8
|
+
import { LOCK_SCHEMA_VERSION } from "./types.js";
|
|
9
|
+
export class RegistryLockError extends RegistryError {
|
|
10
|
+
lockPath;
|
|
11
|
+
owner;
|
|
12
|
+
constructor(code, message, lockPath, details = {}, owner) {
|
|
13
|
+
super(code, message, details);
|
|
14
|
+
this.name = "RegistryLockError";
|
|
15
|
+
this.lockPath = lockPath;
|
|
16
|
+
this.owner = owner;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const DEFAULT_STALE_AFTER_MS = 5_000;
|
|
20
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 10_000;
|
|
21
|
+
const DEFAULT_RETRY_DELAY_MS = 10;
|
|
22
|
+
// Lock-directory creation and owner metadata publication are separate
|
|
23
|
+
// filesystem operations. Give a contending process enough time to observe
|
|
24
|
+
// the atomic owner-file rename under a loaded CI or local filesystem before
|
|
25
|
+
// treating an otherwise young lock as invalid.
|
|
26
|
+
const DEFAULT_METADATA_GRACE_MS = 1_000;
|
|
27
|
+
function errorCode(error) {
|
|
28
|
+
if (!(error instanceof Error) || !("code" in error)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const code = error.code;
|
|
32
|
+
return typeof code === "string" ? code : undefined;
|
|
33
|
+
}
|
|
34
|
+
function isErrorCode(error, code) {
|
|
35
|
+
return errorCode(error) === code;
|
|
36
|
+
}
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return typeof value === "object" && value !== null;
|
|
39
|
+
}
|
|
40
|
+
function isTimestamp(value) {
|
|
41
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
42
|
+
}
|
|
43
|
+
function parseLockOwner(value) {
|
|
44
|
+
if (!isRecord(value)) {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
const processStartTime = value.processStartTime;
|
|
48
|
+
if (value.schemaVersion !== LOCK_SCHEMA_VERSION ||
|
|
49
|
+
typeof value.token !== "string" ||
|
|
50
|
+
value.token.length === 0 ||
|
|
51
|
+
typeof value.pid !== "number" ||
|
|
52
|
+
!Number.isSafeInteger(value.pid) ||
|
|
53
|
+
value.pid <= 0 ||
|
|
54
|
+
typeof value.hostname !== "string" ||
|
|
55
|
+
value.hostname.length === 0 ||
|
|
56
|
+
(typeof processStartTime !== "string" && processStartTime !== null) ||
|
|
57
|
+
!isTimestamp(value.acquiredAt)) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
62
|
+
token: value.token,
|
|
63
|
+
pid: value.pid,
|
|
64
|
+
hostname: value.hostname,
|
|
65
|
+
processStartTime,
|
|
66
|
+
acquiredAt: value.acquiredAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function readOwner(directory) {
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = await readFile(join(directory, "owner.json"), "utf8");
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return parseLockOwner(JSON.parse(raw));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function readOwnerSync(directory) {
|
|
88
|
+
let raw;
|
|
89
|
+
try {
|
|
90
|
+
raw = fs.readFileSync(join(directory, "owner.json"), "utf8");
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
return parseLockOwner(JSON.parse(raw));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function createOwner(options, now) {
|
|
106
|
+
return {
|
|
107
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
108
|
+
token: randomUUID(),
|
|
109
|
+
pid: process.pid,
|
|
110
|
+
hostname: options.hostname,
|
|
111
|
+
processStartTime: options.processStartTime === undefined ? await readProcessStartTime(process.pid) : options.processStartTime,
|
|
112
|
+
acquiredAt: new Date(now).toISOString(),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function createOwnerSync(options, now) {
|
|
116
|
+
return {
|
|
117
|
+
schemaVersion: LOCK_SCHEMA_VERSION,
|
|
118
|
+
token: randomUUID(),
|
|
119
|
+
pid: process.pid,
|
|
120
|
+
hostname: options.hostname,
|
|
121
|
+
processStartTime: options.processStartTime === undefined ? readProcessStartTimeSync(process.pid) : options.processStartTime,
|
|
122
|
+
acquiredAt: new Date(now).toISOString(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function assertNonNegative(name, value) {
|
|
126
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
127
|
+
throw new RangeError(`${name} must be a non-negative finite number`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
async function sleep(milliseconds) {
|
|
131
|
+
await new Promise((resolvePromise) => {
|
|
132
|
+
setTimeout(resolvePromise, milliseconds);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Read Linux's process start token. A PID alone is not sufficient for safe
|
|
137
|
+
* stale-lock recovery because the operating system can reuse a PID.
|
|
138
|
+
*/
|
|
139
|
+
export async function readProcessStartTime(pid) {
|
|
140
|
+
if (process.platform !== "linux") {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
return parseProcessStartTime(await readFile(`/proc/${pid}/stat`, "utf8"));
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function parseProcessStartTime(raw) {
|
|
151
|
+
const commandEnd = raw.lastIndexOf(")");
|
|
152
|
+
if (commandEnd === -1) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const fields = raw
|
|
156
|
+
.slice(commandEnd + 1)
|
|
157
|
+
.trim()
|
|
158
|
+
.split(/\s+/);
|
|
159
|
+
const startTime = fields[19];
|
|
160
|
+
return startTime === undefined || startTime.length === 0 ? null : startTime;
|
|
161
|
+
}
|
|
162
|
+
function readProcessStartTimeSync(pid) {
|
|
163
|
+
if (process.platform !== "linux") {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return parseProcessStartTime(fs.readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function ownerLiveness(owner, localHostname) {
|
|
174
|
+
if (owner.hostname !== localHostname || owner.processStartTime === null) {
|
|
175
|
+
return "unknown";
|
|
176
|
+
}
|
|
177
|
+
if (process.platform === "linux" && !/^\d+$/.test(owner.processStartTime)) {
|
|
178
|
+
return "unknown";
|
|
179
|
+
}
|
|
180
|
+
const currentStartTime = await readProcessStartTime(owner.pid);
|
|
181
|
+
if (currentStartTime !== null) {
|
|
182
|
+
return currentStartTime === owner.processStartTime ? "alive" : "dead";
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
process.kill(owner.pid, 0);
|
|
186
|
+
return "unknown";
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return isErrorCode(error, "ESRCH") ? "dead" : "unknown";
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function ownerLivenessSync(owner, localHostname) {
|
|
193
|
+
if (owner.hostname !== localHostname || owner.processStartTime === null) {
|
|
194
|
+
return "unknown";
|
|
195
|
+
}
|
|
196
|
+
if (process.platform === "linux" && !/^\d+$/.test(owner.processStartTime)) {
|
|
197
|
+
return "unknown";
|
|
198
|
+
}
|
|
199
|
+
const currentStartTime = readProcessStartTimeSync(owner.pid);
|
|
200
|
+
if (currentStartTime !== null) {
|
|
201
|
+
return currentStartTime === owner.processStartTime ? "alive" : "dead";
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
process.kill(owner.pid, 0);
|
|
205
|
+
return "unknown";
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
return isErrorCode(error, "ESRCH") ? "dead" : "unknown";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function detailsForOwner(owner) {
|
|
212
|
+
return owner === undefined ? {} : { owner };
|
|
213
|
+
}
|
|
214
|
+
function asLockError(error, code, message, lockPath) {
|
|
215
|
+
if (error instanceof RegistryLockError) {
|
|
216
|
+
return error;
|
|
217
|
+
}
|
|
218
|
+
return new RegistryLockError(code, message, lockPath, {
|
|
219
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
class Lease {
|
|
223
|
+
token;
|
|
224
|
+
owner;
|
|
225
|
+
releaseLock;
|
|
226
|
+
released = false;
|
|
227
|
+
constructor(token, owner, releaseLock) {
|
|
228
|
+
this.token = token;
|
|
229
|
+
this.owner = owner;
|
|
230
|
+
this.releaseLock = releaseLock;
|
|
231
|
+
}
|
|
232
|
+
async release() {
|
|
233
|
+
if (this.released) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
await this.releaseLock(this.owner);
|
|
237
|
+
this.released = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
class SyncLease {
|
|
241
|
+
token;
|
|
242
|
+
owner;
|
|
243
|
+
releaseLock;
|
|
244
|
+
released = false;
|
|
245
|
+
constructor(token, owner, releaseLock) {
|
|
246
|
+
this.token = token;
|
|
247
|
+
this.owner = owner;
|
|
248
|
+
this.releaseLock = releaseLock;
|
|
249
|
+
}
|
|
250
|
+
release() {
|
|
251
|
+
if (this.released) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
this.releaseLock(this.owner);
|
|
255
|
+
this.released = true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
export class RepositoryLock {
|
|
259
|
+
options;
|
|
260
|
+
reclaimPath;
|
|
261
|
+
constructor(options) {
|
|
262
|
+
const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
|
|
263
|
+
const acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
264
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
265
|
+
const metadataGraceMs = options.metadataGraceMs ?? DEFAULT_METADATA_GRACE_MS;
|
|
266
|
+
assertNonNegative("staleAfterMs", staleAfterMs);
|
|
267
|
+
assertNonNegative("acquireTimeoutMs", acquireTimeoutMs);
|
|
268
|
+
assertNonNegative("retryDelayMs", retryDelayMs);
|
|
269
|
+
assertNonNegative("metadataGraceMs", metadataGraceMs);
|
|
270
|
+
this.options = {
|
|
271
|
+
lockPath: resolve(options.lockPath),
|
|
272
|
+
staleAfterMs,
|
|
273
|
+
acquireTimeoutMs,
|
|
274
|
+
retryDelayMs,
|
|
275
|
+
metadataGraceMs,
|
|
276
|
+
hostname: options.hostname ?? getHostname(),
|
|
277
|
+
processStartTime: options.processStartTime,
|
|
278
|
+
clock: options.clock ?? Date.now,
|
|
279
|
+
beforeReclaimRemove: options.beforeReclaimRemove,
|
|
280
|
+
onReclaimMarkerObserved: options.onReclaimMarkerObserved,
|
|
281
|
+
};
|
|
282
|
+
this.reclaimPath = `${this.options.lockPath}.reclaim`;
|
|
283
|
+
}
|
|
284
|
+
get lockPath() {
|
|
285
|
+
return this.options.lockPath;
|
|
286
|
+
}
|
|
287
|
+
async acquire() {
|
|
288
|
+
await mkdir(dirname(this.options.lockPath), { recursive: true, mode: 0o700 });
|
|
289
|
+
const deadline = this.options.clock() + this.options.acquireTimeoutMs;
|
|
290
|
+
while (true) {
|
|
291
|
+
const created = await this.tryCreate();
|
|
292
|
+
if (created !== undefined) {
|
|
293
|
+
return created;
|
|
294
|
+
}
|
|
295
|
+
const inspection = await this.inspectExisting();
|
|
296
|
+
if (inspection.kind === "stale") {
|
|
297
|
+
const reclaimed = await this.tryReclaim(inspection.owner);
|
|
298
|
+
if (reclaimed) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const remaining = deadline - this.options.clock();
|
|
303
|
+
if (remaining <= 0) {
|
|
304
|
+
throw new RegistryLockError("LOCK_BUSY", "Repository registry lock is held by another process", this.options.lockPath, detailsForOwner(inspection.owner), inspection.owner);
|
|
305
|
+
}
|
|
306
|
+
await sleep(Math.min(Math.max(this.options.retryDelayMs, 1), remaining));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/** Synchronous adapter for the legacy synchronous SessionRegistry API. */
|
|
310
|
+
acquireSync() {
|
|
311
|
+
fs.mkdirSync(dirname(this.options.lockPath), { recursive: true, mode: 0o700 });
|
|
312
|
+
const deadline = this.options.clock() + this.options.acquireTimeoutMs;
|
|
313
|
+
while (true) {
|
|
314
|
+
const created = this.tryCreateSync();
|
|
315
|
+
if (created !== undefined) {
|
|
316
|
+
return created;
|
|
317
|
+
}
|
|
318
|
+
const inspection = this.inspectExistingSync();
|
|
319
|
+
if (inspection.kind === "stale") {
|
|
320
|
+
const reclaimed = this.tryReclaimSync(inspection.owner);
|
|
321
|
+
if (reclaimed) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const remaining = deadline - this.options.clock();
|
|
326
|
+
if (remaining <= 0) {
|
|
327
|
+
throw new RegistryLockError("LOCK_BUSY", "Repository registry lock is held by another process", this.options.lockPath, detailsForOwner(inspection.owner), inspection.owner);
|
|
328
|
+
}
|
|
329
|
+
waitSync(Math.min(Math.max(this.options.retryDelayMs, 1), remaining));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
async tryCreate() {
|
|
333
|
+
if (await this.reclaimMarkerExists()) {
|
|
334
|
+
this.options.onReclaimMarkerObserved?.();
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
const now = this.options.clock();
|
|
338
|
+
const owner = await createOwner(this.options, now);
|
|
339
|
+
try {
|
|
340
|
+
await mkdir(this.options.lockPath, { mode: 0o700 });
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
if (isErrorCode(error, "EEXIST")) {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot create repository registry lock", this.options.lockPath);
|
|
347
|
+
}
|
|
348
|
+
// A reclaimer may have claimed the marker between the first check and
|
|
349
|
+
// mkdir(). Never publish a new owner while that marker is active.
|
|
350
|
+
if (await this.reclaimMarkerExists()) {
|
|
351
|
+
this.options.onReclaimMarkerObserved?.();
|
|
352
|
+
await rm(this.options.lockPath, { recursive: true, force: true });
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
await writeJsonAtomically(join(this.options.lockPath, "owner.json"), owner, {
|
|
357
|
+
ensureParent: false,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
catch (error) {
|
|
361
|
+
await this.removeCreatedLock(owner.token);
|
|
362
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot initialize repository registry lock", this.options.lockPath);
|
|
363
|
+
}
|
|
364
|
+
return new Lease(owner.token, owner, (leaseOwner) => this.release(leaseOwner));
|
|
365
|
+
}
|
|
366
|
+
tryCreateSync() {
|
|
367
|
+
if (this.reclaimMarkerExistsSync()) {
|
|
368
|
+
this.options.onReclaimMarkerObserved?.();
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
const owner = createOwnerSync(this.options, this.options.clock());
|
|
372
|
+
try {
|
|
373
|
+
fs.mkdirSync(this.options.lockPath, { mode: 0o700 });
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (isErrorCode(error, "EEXIST")) {
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot create repository registry lock", this.options.lockPath);
|
|
380
|
+
}
|
|
381
|
+
if (this.reclaimMarkerExistsSync()) {
|
|
382
|
+
this.options.onReclaimMarkerObserved?.();
|
|
383
|
+
fs.rmSync(this.options.lockPath, { recursive: true, force: true });
|
|
384
|
+
return undefined;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
writeJsonAtomicallySync(join(this.options.lockPath, "owner.json"), owner, { ensureParent: false });
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
this.removeCreatedLockSync(owner.token);
|
|
391
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot initialize repository registry lock", this.options.lockPath);
|
|
392
|
+
}
|
|
393
|
+
return new SyncLease(owner.token, owner, (leaseOwner) => this.releaseSync(leaseOwner));
|
|
394
|
+
}
|
|
395
|
+
async inspectExisting() {
|
|
396
|
+
let lockStats;
|
|
397
|
+
try {
|
|
398
|
+
lockStats = await stat(this.options.lockPath);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
402
|
+
return { kind: "wait" };
|
|
403
|
+
}
|
|
404
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot inspect repository registry lock", this.options.lockPath);
|
|
405
|
+
}
|
|
406
|
+
const owner = await readOwner(this.options.lockPath);
|
|
407
|
+
const now = this.options.clock();
|
|
408
|
+
if (owner === undefined) {
|
|
409
|
+
if (now - lockStats.mtimeMs < this.options.metadataGraceMs) {
|
|
410
|
+
return { kind: "wait" };
|
|
411
|
+
}
|
|
412
|
+
throw new RegistryLockError("LOCK_INVALID", "Repository registry lock metadata is missing or invalid", this.options.lockPath);
|
|
413
|
+
}
|
|
414
|
+
const acquiredAt = Date.parse(owner.acquiredAt);
|
|
415
|
+
const age = now - acquiredAt;
|
|
416
|
+
if (!Number.isFinite(age) || age < 0) {
|
|
417
|
+
throw new RegistryLockError("LOCK_INVALID", "Repository registry lock timestamp is invalid", this.options.lockPath, detailsForOwner(owner), owner);
|
|
418
|
+
}
|
|
419
|
+
if (age < this.options.staleAfterMs) {
|
|
420
|
+
return { kind: "wait", owner };
|
|
421
|
+
}
|
|
422
|
+
const liveness = await ownerLiveness(owner, this.options.hostname);
|
|
423
|
+
if (liveness === "alive") {
|
|
424
|
+
return { kind: "wait", owner };
|
|
425
|
+
}
|
|
426
|
+
if (liveness === "unknown") {
|
|
427
|
+
throw new RegistryLockError("LOCK_STALE", "Repository registry lock is old but its owner cannot be proven dead", this.options.lockPath, { ...detailsForOwner(owner), reason: "owner_liveness_unknown" }, owner);
|
|
428
|
+
}
|
|
429
|
+
return { kind: "stale", owner };
|
|
430
|
+
}
|
|
431
|
+
inspectExistingSync() {
|
|
432
|
+
let lockStats;
|
|
433
|
+
try {
|
|
434
|
+
lockStats = fs.statSync(this.options.lockPath);
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
438
|
+
return { kind: "wait" };
|
|
439
|
+
}
|
|
440
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot inspect repository registry lock", this.options.lockPath);
|
|
441
|
+
}
|
|
442
|
+
const owner = readOwnerSync(this.options.lockPath);
|
|
443
|
+
const now = this.options.clock();
|
|
444
|
+
if (owner === undefined) {
|
|
445
|
+
if (now - lockStats.mtimeMs < this.options.metadataGraceMs) {
|
|
446
|
+
return { kind: "wait" };
|
|
447
|
+
}
|
|
448
|
+
throw new RegistryLockError("LOCK_INVALID", "Repository registry lock metadata is missing or invalid", this.options.lockPath);
|
|
449
|
+
}
|
|
450
|
+
const age = now - Date.parse(owner.acquiredAt);
|
|
451
|
+
if (!Number.isFinite(age) || age < 0) {
|
|
452
|
+
throw new RegistryLockError("LOCK_INVALID", "Repository registry lock timestamp is invalid", this.options.lockPath, detailsForOwner(owner), owner);
|
|
453
|
+
}
|
|
454
|
+
if (age < this.options.staleAfterMs) {
|
|
455
|
+
return { kind: "wait", owner };
|
|
456
|
+
}
|
|
457
|
+
const liveness = ownerLivenessSync(owner, this.options.hostname);
|
|
458
|
+
if (liveness === "alive") {
|
|
459
|
+
return { kind: "wait", owner };
|
|
460
|
+
}
|
|
461
|
+
if (liveness === "unknown") {
|
|
462
|
+
throw new RegistryLockError("LOCK_STALE", "Repository registry lock is old but its owner cannot be proven dead", this.options.lockPath, { ...detailsForOwner(owner), reason: "owner_liveness_unknown" }, owner);
|
|
463
|
+
}
|
|
464
|
+
return { kind: "stale", owner };
|
|
465
|
+
}
|
|
466
|
+
async tryReclaim(owner) {
|
|
467
|
+
let markerCreated = false;
|
|
468
|
+
try {
|
|
469
|
+
await mkdir(this.reclaimPath, { mode: 0o700 });
|
|
470
|
+
markerCreated = true;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (!isErrorCode(error, "EEXIST")) {
|
|
474
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot coordinate stale lock recovery", this.options.lockPath);
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
await this.handleExistingReclaimer();
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
// An active reclaimer is a normal contention state. Wait for its
|
|
481
|
+
// marker to disappear; malformed or abandoned metadata still fails
|
|
482
|
+
// closed through the typed error.
|
|
483
|
+
if (error instanceof RegistryLockError && error.details.reason === "reclaimer_active") {
|
|
484
|
+
return false;
|
|
485
|
+
}
|
|
486
|
+
throw error;
|
|
487
|
+
}
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
const reclaimer = await createOwner(this.options, this.options.clock());
|
|
491
|
+
try {
|
|
492
|
+
await writeJsonAtomically(join(this.reclaimPath, "owner.json"), reclaimer, {
|
|
493
|
+
ensureParent: false,
|
|
494
|
+
});
|
|
495
|
+
const currentOwner = await readOwner(this.options.lockPath);
|
|
496
|
+
if (currentOwner === undefined || currentOwner.token !== owner.token) {
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
const currentLiveness = await ownerLiveness(currentOwner, this.options.hostname);
|
|
500
|
+
const currentAge = this.options.clock() - Date.parse(currentOwner.acquiredAt);
|
|
501
|
+
if (currentLiveness !== "dead" || currentAge < this.options.staleAfterMs) {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
const finalOwner = await readOwner(this.options.lockPath);
|
|
505
|
+
if (finalOwner === undefined || finalOwner.token !== owner.token) {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
const finalAge = this.options.clock() - Date.parse(finalOwner.acquiredAt);
|
|
509
|
+
if ((await ownerLiveness(finalOwner, this.options.hostname)) !== "dead" ||
|
|
510
|
+
!Number.isFinite(finalAge) ||
|
|
511
|
+
finalAge < this.options.staleAfterMs ||
|
|
512
|
+
!(await this.reclaimMarkerExists())) {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
// The marker blocks new creators for the entire interval between the
|
|
516
|
+
// final owner-token check and removal. This is the TOCTOU boundary.
|
|
517
|
+
await this.options.beforeReclaimRemove?.();
|
|
518
|
+
try {
|
|
519
|
+
await rm(this.options.lockPath, { recursive: true, force: false });
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
if (!isErrorCode(error, "ENOENT")) {
|
|
523
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot remove stale repository registry lock", this.options.lockPath);
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
finally {
|
|
530
|
+
if (markerCreated) {
|
|
531
|
+
await rm(this.reclaimPath, { recursive: true, force: true });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
tryReclaimSync(owner) {
|
|
536
|
+
let markerCreated = false;
|
|
537
|
+
try {
|
|
538
|
+
fs.mkdirSync(this.reclaimPath, { mode: 0o700 });
|
|
539
|
+
markerCreated = true;
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
if (!isErrorCode(error, "EEXIST")) {
|
|
543
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot coordinate stale lock recovery", this.options.lockPath);
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
this.handleExistingReclaimerSync();
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
if (error instanceof RegistryLockError && error.details.reason === "reclaimer_active") {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
throw error;
|
|
553
|
+
}
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
const reclaimer = createOwnerSync(this.options, this.options.clock());
|
|
557
|
+
try {
|
|
558
|
+
writeJsonAtomicallySync(join(this.reclaimPath, "owner.json"), reclaimer, { ensureParent: false });
|
|
559
|
+
const currentOwner = readOwnerSync(this.options.lockPath);
|
|
560
|
+
if (currentOwner === undefined || currentOwner.token !== owner.token) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
const currentAge = this.options.clock() - Date.parse(currentOwner.acquiredAt);
|
|
564
|
+
if (ownerLivenessSync(currentOwner, this.options.hostname) !== "dead" || currentAge < this.options.staleAfterMs) {
|
|
565
|
+
return false;
|
|
566
|
+
}
|
|
567
|
+
const finalOwner = readOwnerSync(this.options.lockPath);
|
|
568
|
+
const finalAge = finalOwner === undefined ? Number.NaN : this.options.clock() - Date.parse(finalOwner.acquiredAt);
|
|
569
|
+
if (finalOwner === undefined ||
|
|
570
|
+
finalOwner.token !== owner.token ||
|
|
571
|
+
ownerLivenessSync(finalOwner, this.options.hostname) !== "dead" ||
|
|
572
|
+
!Number.isFinite(finalAge) ||
|
|
573
|
+
finalAge < this.options.staleAfterMs ||
|
|
574
|
+
!this.reclaimMarkerExistsSync()) {
|
|
575
|
+
return false;
|
|
576
|
+
}
|
|
577
|
+
const hookResult = this.options.beforeReclaimRemove?.();
|
|
578
|
+
if (hookResult !== undefined && typeof hookResult.then === "function") {
|
|
579
|
+
throw new RegistryLockError("LOCK_IO_ERROR", "Synchronous stale-lock reclaim hook returned a promise", this.options.lockPath);
|
|
580
|
+
}
|
|
581
|
+
try {
|
|
582
|
+
fs.rmSync(this.options.lockPath, { recursive: true, force: false });
|
|
583
|
+
}
|
|
584
|
+
catch (error) {
|
|
585
|
+
if (!isErrorCode(error, "ENOENT")) {
|
|
586
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot remove stale repository registry lock", this.options.lockPath);
|
|
587
|
+
}
|
|
588
|
+
return false;
|
|
589
|
+
}
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
if (markerCreated) {
|
|
594
|
+
fs.rmSync(this.reclaimPath, { recursive: true, force: true });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
async handleExistingReclaimer() {
|
|
599
|
+
const reclaimer = await readOwner(this.reclaimPath);
|
|
600
|
+
if (reclaimer === undefined) {
|
|
601
|
+
throw new RegistryLockError("LOCK_STALE", "Stale lock recovery is already in progress with invalid metadata", this.options.lockPath, { reason: "reclaimer_invalid" });
|
|
602
|
+
}
|
|
603
|
+
const age = this.options.clock() - Date.parse(reclaimer.acquiredAt);
|
|
604
|
+
const liveness = await ownerLiveness(reclaimer, this.options.hostname);
|
|
605
|
+
if (liveness !== "dead" || age < this.options.staleAfterMs) {
|
|
606
|
+
throw new RegistryLockError("LOCK_STALE", "Stale lock recovery is already in progress", this.options.lockPath, { reason: "reclaimer_active", reclaimer }, reclaimer);
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
await rm(this.reclaimPath, { recursive: true, force: false });
|
|
610
|
+
}
|
|
611
|
+
catch (error) {
|
|
612
|
+
if (!isErrorCode(error, "ENOENT")) {
|
|
613
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot clear stale lock recovery marker", this.options.lockPath);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
handleExistingReclaimerSync() {
|
|
618
|
+
const reclaimer = readOwnerSync(this.reclaimPath);
|
|
619
|
+
if (reclaimer === undefined) {
|
|
620
|
+
throw new RegistryLockError("LOCK_STALE", "Stale lock recovery is already in progress with invalid metadata", this.options.lockPath, { reason: "reclaimer_invalid" });
|
|
621
|
+
}
|
|
622
|
+
const age = this.options.clock() - Date.parse(reclaimer.acquiredAt);
|
|
623
|
+
const liveness = ownerLivenessSync(reclaimer, this.options.hostname);
|
|
624
|
+
if (liveness !== "dead" || age < this.options.staleAfterMs) {
|
|
625
|
+
throw new RegistryLockError("LOCK_STALE", "Stale lock recovery is already in progress", this.options.lockPath, { reason: "reclaimer_active", reclaimer }, reclaimer);
|
|
626
|
+
}
|
|
627
|
+
try {
|
|
628
|
+
fs.rmSync(this.reclaimPath, { recursive: true, force: false });
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
if (!isErrorCode(error, "ENOENT")) {
|
|
632
|
+
throw asLockError(error, "LOCK_IO_ERROR", "Cannot clear stale lock recovery marker", this.options.lockPath);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async removeCreatedLock(token) {
|
|
637
|
+
const owner = await readOwner(this.options.lockPath);
|
|
638
|
+
if (owner !== undefined && owner.token !== token) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
await rm(this.options.lockPath, { recursive: true, force: true });
|
|
642
|
+
}
|
|
643
|
+
removeCreatedLockSync(token) {
|
|
644
|
+
const owner = readOwnerSync(this.options.lockPath);
|
|
645
|
+
if (owner !== undefined && owner.token !== token) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
fs.rmSync(this.options.lockPath, { recursive: true, force: true });
|
|
649
|
+
}
|
|
650
|
+
async release(owner) {
|
|
651
|
+
const currentOwner = await readOwner(this.options.lockPath);
|
|
652
|
+
if (currentOwner === undefined) {
|
|
653
|
+
try {
|
|
654
|
+
await stat(this.options.lockPath);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Cannot verify repository registry lock during release", this.options.lockPath, { cause: error instanceof Error ? error.message : String(error) }, owner);
|
|
661
|
+
}
|
|
662
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Repository registry lock metadata disappeared during release", this.options.lockPath, {}, owner);
|
|
663
|
+
}
|
|
664
|
+
if (currentOwner.token !== owner.token) {
|
|
665
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Repository registry lock is owned by another token", this.options.lockPath, detailsForOwner(currentOwner), currentOwner);
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
await rm(this.options.lockPath, { recursive: true, force: false });
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Cannot release repository registry lock", this.options.lockPath, { cause: error instanceof Error ? error.message : String(error) }, owner);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
releaseSync(owner) {
|
|
675
|
+
const currentOwner = readOwnerSync(this.options.lockPath);
|
|
676
|
+
if (currentOwner === undefined) {
|
|
677
|
+
try {
|
|
678
|
+
fs.statSync(this.options.lockPath);
|
|
679
|
+
}
|
|
680
|
+
catch (error) {
|
|
681
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Cannot verify repository registry lock during release", this.options.lockPath, { cause: error instanceof Error ? error.message : String(error) }, owner);
|
|
685
|
+
}
|
|
686
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Repository registry lock metadata disappeared during release", this.options.lockPath, {}, owner);
|
|
687
|
+
}
|
|
688
|
+
if (currentOwner.token !== owner.token) {
|
|
689
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Repository registry lock is owned by another token", this.options.lockPath, detailsForOwner(currentOwner), currentOwner);
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
fs.rmSync(this.options.lockPath, { recursive: true, force: false });
|
|
693
|
+
}
|
|
694
|
+
catch (error) {
|
|
695
|
+
throw new RegistryLockError("LOCK_RELEASE_FAILED", "Cannot release repository registry lock", this.options.lockPath, { cause: error instanceof Error ? error.message : String(error) }, owner);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
async reclaimMarkerExists() {
|
|
699
|
+
try {
|
|
700
|
+
await stat(this.reclaimPath);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
catch (error) {
|
|
704
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
throw error;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
reclaimMarkerExistsSync() {
|
|
711
|
+
try {
|
|
712
|
+
fs.statSync(this.reclaimPath);
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
if (isErrorCode(error, "ENOENT")) {
|
|
717
|
+
return false;
|
|
718
|
+
}
|
|
719
|
+
throw error;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function waitSync(milliseconds) {
|
|
724
|
+
const buffer = new Int32Array(new SharedArrayBuffer(4));
|
|
725
|
+
Atomics.wait(buffer, 0, 0, milliseconds);
|
|
726
|
+
}
|
|
727
|
+
//# sourceMappingURL=lock.js.map
|