@cortexkit/opencode-openai-auth 0.6.1 → 0.6.3
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/dist/cli.js +7 -7
- package/dist/core/account-paths.d.ts +25 -0
- package/dist/core/accounts.d.ts +31 -5
- package/dist/core/oauth.d.ts +1 -1
- package/dist/core/refresh-file-lock.d.ts +1 -5
- package/dist/{index-e08r33g5.js → index-00rp531h.js} +2 -2
- package/dist/{index-dz9a4xwy.js → index-4agbg1ec.js} +1 -1
- package/dist/{index-nwy7ec7p.js → index-7tsnfb8g.js} +1 -1
- package/dist/{index-hxpv25e8.js → index-80gqxa1f.js} +1 -1
- package/dist/index-8f1200qx.js +6 -0
- package/dist/index-ng315c61.js +89 -0
- package/dist/{index-s2vhsb2v.js → index-x55z0b3y.js} +1 -1
- package/dist/{index-meg0ng0z.js → index-zbpt7v1a.js} +1 -1
- package/dist/index.js +91 -91
- package/dist/{quota-normalize-vf2pj6n7.js → quota-normalize-s27mex1w.js} +1 -1
- package/dist/rpc/port-file.js +1 -1
- package/dist/rpc/rpc-client.js +1 -1
- package/dist/rpc/rpc-dir.js +1 -1
- package/dist/sidebar-state.js +1 -1
- package/dist/{token-esc14v6e.js → token-tag90anp.js} +1 -1
- package/dist/token-util-by7nbdh3.js +1 -0
- package/dist/tui-preferences.js +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +2 -1
- package/src/core/account-paths.ts +103 -0
- package/src/core/refresh-file-lock.ts +217 -145
- package/src/tui-compiled/core/account-paths.ts +103 -0
- package/src/tui-compiled/core/refresh-file-lock.ts +217 -145
- package/dist/index-cxze8h26.js +0 -89
- package/dist/index-n00ybbbq.js +0 -6
- package/dist/token-util-qkpkvy5t.js +0 -1
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { basename, dirname, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where the account store lives.
|
|
7
|
+
*
|
|
8
|
+
* This is the ONLY definition. It used to be duplicated verbatim in both
|
|
9
|
+
* `accounts.ts` and `refresh-file-lock.ts`, because `accounts.ts` imports the
|
|
10
|
+
* lock and the reverse import would have been a cycle. Two copies of a path
|
|
11
|
+
* resolver is a bad trade for that: the lock and the write must agree on the
|
|
12
|
+
* exact file, and if the copies ever drifted, a writer would take a lock on one
|
|
13
|
+
* path while writing another — mutual exclusion silently lost on a file holding
|
|
14
|
+
* credentials. Nothing would fail a test, since each copy works on its own.
|
|
15
|
+
*
|
|
16
|
+
* This module imports nothing from either side, so both can depend on it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const ACCOUNT_FILE_NAME = 'openai-auth.json'
|
|
20
|
+
export const ACCOUNT_STATE_FILE_NAME = 'openai-auth-state.json'
|
|
21
|
+
|
|
22
|
+
function getConfigDir() {
|
|
23
|
+
if (process.env.OPENCODE_CONFIG_DIR?.trim()) {
|
|
24
|
+
return process.env.OPENCODE_CONFIG_DIR.trim()
|
|
25
|
+
}
|
|
26
|
+
return join(
|
|
27
|
+
process.env.XDG_CONFIG_HOME || join(homedir(), '.config'),
|
|
28
|
+
'opencode',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getAccountStoragePath() {
|
|
33
|
+
return (
|
|
34
|
+
process.env.OPENCODE_OPENAI_AUTH_FILE?.trim() ||
|
|
35
|
+
join(getConfigDir(), ACCOUNT_FILE_NAME)
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Derive the state-file path from the config path without reading env vars. */
|
|
40
|
+
export function deriveStatePath(configPath: string): string {
|
|
41
|
+
return basename(configPath) === ACCOUNT_FILE_NAME
|
|
42
|
+
? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME)
|
|
43
|
+
: `${configPath}.state.json`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizePathForComparison(path: string, platform: NodeJS.Platform) {
|
|
47
|
+
const resolved = resolve(path)
|
|
48
|
+
return platform === 'win32' || platform === 'darwin'
|
|
49
|
+
? resolved.toLowerCase()
|
|
50
|
+
: resolved
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function realpathForComparison(path: string): string | undefined {
|
|
54
|
+
try {
|
|
55
|
+
return realpathSync.native(path)
|
|
56
|
+
} catch {
|
|
57
|
+
try {
|
|
58
|
+
return join(realpathSync.native(dirname(path)), basename(path))
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Detect path aliases without requiring either file to exist. This is defense
|
|
67
|
+
* in depth: hardlinks and bind mounts can still make distinct paths share a
|
|
68
|
+
* file, because neither is distinguishable through pathname identity.
|
|
69
|
+
*/
|
|
70
|
+
export function accountPathsCollide(
|
|
71
|
+
configPath: string,
|
|
72
|
+
statePath: string,
|
|
73
|
+
platform: NodeJS.Platform = process.platform,
|
|
74
|
+
) {
|
|
75
|
+
if (
|
|
76
|
+
normalizePathForComparison(configPath, platform) ===
|
|
77
|
+
normalizePathForComparison(statePath, platform)
|
|
78
|
+
) {
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const configIdentity = realpathForComparison(configPath)
|
|
83
|
+
const stateIdentity = realpathForComparison(statePath)
|
|
84
|
+
return (
|
|
85
|
+
configIdentity !== undefined &&
|
|
86
|
+
stateIdentity !== undefined &&
|
|
87
|
+
normalizePathForComparison(configIdentity, platform) ===
|
|
88
|
+
normalizePathForComparison(stateIdentity, platform)
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getAccountStatePath(configPath = getAccountStoragePath()) {
|
|
93
|
+
const explicit = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE?.trim()
|
|
94
|
+
if (explicit) {
|
|
95
|
+
if (accountPathsCollide(configPath, explicit)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`OPENCODE_OPENAI_AUTH_STATE_FILE resolves to the config path (${resolve(configPath)}). Set OPENCODE_OPENAI_AUTH_STATE_FILE to a different file.`,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
return explicit
|
|
101
|
+
}
|
|
102
|
+
return deriveStatePath(configPath)
|
|
103
|
+
}
|
|
@@ -1,24 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
2
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { homedir } from 'node:os'
|
|
4
3
|
import { dirname, join } from 'node:path'
|
|
4
|
+
import { getAccountStoragePath } from './account-paths'
|
|
5
5
|
|
|
6
6
|
const setRefreshLockRenewalTimeout = globalThis.setTimeout.bind(globalThis)
|
|
7
7
|
const clearRefreshLockRenewalTimeout = globalThis.clearTimeout.bind(globalThis)
|
|
8
8
|
|
|
9
|
-
export const ACCOUNT_FILE_NAME = 'openai-auth.json'
|
|
10
|
-
export const ACCOUNT_STATE_FILE_NAME = 'openai-auth-state.json'
|
|
11
|
-
|
|
12
|
-
function getConfigDir() {
|
|
13
|
-
if (process.env.OPENCODE_CONFIG_DIR?.trim()) {
|
|
14
|
-
return process.env.OPENCODE_CONFIG_DIR.trim()
|
|
15
|
-
}
|
|
16
|
-
return join(
|
|
17
|
-
process.env.XDG_CONFIG_HOME || join(homedir(), '.config'),
|
|
18
|
-
'opencode',
|
|
19
|
-
)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
9
|
// A concurrent contender renaming the freshly-created eviction-marker directory
|
|
23
10
|
// away surfaces the vanished parent differently per platform: ENOENT on Linux,
|
|
24
11
|
// EINVAL or ENOTDIR on macOS/APFS. All three mean the marker is no longer ours
|
|
@@ -28,21 +15,6 @@ export function isLostMarkerRaceError(error: unknown): boolean {
|
|
|
28
15
|
return code === 'ENOENT' || code === 'EINVAL' || code === 'ENOTDIR'
|
|
29
16
|
}
|
|
30
17
|
|
|
31
|
-
export function getAccountStoragePath() {
|
|
32
|
-
return (
|
|
33
|
-
process.env.OPENCODE_OPENAI_AUTH_FILE?.trim() ||
|
|
34
|
-
join(getConfigDir(), ACCOUNT_FILE_NAME)
|
|
35
|
-
)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function getAccountStatePath(configPath = getAccountStoragePath()) {
|
|
39
|
-
const explicit = process.env.OPENCODE_OPENAI_AUTH_STATE_FILE?.trim()
|
|
40
|
-
if (explicit) return explicit
|
|
41
|
-
return configPath.endsWith(ACCOUNT_FILE_NAME)
|
|
42
|
-
? join(dirname(configPath), ACCOUNT_STATE_FILE_NAME)
|
|
43
|
-
: `${configPath}.state.json`
|
|
44
|
-
}
|
|
45
|
-
|
|
46
18
|
export async function acquireRefreshFileLock(options: {
|
|
47
19
|
name: string
|
|
48
20
|
ttlMs: number
|
|
@@ -55,7 +27,14 @@ export async function acquireRefreshFileLock(options: {
|
|
|
55
27
|
| 'stale-marker-stat'
|
|
56
28
|
| 'stale-marker-claimed'
|
|
57
29
|
| 'stale-lock-confirmed'
|
|
58
|
-
| 'eviction-marker-acquired'
|
|
30
|
+
| 'eviction-marker-acquired'
|
|
31
|
+
| 'renewal-owner-confirmed'
|
|
32
|
+
| 'renewal-marker-unavailable'
|
|
33
|
+
| 'renewal-write-fenced'
|
|
34
|
+
| 'renewal-write-ready'
|
|
35
|
+
| 'relinquish-read'
|
|
36
|
+
| 'renewal-finished'
|
|
37
|
+
| 'release-owner-confirmed',
|
|
59
38
|
) => void | Promise<void>
|
|
60
39
|
}): Promise<{ release: () => Promise<void> } | null> {
|
|
61
40
|
const accountPath = options.path ?? getAccountStoragePath()
|
|
@@ -65,6 +44,18 @@ export async function acquireRefreshFileLock(options: {
|
|
|
65
44
|
const now = options.now ?? Date.now
|
|
66
45
|
let renewTimer: ReturnType<typeof setTimeout> | null = null
|
|
67
46
|
let released = false
|
|
47
|
+
let renewalInFlight: Promise<void> | null = null
|
|
48
|
+
// Fencing-token eviction: a directory-based marker (mkdir O_EXCL) serializes
|
|
49
|
+
// destructive removal and generation-sensitive owner mutations. The marker
|
|
50
|
+
// holds an owner file so ownership survives a stale-marker recovery rename:
|
|
51
|
+
// the recovering contender renames the stale directory, then must re-check
|
|
52
|
+
// ownership before acting — preventing the 3rd interleaving where a stale
|
|
53
|
+
// observer renames the FRESH marker the mkdir-winner created.
|
|
54
|
+
const evictPath = `${lockPath}.evicting`
|
|
55
|
+
const evictOwnerPath = join(evictPath, 'owner.json')
|
|
56
|
+
const evictOwnerId = randomUUID()
|
|
57
|
+
const EVICT_TTL = 5_000
|
|
58
|
+
const MAX_STEAL_ATTEMPTS = 8
|
|
68
59
|
|
|
69
60
|
async function readOwner() {
|
|
70
61
|
try {
|
|
@@ -115,105 +106,197 @@ export async function acquireRefreshFileLock(options: {
|
|
|
115
106
|
}
|
|
116
107
|
}
|
|
117
108
|
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
renewTimer = setRefreshLockRenewalTimeout(() => {
|
|
123
|
-
void (async () => {
|
|
124
|
-
try {
|
|
125
|
-
const owner = await readOwner()
|
|
126
|
-
const currentNow = now()
|
|
127
|
-
if (
|
|
128
|
-
released ||
|
|
129
|
-
owner?.ownerId !== ownerId ||
|
|
130
|
-
Number(owner?.expiresAt) <= currentNow
|
|
131
|
-
) {
|
|
132
|
-
return
|
|
133
|
-
}
|
|
134
|
-
await writeOwner()
|
|
135
|
-
scheduleRenewal()
|
|
136
|
-
} catch {
|
|
137
|
-
// If renewal fails, contenders will wait until the last written expiry.
|
|
138
|
-
}
|
|
139
|
-
})()
|
|
140
|
-
}, intervalMs)
|
|
141
|
-
if ('unref' in renewTimer) renewTimer.unref()
|
|
109
|
+
async function backoff() {
|
|
110
|
+
await new Promise((resolve) =>
|
|
111
|
+
setTimeout(resolve, Math.floor(Math.random() * 4)),
|
|
112
|
+
)
|
|
142
113
|
}
|
|
143
114
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
115
|
+
async function lockIsLive() {
|
|
116
|
+
try {
|
|
117
|
+
const currentOwner = await readOwner()
|
|
118
|
+
return Number(currentOwner?.expiresAt) > now()
|
|
119
|
+
} catch {
|
|
120
|
+
try {
|
|
121
|
+
const current = await stat(lockPath)
|
|
122
|
+
return current.mtimeMs + options.ttlMs > now()
|
|
123
|
+
} catch {
|
|
124
|
+
// Lock doesn't exist — safe to acquire.
|
|
125
|
+
return false
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fail-closed: any read error means we do NOT own the marker.
|
|
131
|
+
async function ownsEvictionMarker() {
|
|
132
|
+
try {
|
|
133
|
+
const owner = JSON.parse(await readFile(evictOwnerPath, 'utf8'))
|
|
134
|
+
return owner?.ownerId === evictOwnerId
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function releaseEvictionMarker() {
|
|
141
|
+
if (await ownsEvictionMarker()) {
|
|
142
|
+
await rm(evictPath, { recursive: true, force: true }).catch(() => {})
|
|
143
|
+
}
|
|
144
|
+
}
|
|
157
145
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
146
|
+
async function tryAcquireEvictionMarker() {
|
|
147
|
+
await mkdir(evictPath)
|
|
148
|
+
try {
|
|
149
|
+
await writeFile(
|
|
150
|
+
evictOwnerPath,
|
|
151
|
+
`${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`,
|
|
152
|
+
{ encoding: 'utf8', mode: 0o600, flag: 'wx' },
|
|
161
153
|
)
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// A competing contender can rename our just-created marker directory
|
|
156
|
+
// away between the mkdir above and this write (the stale-marker steal
|
|
157
|
+
// path below does exactly that). That is a lost race, not a failure, so
|
|
158
|
+
// report it as such and let the caller back off and retry rather than
|
|
159
|
+
// failing the whole lock acquisition.
|
|
160
|
+
if (isLostMarkerRaceError(error)) return false
|
|
161
|
+
await releaseEvictionMarker()
|
|
162
|
+
throw error
|
|
162
163
|
}
|
|
164
|
+
if (options.onStep) await options.onStep('eviction-marker-acquired')
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
163
167
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
168
|
+
async function recoverStaleEvictionMarker(): Promise<
|
|
169
|
+
'fresh' | 'missing' | 'recovered'
|
|
170
|
+
> {
|
|
171
|
+
let evictStat: Awaited<ReturnType<typeof stat>>
|
|
172
|
+
try {
|
|
173
|
+
evictStat = await stat(evictPath)
|
|
174
|
+
} catch (error) {
|
|
175
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'
|
|
176
|
+
throw error
|
|
177
|
+
}
|
|
178
|
+
if (evictStat.mtimeMs + EVICT_TTL > now()) return 'fresh'
|
|
179
|
+
|
|
180
|
+
if (options.onStep) await options.onStep('stale-marker-stat')
|
|
181
|
+
const claimedPath = `${evictPath}.${randomUUID()}`
|
|
182
|
+
try {
|
|
183
|
+
await rename(evictPath, claimedPath)
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return 'missing'
|
|
186
|
+
throw error
|
|
187
|
+
}
|
|
188
|
+
if (options.onStep) await options.onStep('stale-marker-claimed')
|
|
189
|
+
await rm(claimedPath, { recursive: true, force: true }).catch(() => {})
|
|
190
|
+
return 'recovered'
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function withEvictionMarker(action: () => Promise<void>) {
|
|
194
|
+
try {
|
|
195
|
+
if (!(await tryAcquireEvictionMarker())) return false
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false
|
|
198
|
+
throw error
|
|
177
199
|
}
|
|
178
200
|
|
|
179
|
-
|
|
180
|
-
|
|
201
|
+
try {
|
|
202
|
+
await action()
|
|
203
|
+
} finally {
|
|
204
|
+
await releaseEvictionMarker()
|
|
205
|
+
}
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Marker loss after a write may mean our record replaced a successor's.
|
|
210
|
+
// Delete only a record still owned by us; a concurrent successor write can
|
|
211
|
+
// then yield zero winners, never two.
|
|
212
|
+
async function relinquishLockAfterMarkerLoss() {
|
|
213
|
+
for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
|
|
214
|
+
if (options.onStep) await options.onStep('relinquish-read')
|
|
215
|
+
let owner: { ownerId?: string } | undefined
|
|
181
216
|
try {
|
|
182
|
-
|
|
183
|
-
return owner?.ownerId === evictOwnerId
|
|
217
|
+
owner = await readOwner()
|
|
184
218
|
} catch {
|
|
185
|
-
return
|
|
219
|
+
return
|
|
186
220
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
async function tryAcquireEvictionMarker() {
|
|
190
|
-
await mkdir(evictPath)
|
|
221
|
+
if (owner?.ownerId !== ownerId) return
|
|
191
222
|
try {
|
|
192
|
-
await
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
)
|
|
197
|
-
} catch (error) {
|
|
198
|
-
// A competing contender can rename our just-created marker directory
|
|
199
|
-
// away between the mkdir above and this write (the stale-marker steal
|
|
200
|
-
// path below does exactly that). That is a lost race, not a failure, so
|
|
201
|
-
// report it as such and let the caller back off and retry rather than
|
|
202
|
-
// failing the whole lock acquisition.
|
|
203
|
-
if (isLostMarkerRaceError(error)) return false
|
|
204
|
-
await releaseEvictionMarker()
|
|
205
|
-
throw error
|
|
223
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
224
|
+
return
|
|
225
|
+
} catch {
|
|
226
|
+
await backoff()
|
|
206
227
|
}
|
|
207
|
-
await options.onStep?.('eviction-marker-acquired')
|
|
208
|
-
return true
|
|
209
228
|
}
|
|
229
|
+
}
|
|
210
230
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
231
|
+
function scheduleRenewal() {
|
|
232
|
+
if (!options.renew || released) return
|
|
233
|
+
const intervalMs =
|
|
234
|
+
options.renewIntervalMs ?? Math.max(1_000, Math.floor(options.ttlMs / 3))
|
|
235
|
+
renewTimer = setRefreshLockRenewalTimeout(() => {
|
|
236
|
+
const renewal = (async () => {
|
|
237
|
+
let shouldReschedule = !released
|
|
238
|
+
try {
|
|
239
|
+
const markerAcquired = await withEvictionMarker(async () => {
|
|
240
|
+
const owner = await readOwner()
|
|
241
|
+
const currentNow = now()
|
|
242
|
+
if (released || owner?.ownerId !== ownerId) {
|
|
243
|
+
shouldReschedule = false
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
// An expired lease is no longer ours to extend; a contender may
|
|
247
|
+
// already be eligible to acquire it.
|
|
248
|
+
if (Number(owner?.expiresAt) <= currentNow) {
|
|
249
|
+
shouldReschedule = false
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
if (options.onStep) await options.onStep('renewal-owner-confirmed')
|
|
253
|
+
if (released) {
|
|
254
|
+
shouldReschedule = false
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
if (!(await ownsEvictionMarker())) return
|
|
258
|
+
if (options.onStep) await options.onStep('renewal-write-fenced')
|
|
259
|
+
if (released) {
|
|
260
|
+
shouldReschedule = false
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (!(await ownsEvictionMarker())) return
|
|
264
|
+
if (options.onStep) await options.onStep('renewal-write-ready')
|
|
265
|
+
await writeOwner()
|
|
266
|
+
if (!(await ownsEvictionMarker())) {
|
|
267
|
+
// Marker read errors fail closed: prompt relinquish avoids ambiguity
|
|
268
|
+
// instead of waiting for TTL; both outcomes keep zero or one winner.
|
|
269
|
+
shouldReschedule = false
|
|
270
|
+
await relinquishLockAfterMarkerLoss()
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
})
|
|
274
|
+
if (!markerAcquired && options.onStep) {
|
|
275
|
+
await options.onStep('renewal-marker-unavailable')
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
// Transient marker and filesystem failures retry on the next interval.
|
|
279
|
+
} finally {
|
|
280
|
+
if (options.onStep) {
|
|
281
|
+
try {
|
|
282
|
+
await options.onStep('renewal-finished')
|
|
283
|
+
} catch {
|
|
284
|
+
// Test seams must not turn an otherwise-safe renewal into a rejection.
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
if (shouldReschedule && !released) scheduleRenewal()
|
|
288
|
+
}
|
|
289
|
+
})()
|
|
290
|
+
renewalInFlight = renewal
|
|
291
|
+
void renewal.finally(() => {
|
|
292
|
+
if (renewalInFlight === renewal) renewalInFlight = null
|
|
293
|
+
})
|
|
294
|
+
}, intervalMs)
|
|
295
|
+
if ('unref' in renewTimer) renewTimer.unref()
|
|
296
|
+
}
|
|
216
297
|
|
|
298
|
+
let acquired = await tryAcquire()
|
|
299
|
+
if (!acquired) {
|
|
217
300
|
for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
|
|
218
301
|
acquired = await tryAcquire()
|
|
219
302
|
if (acquired) break
|
|
@@ -228,31 +311,8 @@ export async function acquireRefreshFileLock(options: {
|
|
|
228
311
|
const code = (evictError as NodeJS.ErrnoException).code
|
|
229
312
|
if (code !== 'EEXIST') throw evictError
|
|
230
313
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
evictStat = await stat(evictPath)
|
|
234
|
-
} catch (statError) {
|
|
235
|
-
if ((statError as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
236
|
-
await backoff()
|
|
237
|
-
continue
|
|
238
|
-
}
|
|
239
|
-
throw statError
|
|
240
|
-
}
|
|
241
|
-
if (evictStat.mtimeMs + EVICT_TTL > now()) return null
|
|
242
|
-
|
|
243
|
-
await options.onStep?.('stale-marker-stat')
|
|
244
|
-
const claimedPath = `${evictPath}.${randomUUID()}`
|
|
245
|
-
try {
|
|
246
|
-
await rename(evictPath, claimedPath)
|
|
247
|
-
} catch (renameError) {
|
|
248
|
-
if ((renameError as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
249
|
-
await backoff()
|
|
250
|
-
continue
|
|
251
|
-
}
|
|
252
|
-
throw renameError
|
|
253
|
-
}
|
|
254
|
-
await options.onStep?.('stale-marker-claimed')
|
|
255
|
-
await rm(claimedPath, { recursive: true, force: true }).catch(() => {})
|
|
314
|
+
const recovered = await recoverStaleEvictionMarker()
|
|
315
|
+
if (recovered === 'fresh') return null
|
|
256
316
|
await backoff()
|
|
257
317
|
continue
|
|
258
318
|
}
|
|
@@ -262,7 +322,7 @@ export async function acquireRefreshFileLock(options: {
|
|
|
262
322
|
// Fence check 1: verify we still own the marker before acting on the
|
|
263
323
|
// stale-lock-confirmed decision.
|
|
264
324
|
if (!(await ownsEvictionMarker())) return null
|
|
265
|
-
await options.onStep
|
|
325
|
+
if (options.onStep) await options.onStep('stale-lock-confirmed')
|
|
266
326
|
// Fence check 2: re-verify ownership after the seam (another contender
|
|
267
327
|
// may have renamed our fresh marker while we were paused here).
|
|
268
328
|
if (!(await ownsEvictionMarker())) return null
|
|
@@ -297,13 +357,25 @@ export async function acquireRefreshFileLock(options: {
|
|
|
297
357
|
clearRefreshLockRenewalTimeout(renewTimer)
|
|
298
358
|
renewTimer = null
|
|
299
359
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
360
|
+
await renewalInFlight
|
|
361
|
+
for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
|
|
362
|
+
try {
|
|
363
|
+
const markerAcquired = await withEvictionMarker(async () => {
|
|
364
|
+
const owner = await readOwner()
|
|
365
|
+
if (owner?.ownerId !== ownerId) return
|
|
366
|
+
if (options.onStep) await options.onStep('release-owner-confirmed')
|
|
367
|
+
if (!(await ownsEvictionMarker())) return
|
|
368
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => {})
|
|
369
|
+
})
|
|
370
|
+
if (markerAcquired) return
|
|
371
|
+
await recoverStaleEvictionMarker()
|
|
372
|
+
} catch {
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
await backoff()
|
|
305
376
|
}
|
|
306
|
-
|
|
377
|
+
// Do not delete by pathname without the marker: bounded retries leave the
|
|
378
|
+
// lease to expire rather than risking removal of a successor's lock.
|
|
307
379
|
},
|
|
308
380
|
}
|
|
309
381
|
}
|