opencode-goal-plugin 0.6.7 → 0.6.8
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/CHANGELOG.md +6 -0
- package/README.md +9 -4
- package/docs/compatibility.md +29 -1
- package/package.json +1 -1
- package/src/goal-plugin.js +473 -115
- package/src/persistence-lease.js +594 -59
package/src/persistence-lease.js
CHANGED
|
@@ -1,7 +1,91 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto"
|
|
2
|
-
import { promises as fs } from "node:fs"
|
|
2
|
+
import { constants as fsConstants, promises as fs } from "node:fs"
|
|
3
3
|
import { hostname } from "node:os"
|
|
4
|
-
import { dirname } from "node:path"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
|
|
6
|
+
export const PERSISTENCE_LEASE_CONTENDED = "GOAL_PERSISTENCE_LEASE_CONTENDED"
|
|
7
|
+
const LEASE_PROTOCOL_VERSION = 2
|
|
8
|
+
const LEGACY_SENTINEL_TOKEN = "opencode-goal-plugin-immutable-claims-v2"
|
|
9
|
+
const LEGACY_SENTINEL_HOSTNAME = "opencode-goal-plugin-v2.invalid"
|
|
10
|
+
const LEGACY_GUARD_MTIME_MS = Date.UTC(2100, 0, 1)
|
|
11
|
+
const LEGACY_GUARD_MTIME_TOLERANCE_MS = 2_000
|
|
12
|
+
const CLAIM_DIRECTORY_SUFFIX = ".claims-v2"
|
|
13
|
+
const CLAIM_PREFIX = "claim-"
|
|
14
|
+
const CLAIM_SUFFIX = ".json"
|
|
15
|
+
const MAX_OWNER_FILE_BYTES = 4 * 1024
|
|
16
|
+
const MAX_OWNER_TOKEN_LENGTH = 256
|
|
17
|
+
const MAX_OWNER_HOSTNAME_LENGTH = 255
|
|
18
|
+
const MAX_ACQUIRE_ATTEMPTS = 5
|
|
19
|
+
|
|
20
|
+
function validStoredHostname(value) {
|
|
21
|
+
return (
|
|
22
|
+
typeof value === "string" &&
|
|
23
|
+
value.length >= 1 &&
|
|
24
|
+
value.length <= MAX_OWNER_HOSTNAME_LENGTH
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validDisplayHostname(value) {
|
|
29
|
+
return validStoredHostname(value) && /^[A-Za-z0-9._-]+$/.test(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validOwner(owner) {
|
|
33
|
+
return (
|
|
34
|
+
owner !== null &&
|
|
35
|
+
typeof owner === "object" &&
|
|
36
|
+
!Array.isArray(owner) &&
|
|
37
|
+
typeof owner.token === "string" &&
|
|
38
|
+
owner.token.length >= 1 &&
|
|
39
|
+
owner.token.length <= MAX_OWNER_TOKEN_LENGTH &&
|
|
40
|
+
Number.isSafeInteger(owner.pid) &&
|
|
41
|
+
owner.pid > 0 &&
|
|
42
|
+
validStoredHostname(owner.hostname)
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function sanitizeOwner(owner) {
|
|
47
|
+
const pid = Number.isSafeInteger(owner?.pid) && owner.pid > 0 ? owner.pid : null
|
|
48
|
+
const hostname = validDisplayHostname(owner?.hostname) ? owner.hostname : null
|
|
49
|
+
return Object.freeze({ pid, hostname })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function describeOwner(owner) {
|
|
53
|
+
return owner?.pid && owner?.hostname
|
|
54
|
+
? `pid ${owner.pid} on ${owner.hostname}`
|
|
55
|
+
: "an unknown owner"
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class PersistenceLeaseContendedError extends Error {
|
|
59
|
+
constructor(owner, reason = "owned_elsewhere") {
|
|
60
|
+
const safeOwner = sanitizeOwner(owner)
|
|
61
|
+
const safeReason = reason === "legacy_lock" ? "legacy_lock" : "owned_elsewhere"
|
|
62
|
+
super(safeReason === "legacy_lock"
|
|
63
|
+
? "goal persistence uses a legacy or incomplete lease; close every OpenCode instance using this session, then remove its lease artifacts before retrying"
|
|
64
|
+
: `goal persistence is already owned by ${describeOwner(safeOwner)}; close the other OpenCode instance or open a fork`)
|
|
65
|
+
this.name = "PersistenceLeaseContendedError"
|
|
66
|
+
this.code = PERSISTENCE_LEASE_CONTENDED
|
|
67
|
+
this.owner = safeOwner
|
|
68
|
+
this.reason = safeReason
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function isPersistenceLeaseContendedError(error) {
|
|
73
|
+
return error instanceof PersistenceLeaseContendedError
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function persistenceLeasePathError() {
|
|
77
|
+
const error = new Error("goal persistence lease paths must use their expected real file types")
|
|
78
|
+
error.code = "ERR_GOAL_PERSISTENCE_LEASE_PATH"
|
|
79
|
+
return error
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function persistenceLeaseHardLinkError() {
|
|
83
|
+
const error = new Error(
|
|
84
|
+
"goal persistence requires same-filesystem hard-link support for its compatibility guard",
|
|
85
|
+
)
|
|
86
|
+
error.code = "ERR_GOAL_PERSISTENCE_LEASE_HARDLINK"
|
|
87
|
+
return error
|
|
88
|
+
}
|
|
5
89
|
|
|
6
90
|
function processIsAlive(pid) {
|
|
7
91
|
if (!Number.isSafeInteger(pid) || pid <= 0) return null
|
|
@@ -14,81 +98,532 @@ function processIsAlive(pid) {
|
|
|
14
98
|
}
|
|
15
99
|
}
|
|
16
100
|
|
|
17
|
-
async function
|
|
101
|
+
async function assertLockDirectory(lockPath) {
|
|
102
|
+
let lockInfo
|
|
18
103
|
try {
|
|
19
|
-
|
|
20
|
-
} catch {
|
|
104
|
+
lockInfo = await fs.lstat(lockPath)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.code === "ENOENT") return null
|
|
107
|
+
throw error
|
|
108
|
+
}
|
|
109
|
+
if (lockInfo.isSymbolicLink() || !lockInfo.isDirectory()) {
|
|
110
|
+
throw persistenceLeasePathError()
|
|
111
|
+
}
|
|
112
|
+
return lockInfo
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function readBoundedOwnerFile(handle, expectedInfo) {
|
|
116
|
+
const buffer = Buffer.alloc(MAX_OWNER_FILE_BYTES + 1)
|
|
117
|
+
let bytesReadTotal = 0
|
|
118
|
+
while (bytesReadTotal < buffer.length) {
|
|
119
|
+
const { bytesRead } = await handle.read(
|
|
120
|
+
buffer,
|
|
121
|
+
bytesReadTotal,
|
|
122
|
+
buffer.length - bytesReadTotal,
|
|
123
|
+
bytesReadTotal,
|
|
124
|
+
)
|
|
125
|
+
if (bytesRead === 0) break
|
|
126
|
+
bytesReadTotal += bytesRead
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const finalInfo = await handle.stat()
|
|
130
|
+
if (
|
|
131
|
+
!finalInfo.isFile() ||
|
|
132
|
+
bytesReadTotal === 0 ||
|
|
133
|
+
bytesReadTotal > MAX_OWNER_FILE_BYTES ||
|
|
134
|
+
bytesReadTotal !== expectedInfo.size ||
|
|
135
|
+
finalInfo.size !== expectedInfo.size ||
|
|
136
|
+
finalInfo.dev !== expectedInfo.dev ||
|
|
137
|
+
finalInfo.ino !== expectedInfo.ino
|
|
138
|
+
) {
|
|
21
139
|
return null
|
|
22
140
|
}
|
|
141
|
+
return buffer.toString("utf8", 0, bytesReadTotal)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function readOwnerRecord(ownerPath) {
|
|
145
|
+
let ownerInfo
|
|
146
|
+
try {
|
|
147
|
+
ownerInfo = await fs.lstat(ownerPath)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error?.code === "ENOENT") return { status: "missing", owner: null, info: null }
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
ownerInfo.isSymbolicLink() ||
|
|
154
|
+
!ownerInfo.isFile() ||
|
|
155
|
+
ownerInfo.size === 0 ||
|
|
156
|
+
ownerInfo.size > MAX_OWNER_FILE_BYTES
|
|
157
|
+
) {
|
|
158
|
+
return { status: "malformed", owner: null, info: ownerInfo }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let handle
|
|
162
|
+
try {
|
|
163
|
+
const flags =
|
|
164
|
+
fsConstants.O_RDONLY |
|
|
165
|
+
(fsConstants.O_NOFOLLOW ?? 0) |
|
|
166
|
+
(fsConstants.O_NONBLOCK ?? 0)
|
|
167
|
+
handle = await fs.open(ownerPath, flags)
|
|
168
|
+
const openedInfo = await handle.stat()
|
|
169
|
+
if (
|
|
170
|
+
!openedInfo.isFile() ||
|
|
171
|
+
openedInfo.size === 0 ||
|
|
172
|
+
openedInfo.size > MAX_OWNER_FILE_BYTES ||
|
|
173
|
+
openedInfo.dev !== ownerInfo.dev ||
|
|
174
|
+
openedInfo.ino !== ownerInfo.ino
|
|
175
|
+
) {
|
|
176
|
+
return { status: "malformed", owner: null, info: ownerInfo }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const raw = await readBoundedOwnerFile(handle, openedInfo)
|
|
180
|
+
if (raw === null) return { status: "malformed", owner: null, info: ownerInfo }
|
|
181
|
+
const parsed = JSON.parse(raw)
|
|
182
|
+
return validOwner(parsed)
|
|
183
|
+
? { status: "valid", owner: parsed, info: ownerInfo }
|
|
184
|
+
: { status: "malformed", owner: null, info: ownerInfo }
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof SyntaxError || error?.code === "ENOENT" || error?.code === "ELOOP") {
|
|
187
|
+
return { status: "malformed", owner: null, info: ownerInfo }
|
|
188
|
+
}
|
|
189
|
+
throw error
|
|
190
|
+
} finally {
|
|
191
|
+
await handle?.close().catch(() => {})
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function readOwner(lockPath) {
|
|
196
|
+
let lockInfo
|
|
197
|
+
try {
|
|
198
|
+
lockInfo = await fs.lstat(lockPath)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (error?.code === "ENOENT") return null
|
|
201
|
+
throw error
|
|
202
|
+
}
|
|
203
|
+
if (lockInfo.isSymbolicLink()) throw persistenceLeasePathError()
|
|
204
|
+
const ownerPath = lockInfo.isDirectory()
|
|
205
|
+
? join(lockPath, "owner.json")
|
|
206
|
+
: lockInfo.isFile()
|
|
207
|
+
? lockPath
|
|
208
|
+
: null
|
|
209
|
+
if (!ownerPath) throw persistenceLeasePathError()
|
|
210
|
+
const record = await readOwnerRecord(ownerPath)
|
|
211
|
+
return record.status === "valid" ? record.owner : null
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ownerIsBlocking(owner, localHostname) {
|
|
215
|
+
if (owner.hostname !== localHostname) return true
|
|
216
|
+
return processIsAlive(owner.pid) !== false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function claimNameFor(token) {
|
|
220
|
+
return `${CLAIM_PREFIX}${token}${CLAIM_SUFFIX}`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function isClaimLikeName(name) {
|
|
224
|
+
return name.startsWith(CLAIM_PREFIX) && name.endsWith(CLAIM_SUFFIX)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function tokenFromClaimName(name) {
|
|
228
|
+
if (!isClaimLikeName(name)) return null
|
|
229
|
+
const token = name.slice(CLAIM_PREFIX.length, -CLAIM_SUFFIX.length)
|
|
230
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(token)
|
|
231
|
+
? token
|
|
232
|
+
: null
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function writeAtomicJSON(path, value) {
|
|
236
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
237
|
+
try {
|
|
238
|
+
await fs.writeFile(temporaryPath, JSON.stringify(value), { mode: 0o600, flag: "wx" })
|
|
239
|
+
await fs.rename(temporaryPath, path)
|
|
240
|
+
} finally {
|
|
241
|
+
await fs.unlink(temporaryPath).catch((error) => {
|
|
242
|
+
if (error?.code !== "ENOENT") throw error
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function legacySentinel() {
|
|
248
|
+
return {
|
|
249
|
+
protocol: LEASE_PROTOCOL_VERSION,
|
|
250
|
+
sentinel: true,
|
|
251
|
+
token: LEGACY_SENTINEL_TOKEN,
|
|
252
|
+
pid: 1,
|
|
253
|
+
hostname: LEGACY_SENTINEL_HOSTNAME,
|
|
254
|
+
createdAt: Date.now(),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function validLegacySentinel(owner) {
|
|
259
|
+
return (
|
|
260
|
+
validOwner(owner) &&
|
|
261
|
+
Object.keys(owner).sort().join(",") ===
|
|
262
|
+
"createdAt,hostname,pid,protocol,sentinel,token" &&
|
|
263
|
+
owner.protocol === LEASE_PROTOCOL_VERSION &&
|
|
264
|
+
owner.sentinel === true &&
|
|
265
|
+
owner.token === LEGACY_SENTINEL_TOKEN &&
|
|
266
|
+
owner.pid === 1 &&
|
|
267
|
+
owner.hostname === LEGACY_SENTINEL_HOSTNAME &&
|
|
268
|
+
Number.isFinite(owner.createdAt) &&
|
|
269
|
+
owner.createdAt >= 0
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function legacyGuardMtimeIsSafe(info) {
|
|
274
|
+
return (
|
|
275
|
+
Number.isFinite(info?.mtimeMs) &&
|
|
276
|
+
info.mtimeMs >= LEGACY_GUARD_MTIME_MS - LEGACY_GUARD_MTIME_TOLERANCE_MS
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function claimDirectoryPathFor(lockPath) {
|
|
281
|
+
return `${lockPath}${CLAIM_DIRECTORY_SUFFIX}`
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function inspectLegacyGuard(lockPath) {
|
|
285
|
+
let info
|
|
286
|
+
try {
|
|
287
|
+
info = await fs.lstat(lockPath)
|
|
288
|
+
} catch (error) {
|
|
289
|
+
if (error?.code === "ENOENT") return { status: "missing", owner: null, info: null }
|
|
290
|
+
throw error
|
|
291
|
+
}
|
|
292
|
+
if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) {
|
|
293
|
+
throw persistenceLeasePathError()
|
|
294
|
+
}
|
|
295
|
+
if (info.isDirectory()) {
|
|
296
|
+
const legacy = await readOwnerRecord(join(lockPath, "owner.json"))
|
|
297
|
+
return { status: "legacy", owner: legacy.owner, info }
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const record = await readOwnerRecord(lockPath)
|
|
301
|
+
if (
|
|
302
|
+
record.status === "valid" &&
|
|
303
|
+
validLegacySentinel(record.owner) &&
|
|
304
|
+
legacyGuardMtimeIsSafe(record.info)
|
|
305
|
+
) {
|
|
306
|
+
return { status: "valid", owner: record.owner, info: record.info }
|
|
307
|
+
}
|
|
308
|
+
return { status: "incomplete", owner: record.owner, info: record.info }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function throwForGuardStatus(guard) {
|
|
312
|
+
if (guard.status === "valid" || guard.status === "missing") return
|
|
313
|
+
throw new PersistenceLeaseContendedError(guard.owner, "legacy_lock")
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function hardLinkUnsupported(error) {
|
|
317
|
+
return ["EPERM", "EOPNOTSUPP", "ENOTSUP", "EXDEV"].includes(error?.code)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function publishLegacyGuard(
|
|
321
|
+
lockPath,
|
|
322
|
+
{ beforeGuardLink, afterGuardLink, linkGuard = (source, target) => fs.link(source, target) } = {},
|
|
323
|
+
) {
|
|
324
|
+
const temporaryPath = `${lockPath}.guard.${process.pid}.${randomUUID()}.tmp`
|
|
325
|
+
const sentinel = legacySentinel()
|
|
326
|
+
let handle
|
|
327
|
+
let preparedInfo
|
|
328
|
+
let linked = false
|
|
329
|
+
try {
|
|
330
|
+
handle = await fs.open(temporaryPath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600)
|
|
331
|
+
await handle.writeFile(JSON.stringify(sentinel))
|
|
332
|
+
await handle.sync()
|
|
333
|
+
const guardDate = new Date(LEGACY_GUARD_MTIME_MS)
|
|
334
|
+
await handle.utimes(guardDate, guardDate)
|
|
335
|
+
await handle.sync()
|
|
336
|
+
preparedInfo = await handle.stat()
|
|
337
|
+
if (!preparedInfo.isFile() || !legacyGuardMtimeIsSafe(preparedInfo)) {
|
|
338
|
+
throw persistenceLeasePathError()
|
|
339
|
+
}
|
|
340
|
+
await handle.close()
|
|
341
|
+
handle = null
|
|
342
|
+
|
|
343
|
+
await beforeGuardLink?.({ lockPath, temporaryPath, sentinel: { ...sentinel } })
|
|
344
|
+
try {
|
|
345
|
+
await linkGuard(temporaryPath, lockPath)
|
|
346
|
+
linked = true
|
|
347
|
+
} catch (error) {
|
|
348
|
+
const racedGuard = await inspectLegacyGuard(lockPath)
|
|
349
|
+
if (racedGuard.status !== "missing") {
|
|
350
|
+
throwForGuardStatus(racedGuard)
|
|
351
|
+
return racedGuard
|
|
352
|
+
}
|
|
353
|
+
if (error?.code === "EEXIST") return null
|
|
354
|
+
if (hardLinkUnsupported(error)) throw persistenceLeaseHardLinkError()
|
|
355
|
+
throw error
|
|
356
|
+
}
|
|
357
|
+
await afterGuardLink?.({ lockPath, temporaryPath, sentinel: { ...sentinel } })
|
|
358
|
+
|
|
359
|
+
const guard = await inspectLegacyGuard(lockPath)
|
|
360
|
+
if (guard.status === "missing") return null
|
|
361
|
+
throwForGuardStatus(guard)
|
|
362
|
+
if (
|
|
363
|
+
!linked ||
|
|
364
|
+
guard.info.dev !== preparedInfo.dev ||
|
|
365
|
+
guard.info.ino !== preparedInfo.ino
|
|
366
|
+
) {
|
|
367
|
+
throw persistenceLeasePathError()
|
|
368
|
+
}
|
|
369
|
+
return guard
|
|
370
|
+
} finally {
|
|
371
|
+
await handle?.close().catch(() => {})
|
|
372
|
+
await fs.unlink(temporaryPath).catch((error) => {
|
|
373
|
+
if (error?.code !== "ENOENT") throw error
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function ensureLegacyGuard(lockPath, hooks = {}) {
|
|
379
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt += 1) {
|
|
380
|
+
const guard = await inspectLegacyGuard(lockPath)
|
|
381
|
+
if (guard.status === "valid") return guard
|
|
382
|
+
if (guard.status !== "missing") {
|
|
383
|
+
throwForGuardStatus(guard)
|
|
384
|
+
}
|
|
385
|
+
const published = await publishLegacyGuard(lockPath, hooks)
|
|
386
|
+
if (published) return published
|
|
387
|
+
await retryDelay(randomUUID(), attempt)
|
|
388
|
+
}
|
|
389
|
+
throw new PersistenceLeaseContendedError(null)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function ensureClaimDirectory(claimDirectoryPath) {
|
|
393
|
+
try {
|
|
394
|
+
await fs.mkdir(claimDirectoryPath, { mode: 0o700 })
|
|
395
|
+
} catch (error) {
|
|
396
|
+
if (error?.code !== "EEXIST") throw error
|
|
397
|
+
}
|
|
398
|
+
return assertLockDirectory(claimDirectoryPath)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function removeUniqueClaim(claimPath) {
|
|
402
|
+
try {
|
|
403
|
+
await fs.unlink(claimPath)
|
|
404
|
+
return true
|
|
405
|
+
} catch (error) {
|
|
406
|
+
if (error?.code === "ENOENT") return false
|
|
407
|
+
throw error
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function inspectClaims(
|
|
412
|
+
lockPath,
|
|
413
|
+
ownToken,
|
|
414
|
+
localHostname,
|
|
415
|
+
{ malformedGraceMs, now },
|
|
416
|
+
) {
|
|
417
|
+
const entries = await fs.readdir(lockPath, { withFileTypes: true })
|
|
418
|
+
let ownFound = false
|
|
419
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
420
|
+
const expectedToken = tokenFromClaimName(entry.name)
|
|
421
|
+
if (!expectedToken) {
|
|
422
|
+
if (isClaimLikeName(entry.name)) {
|
|
423
|
+
return { blocker: null, blocked: true, ownFound }
|
|
424
|
+
}
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
const claimPath = join(lockPath, entry.name)
|
|
428
|
+
const record = await readOwnerRecord(claimPath)
|
|
429
|
+
if (record.status === "missing") continue
|
|
430
|
+
|
|
431
|
+
const structurallyValidClaim =
|
|
432
|
+
record.status === "valid" &&
|
|
433
|
+
record.owner.token === expectedToken
|
|
434
|
+
if (!structurallyValidClaim) {
|
|
435
|
+
const age = record.info ? now() - record.info.mtimeMs : 0
|
|
436
|
+
if (age < malformedGraceMs) {
|
|
437
|
+
return { blocker: null, blocked: true, ownFound }
|
|
438
|
+
}
|
|
439
|
+
await removeUniqueClaim(claimPath)
|
|
440
|
+
continue
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// A token-matching claim from an unknown protocol is authoritative to that
|
|
444
|
+
// protocol. Never infer that it is stale or safe to remove.
|
|
445
|
+
if (record.owner.protocol !== LEASE_PROTOCOL_VERSION) {
|
|
446
|
+
return { blocker: record.owner, blocked: true, ownFound }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (record.owner.token === ownToken) {
|
|
450
|
+
ownFound = true
|
|
451
|
+
continue
|
|
452
|
+
}
|
|
453
|
+
if (ownerIsBlocking(record.owner, localHostname)) {
|
|
454
|
+
return { blocker: record.owner, blocked: true, ownFound }
|
|
455
|
+
}
|
|
456
|
+
await removeUniqueClaim(claimPath)
|
|
457
|
+
}
|
|
458
|
+
return { blocker: null, blocked: false, ownFound }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function retryDelay(token, attempt) {
|
|
462
|
+
const offset = Number.parseInt(token.slice(attempt * 2, attempt * 2 + 2), 16) || 0
|
|
463
|
+
return new Promise((resolve) => setTimeout(resolve, 1 + (offset % 7)))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function createLease(
|
|
467
|
+
lockPath,
|
|
468
|
+
claimDirectoryPath,
|
|
469
|
+
claimPath,
|
|
470
|
+
owner,
|
|
471
|
+
{ beforeClaimRemove } = {},
|
|
472
|
+
) {
|
|
473
|
+
let releasing = false
|
|
474
|
+
let released = false
|
|
475
|
+
return {
|
|
476
|
+
lockPath,
|
|
477
|
+
claimDirectoryPath,
|
|
478
|
+
owner,
|
|
479
|
+
async release() {
|
|
480
|
+
if (released || releasing) return false
|
|
481
|
+
releasing = true
|
|
482
|
+
try {
|
|
483
|
+
const claim = await readOwnerRecord(claimPath)
|
|
484
|
+
if (claim.status === "missing") {
|
|
485
|
+
released = true
|
|
486
|
+
return false
|
|
487
|
+
}
|
|
488
|
+
if (claim.status !== "valid" || claim.owner.token !== owner.token) return false
|
|
489
|
+
|
|
490
|
+
await beforeClaimRemove?.({
|
|
491
|
+
lockPath,
|
|
492
|
+
claimDirectoryPath,
|
|
493
|
+
claimPath,
|
|
494
|
+
owner: { ...owner },
|
|
495
|
+
})
|
|
496
|
+
const removed = await removeUniqueClaim(claimPath)
|
|
497
|
+
if (removed) released = true
|
|
498
|
+
return removed
|
|
499
|
+
} finally {
|
|
500
|
+
releasing = false
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
}
|
|
23
504
|
}
|
|
24
505
|
|
|
25
506
|
/**
|
|
26
|
-
* Hold an exclusive session lease for the plugin instance lifetime.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
507
|
+
* Hold an exclusive session lease for the plugin instance lifetime.
|
|
508
|
+
*
|
|
509
|
+
* Version 2 atomically publishes a long-lived regular-file guard at the legacy
|
|
510
|
+
* `.lock` path. Its far-future mtime makes version-1's malformed-directory
|
|
511
|
+
* recovery fail closed, while hard-link publication ensures the legacy path is
|
|
512
|
+
* never visible partially initialized. Version-2 peers elect ownership from
|
|
513
|
+
* never-reused UUID claims in a stable sibling directory. Stale cleanup and
|
|
514
|
+
* release therefore unlink only an immutable claim; neither operation can
|
|
515
|
+
* delete a newer owner's lease after a delayed filesystem call.
|
|
29
516
|
*/
|
|
30
|
-
|
|
517
|
+
async function acquirePersistenceLeaseWithHooks(
|
|
31
518
|
stateFilePath,
|
|
32
519
|
{ malformedGraceMs = 30_000, now = () => Date.now() } = {},
|
|
520
|
+
hooks = {},
|
|
33
521
|
) {
|
|
522
|
+
const {
|
|
523
|
+
beforeGuardLink,
|
|
524
|
+
afterGuardLink,
|
|
525
|
+
linkGuard,
|
|
526
|
+
beforeOwnerWrite,
|
|
527
|
+
afterOwnerWrite,
|
|
528
|
+
beforeClaimRemove,
|
|
529
|
+
} = hooks
|
|
34
530
|
const lockPath = `${stateFilePath}.lock`
|
|
531
|
+
const claimDirectoryPath = claimDirectoryPathFor(lockPath)
|
|
532
|
+
const localHostname = hostname()
|
|
35
533
|
await fs.mkdir(dirname(stateFilePath), { recursive: true, mode: 0o700 })
|
|
36
|
-
|
|
37
|
-
token: randomUUID(),
|
|
38
|
-
pid: process.pid,
|
|
39
|
-
hostname: hostname(),
|
|
40
|
-
createdAt: Date.now(),
|
|
41
|
-
}
|
|
534
|
+
let lastBlocker = null
|
|
42
535
|
|
|
43
|
-
for (let attempt = 0; attempt <
|
|
536
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt += 1) {
|
|
537
|
+
await ensureLegacyGuard(lockPath, { beforeGuardLink, afterGuardLink, linkGuard })
|
|
538
|
+
if (!(await ensureClaimDirectory(claimDirectoryPath))) {
|
|
539
|
+
continue
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const existing = await inspectClaims(
|
|
543
|
+
claimDirectoryPath,
|
|
544
|
+
null,
|
|
545
|
+
localHostname,
|
|
546
|
+
{ malformedGraceMs, now },
|
|
547
|
+
)
|
|
548
|
+
if (existing.blocked) throw new PersistenceLeaseContendedError(existing.blocker)
|
|
549
|
+
const owner = {
|
|
550
|
+
protocol: LEASE_PROTOCOL_VERSION,
|
|
551
|
+
token: randomUUID(),
|
|
552
|
+
pid: process.pid,
|
|
553
|
+
hostname: localHostname,
|
|
554
|
+
createdAt: Date.now(),
|
|
555
|
+
}
|
|
556
|
+
const claimPath = join(claimDirectoryPath, claimNameFor(owner.token))
|
|
557
|
+
let claimPublished = false
|
|
44
558
|
try {
|
|
45
|
-
await
|
|
46
|
-
await fs.writeFile(`${lockPath}/owner.json`, JSON.stringify(owner), { mode: 0o600 })
|
|
47
|
-
return {
|
|
559
|
+
await beforeOwnerWrite?.({
|
|
48
560
|
lockPath,
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
await fs.rename(lockPath, stalePath)
|
|
77
|
-
await fs.rm(stalePath, { recursive: true, force: true })
|
|
78
|
-
continue
|
|
79
|
-
} catch (reclaimError) {
|
|
80
|
-
if (reclaimError?.code === "ENOENT") continue
|
|
81
|
-
}
|
|
561
|
+
claimDirectoryPath,
|
|
562
|
+
claimPath,
|
|
563
|
+
owner: { ...owner },
|
|
564
|
+
attempt,
|
|
565
|
+
})
|
|
566
|
+
await writeAtomicJSON(claimPath, owner)
|
|
567
|
+
claimPublished = true
|
|
568
|
+
await afterOwnerWrite?.({
|
|
569
|
+
lockPath,
|
|
570
|
+
claimDirectoryPath,
|
|
571
|
+
claimPath,
|
|
572
|
+
owner: { ...owner },
|
|
573
|
+
attempt,
|
|
574
|
+
})
|
|
575
|
+
|
|
576
|
+
const observed = await inspectClaims(
|
|
577
|
+
claimDirectoryPath,
|
|
578
|
+
owner.token,
|
|
579
|
+
localHostname,
|
|
580
|
+
{ malformedGraceMs, now },
|
|
581
|
+
)
|
|
582
|
+
if (!observed.ownFound || observed.blocked) {
|
|
583
|
+
lastBlocker = observed.blocker
|
|
584
|
+
await removeUniqueClaim(claimPath)
|
|
585
|
+
claimPublished = false
|
|
586
|
+
await retryDelay(owner.token, attempt)
|
|
587
|
+
continue
|
|
82
588
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
589
|
+
|
|
590
|
+
await ensureLegacyGuard(lockPath, { beforeGuardLink, afterGuardLink, linkGuard })
|
|
591
|
+
return createLease(
|
|
592
|
+
lockPath,
|
|
593
|
+
claimDirectoryPath,
|
|
594
|
+
claimPath,
|
|
595
|
+
owner,
|
|
596
|
+
{ beforeClaimRemove },
|
|
88
597
|
)
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (claimPublished) await removeUniqueClaim(claimPath).catch(() => false)
|
|
600
|
+
if (error?.code === "ENOENT") continue
|
|
601
|
+
throw error
|
|
89
602
|
}
|
|
90
603
|
}
|
|
91
|
-
throw new
|
|
604
|
+
throw new PersistenceLeaseContendedError(lastBlocker)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export async function acquirePersistenceLease(stateFilePath, options = {}) {
|
|
608
|
+
return acquirePersistenceLeaseWithHooks(stateFilePath, options)
|
|
92
609
|
}
|
|
93
610
|
|
|
94
|
-
export const persistenceLeaseInternals = Object.freeze({
|
|
611
|
+
export const persistenceLeaseInternals = Object.freeze({
|
|
612
|
+
acquirePersistenceLeaseWithHooks,
|
|
613
|
+
claimDirectoryPathFor,
|
|
614
|
+
claimNameFor,
|
|
615
|
+
inspectLegacyGuard,
|
|
616
|
+
inspectClaims,
|
|
617
|
+
legacyGuardMtimeIsSafe,
|
|
618
|
+
legacySentinel,
|
|
619
|
+
publishLegacyGuard,
|
|
620
|
+
processIsAlive,
|
|
621
|
+
readBoundedOwnerFile,
|
|
622
|
+
readOwner,
|
|
623
|
+
readOwnerRecord,
|
|
624
|
+
sanitizeOwner,
|
|
625
|
+
validLegacySentinel,
|
|
626
|
+
validDisplayHostname,
|
|
627
|
+
validOwner,
|
|
628
|
+
validStoredHostname,
|
|
629
|
+
})
|