@atlaso-labs/opencode 0.1.0 → 0.2.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/CHANGELOG.md +31 -0
- package/lib/atlaso.ts +270 -15
- package/lib/capture_stats.ts +318 -0
- package/lib/connect.ts +43 -11
- package/lib/credential.ts +148 -0
- package/lib/drain.ts +186 -0
- package/lib/entitlement.ts +4 -2
- package/lib/lock.ts +217 -0
- package/lib/mcp.ts +300 -0
- package/lib/outbox.ts +348 -0
- package/lib/project.ts +268 -32
- package/package.json +7 -3
- package/src/index.ts +68 -13
package/lib/drain.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** Outbox drain — the retry half of the durability guarantee.
|
|
2
|
+
*
|
|
3
|
+
* `outbox.ts` persists memories; this decides what a push attempt MEANT and moves
|
|
4
|
+
* each item to its next state. It is deliberately the only place the failure
|
|
5
|
+
* taxonomy lives, so "when do we give up on a memory" is one readable table rather
|
|
6
|
+
* than scattered conditionals.
|
|
7
|
+
*
|
|
8
|
+
* THE TAXONOMY. Getting this wrong fails in one of two directions — lose a user's
|
|
9
|
+
* memory, or wedge the queue behind an item that can never succeed:
|
|
10
|
+
*
|
|
11
|
+
* per-item `invalid` → QUARANTINE. The server rejected the SHAPE. Retrying
|
|
12
|
+
* is guaranteed to fail identically, forever.
|
|
13
|
+
* per-item anything else → SETTLED. added / duplicate / error are all "the
|
|
14
|
+
* server has seen this and formed a verdict". `error`
|
|
15
|
+
* settles because the batch endpoint returns it for a
|
|
16
|
+
* per-item engine failure it already logged; retrying
|
|
17
|
+
* would re-run the same deposit against the same
|
|
18
|
+
* engine. It is recorded, not silently discarded.
|
|
19
|
+
* item absent from results → RETRY. We cannot prove the server took it.
|
|
20
|
+
* HTTP 429 → RETRY, and STOP the pass. The server is shedding;
|
|
21
|
+
* hammering it is the opposite of helpful. Nothing is
|
|
22
|
+
* lost — this is exactly the case the outbox exists
|
|
23
|
+
* for, and the per-item budget makes 429 reachable.
|
|
24
|
+
* HTTP 401/403 → RETRY, and STOP. Either credentials are being
|
|
25
|
+
* rotated or an edge/WAF is blocking us. Both are
|
|
26
|
+
* transient from the memory's point of view, and the
|
|
27
|
+
* WAF sync-brick incident is precisely why a 403 must
|
|
28
|
+
* never cause us to discard data.
|
|
29
|
+
* HTTP 5xx / 0 (timeout) → RETRY, and STOP. Brain down, deploying, or network.
|
|
30
|
+
* HTTP 4xx (other) → QUARANTINE. A durable client-side defect.
|
|
31
|
+
*
|
|
32
|
+
* AMBIGUOUS TIMEOUTS ARE SAFE. A request that times out after the server committed
|
|
33
|
+
* the write is retried — and cannot duplicate, because `client_id` is the server's
|
|
34
|
+
* per-item idempotency key. The retry simply comes back `duplicate` and settles.
|
|
35
|
+
*
|
|
36
|
+
* A drain NEVER throws. It is called from editor hooks; a memory is worth less
|
|
37
|
+
* than the user's session.
|
|
38
|
+
*/
|
|
39
|
+
import { depositDetailed, type Auth } from "./atlaso";
|
|
40
|
+
import { log } from "./log";
|
|
41
|
+
import {
|
|
42
|
+
maxDrainPerRun,
|
|
43
|
+
bumpAttempt,
|
|
44
|
+
enforceBounds,
|
|
45
|
+
hasPending,
|
|
46
|
+
pending,
|
|
47
|
+
quarantine,
|
|
48
|
+
settle,
|
|
49
|
+
type OutboxRecord,
|
|
50
|
+
} from "./outbox";
|
|
51
|
+
|
|
52
|
+
export interface DrainResult {
|
|
53
|
+
attempted: number;
|
|
54
|
+
settled: number;
|
|
55
|
+
retried: number;
|
|
56
|
+
quarantined: number;
|
|
57
|
+
stopped: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const EMPTY: DrainResult = { attempted: 0, settled: 0, retried: 0, quarantined: 0, stopped: false };
|
|
61
|
+
|
|
62
|
+
/** Statuses that mean "the server has formed a verdict on this item". Anything
|
|
63
|
+
* here leaves the queue. */
|
|
64
|
+
function isSettledStatus(s: string | undefined): boolean {
|
|
65
|
+
return s !== undefined && s !== "invalid";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Classify a whole-request failure. `stop` halts the pass: when the brain is down
|
|
69
|
+
* or shedding, the remaining items would fail identically and we would just be
|
|
70
|
+
* burning the user's hook budget. */
|
|
71
|
+
function classifyRequest(status: number): { retry: boolean; stop: boolean; why: string } {
|
|
72
|
+
if (status === 429) return { retry: true, stop: true, why: "rate limited" };
|
|
73
|
+
if (status === 401 || status === 403) return { retry: true, stop: true, why: `auth/edge ${status}` };
|
|
74
|
+
if (status === 0) return { retry: true, stop: true, why: "transport/timeout" };
|
|
75
|
+
if (status >= 500) return { retry: true, stop: true, why: `server ${status}` };
|
|
76
|
+
return { retry: false, stop: true, why: `client ${status}` }; // durable 4xx → quarantine
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Attempt one bounded pass over the queue.
|
|
81
|
+
*
|
|
82
|
+
* Items go up ONE at a time rather than as one large batch on purpose: a batch
|
|
83
|
+
* shares a single fate, so one poisoned item would drag good memories into the
|
|
84
|
+
* same verdict and we could not tell which was which. Per-item costs more
|
|
85
|
+
* round-trips on a backlog, but a backlog is already the rare path, and
|
|
86
|
+
* correctness there is the entire point of this file.
|
|
87
|
+
*/
|
|
88
|
+
/** The push used by a drain. Injectable so tests can drive the taxonomy directly
|
|
89
|
+
* without `mock.module`, which in Bun mutates the module registry for the WHOLE
|
|
90
|
+
* run and silently breaks any later file importing the same module. Production
|
|
91
|
+
* always uses the default. */
|
|
92
|
+
export type DepositFn = typeof depositDetailed;
|
|
93
|
+
|
|
94
|
+
export async function drain(
|
|
95
|
+
tool: string,
|
|
96
|
+
auth: Auth,
|
|
97
|
+
limit = maxDrainPerRun(),
|
|
98
|
+
deposit: DepositFn = depositDetailed,
|
|
99
|
+
): Promise<DrainResult> {
|
|
100
|
+
const out: DrainResult = { ...EMPTY };
|
|
101
|
+
try {
|
|
102
|
+
enforceBounds(tool);
|
|
103
|
+
const items = pending(tool, limit);
|
|
104
|
+
if (!items.length) return out;
|
|
105
|
+
|
|
106
|
+
for (const rec of items) {
|
|
107
|
+
out.attempted++;
|
|
108
|
+
let res: Awaited<ReturnType<DepositFn>>;
|
|
109
|
+
try {
|
|
110
|
+
res = await deposit(auth, [rec.item]);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
// depositDetailed already swallows transport errors; this is belt-and-braces
|
|
113
|
+
// so an unexpected throw can never abort the loop and strand the rest.
|
|
114
|
+
if (bumpAttempt(tool, rec, String(e)) === "quarantine") out.quarantined++;
|
|
115
|
+
else out.retried++;
|
|
116
|
+
out.stopped = true;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (res.ok) {
|
|
121
|
+
const verdict = res.results.find((r) => r.client_id === rec.client_id);
|
|
122
|
+
if (verdict && !isSettledStatus(verdict.status)) {
|
|
123
|
+
quarantine(tool, rec, `server rejected: ${verdict.status}`);
|
|
124
|
+
out.quarantined++;
|
|
125
|
+
} else if (verdict) {
|
|
126
|
+
settle(tool, rec.client_id);
|
|
127
|
+
out.settled++;
|
|
128
|
+
} else {
|
|
129
|
+
// 2xx but our item is not in the results — do not assume it landed.
|
|
130
|
+
if (bumpAttempt(tool, rec, "absent from results") === "quarantine") out.quarantined++;
|
|
131
|
+
else out.retried++;
|
|
132
|
+
}
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const c = classifyRequest(res.status);
|
|
137
|
+
if (!c.retry) {
|
|
138
|
+
quarantine(tool, rec, `${c.why}${res.error ? `: ${res.error}` : ""}`);
|
|
139
|
+
out.quarantined++;
|
|
140
|
+
} else if (bumpAttempt(tool, rec, c.why) === "quarantine") {
|
|
141
|
+
out.quarantined++;
|
|
142
|
+
} else {
|
|
143
|
+
out.retried++;
|
|
144
|
+
}
|
|
145
|
+
if (c.stop) {
|
|
146
|
+
out.stopped = true;
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (out.attempted) {
|
|
152
|
+
log(
|
|
153
|
+
"drain",
|
|
154
|
+
`attempted=${out.attempted} settled=${out.settled} retried=${out.retried} ` +
|
|
155
|
+
`quarantined=${out.quarantined}${out.stopped ? " stopped" : ""}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
} catch (e) {
|
|
159
|
+
// A drain must never break the editor session.
|
|
160
|
+
try {
|
|
161
|
+
log("drain", `error ${e}`);
|
|
162
|
+
} catch {
|
|
163
|
+
/* logging itself failed — nothing left to do */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Cheap guard for latency-sensitive hooks: skip the whole drain (and its
|
|
170
|
+
* directory parsing) when the queue is empty, which is the overwhelmingly
|
|
171
|
+
* common case. */
|
|
172
|
+
export async function drainIfPending(
|
|
173
|
+
tool: string,
|
|
174
|
+
auth: Auth,
|
|
175
|
+
limit = maxDrainPerRun(),
|
|
176
|
+
deposit: DepositFn = depositDetailed,
|
|
177
|
+
): Promise<DrainResult> {
|
|
178
|
+
try {
|
|
179
|
+
if (!hasPending(tool)) return { ...EMPTY };
|
|
180
|
+
} catch {
|
|
181
|
+
return { ...EMPTY };
|
|
182
|
+
}
|
|
183
|
+
return drain(tool, auth, limit, deposit);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export type { OutboxRecord };
|
package/lib/entitlement.ts
CHANGED
|
@@ -16,7 +16,8 @@ export type { Verdict } from "./state";
|
|
|
16
16
|
/** Should we attempt a cloud call right now? True = cloud-linked, False =
|
|
17
17
|
* local-only this turn. Never throws (memory must never break a turn). Hits the
|
|
18
18
|
* network only on a stale/foreign verdict (cached otherwise). */
|
|
19
|
-
export async function online(auth: Auth | null, tool: string
|
|
19
|
+
export async function online(auth: Auth | null, id: { tool: string; deviceId: string | null }): Promise<boolean> {
|
|
20
|
+
const { tool, deviceId } = id;
|
|
20
21
|
if (!auth) return false;
|
|
21
22
|
try {
|
|
22
23
|
const st = state.get();
|
|
@@ -67,7 +68,8 @@ async function verify(auth: Auth, tool: string, deviceId: string | null): Promis
|
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/** The current verdict for surfacing a user notice (no network). */
|
|
70
|
-
export function cloudMode(auth: Auth | null, tool: string
|
|
71
|
+
export function cloudMode(auth: Auth | null, id: { tool: string; deviceId: string | null }): state.Verdict {
|
|
72
|
+
const { tool, deviceId } = id;
|
|
71
73
|
if (!auth) return { ...state.defaultState(), mode: state.LOCAL_ONLY, reason: state.NOT_CONNECTED };
|
|
72
74
|
const st = state.get();
|
|
73
75
|
if (!state.matches(st, tool, deviceId)) return state.defaultState(); // foreign → no notice
|
package/lib/lock.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/** Cross-process advisory lock for per-tool credential minting — a real kernel
|
|
2
|
+
* lock via bun:ffi flock(2), the TS counterpart of the Python client's
|
|
3
|
+
* fcntl.flock in `_credential.py`.
|
|
4
|
+
*
|
|
5
|
+
* WHY a kernel lock and not a lockfile-with-staleness: a self-managed lockfile
|
|
6
|
+
* needs a staleness rule, and every staleness rule is a TOCTOU race where two
|
|
7
|
+
* contenders both decide the other's lock is stale and stomp it. The kernel drops
|
|
8
|
+
* a flock when the holding process dies — no staleness to reason about, nothing to
|
|
9
|
+
* steal, and the lock file is NEVER unlinked (deleting a file another process holds
|
|
10
|
+
* an fd to is its own race).
|
|
11
|
+
*
|
|
12
|
+
* Where flock is unavailable (Windows, or any platform without bun:ffi) we fall back
|
|
13
|
+
* to an O_EXCL lockfile — see `withExclusiveFileLock` at the bottom for why that is
|
|
14
|
+
* acceptable there and was NOT acceptable as the primary mechanism. Before that
|
|
15
|
+
* fallback existed, Windows got `held: false` on every run, which meant it never
|
|
16
|
+
* called /v1/device/exchange, never observed `tool_revoked`, and so REMOVING A TOOL
|
|
17
|
+
* DID NOT STOP IT SYNCING on Windows. Every platform now mints per-tool credentials.
|
|
18
|
+
*
|
|
19
|
+
* Load-bearing invariant (never-brick): a lock we could not take is never a verdict.
|
|
20
|
+
* `held: false` still means "do not mint this run, keep the shared bearer, retry
|
|
21
|
+
* next run" — it must never take a tool offline.
|
|
22
|
+
*/
|
|
23
|
+
import { closeSync, openSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
24
|
+
|
|
25
|
+
const LOCK_EX = 2;
|
|
26
|
+
const LOCK_NB = 4;
|
|
27
|
+
const LOCK_UN = 8;
|
|
28
|
+
const SPIN_MS = 50;
|
|
29
|
+
|
|
30
|
+
// How long to wait for a contended lock before giving up (→ shared bearer). Shorter
|
|
31
|
+
// than the exchange it guards (8s), so "proceed unlocked" is never tempting. Read
|
|
32
|
+
// per-call (not at module load) so it stays env-overridable for tests.
|
|
33
|
+
function lockTimeoutMs(): number {
|
|
34
|
+
const n = parseInt(process.env.ATLASO_LOCK_TIMEOUT_MS ?? "5000", 10);
|
|
35
|
+
return Number.isFinite(n) ? n : 5000;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type FlockFn = (fd: number, op: number) => number;
|
|
39
|
+
|
|
40
|
+
// Resolve flock(2) from libc once. null = no primitive on this platform → the
|
|
41
|
+
// caller degrades to the shared bearer. Cached (including the null) so we probe once.
|
|
42
|
+
let _flock: FlockFn | null | undefined;
|
|
43
|
+
|
|
44
|
+
function resolveFlock(): FlockFn | null {
|
|
45
|
+
if (_flock !== undefined) return _flock;
|
|
46
|
+
// Escape hatch: exercise the portable path on a machine that HAS flock. Tests use
|
|
47
|
+
// it, and it makes the Windows behaviour reproducible during support triage.
|
|
48
|
+
if (process.env.ATLASO_LOCK_NO_FLOCK === "1") {
|
|
49
|
+
_flock = null;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const libs =
|
|
53
|
+
process.platform === "darwin"
|
|
54
|
+
? ["libSystem.B.dylib"]
|
|
55
|
+
: process.platform === "linux"
|
|
56
|
+
? ["libc.so.6", "libc.so"]
|
|
57
|
+
: []; // win32 / others: flock is not the API — no primitive, degrade safely
|
|
58
|
+
for (const lib of libs) {
|
|
59
|
+
try {
|
|
60
|
+
// Lazy import so a platform without bun:ffi never trips over the import itself.
|
|
61
|
+
const { dlopen, FFIType } = require("bun:ffi");
|
|
62
|
+
const { symbols } = dlopen(lib, {
|
|
63
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
64
|
+
});
|
|
65
|
+
_flock = symbols.flock as unknown as FlockFn;
|
|
66
|
+
return _flock;
|
|
67
|
+
} catch {
|
|
68
|
+
/* try the next candidate */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
_flock = null;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Run `fn(held)` holding an exclusive advisory lock on `lockPath`.
|
|
79
|
+
*
|
|
80
|
+
* - `held: true` — the lock is ours; it's safe to exchange + write the credential.
|
|
81
|
+
* - `held: false` — no primitive, couldn't open the file, or the 5s deadline passed
|
|
82
|
+
* while another instance held it. The caller MUST NOT mint; it falls back to the
|
|
83
|
+
* shared bearer and retries next run. The timeout is deliberately shorter than the
|
|
84
|
+
* exchange it guards, so "proceed unlocked" is never the tempting option.
|
|
85
|
+
*
|
|
86
|
+
* The lock is released (LOCK_UN) and the fd closed in `finally`, always. The lock
|
|
87
|
+
* file itself is left in place — never unlinked.
|
|
88
|
+
*/
|
|
89
|
+
export async function withToolLock<T>(
|
|
90
|
+
lockPath: string,
|
|
91
|
+
fn: (held: boolean) => Promise<T>,
|
|
92
|
+
): Promise<T> {
|
|
93
|
+
const flock = resolveFlock();
|
|
94
|
+
if (!flock) return withExclusiveFileLock(lockPath, fn);
|
|
95
|
+
|
|
96
|
+
let fd: number;
|
|
97
|
+
try {
|
|
98
|
+
// "a" = O_CREAT|O_WRONLY|O_APPEND — creates the lock file if absent, never
|
|
99
|
+
// truncates, and we never write to it; we only need a stable fd to flock.
|
|
100
|
+
fd = openSync(lockPath, "a", 0o600);
|
|
101
|
+
} catch {
|
|
102
|
+
return fn(false); // can't even open the lock file → don't mint unlocked
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const deadline = Date.now() + lockTimeoutMs();
|
|
106
|
+
let held = false;
|
|
107
|
+
while (Date.now() < deadline) {
|
|
108
|
+
if (flock(fd, LOCK_EX | LOCK_NB) === 0) {
|
|
109
|
+
held = true;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
await sleep(SPIN_MS);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
return await fn(held);
|
|
117
|
+
} finally {
|
|
118
|
+
if (held) {
|
|
119
|
+
try {
|
|
120
|
+
flock(fd, LOCK_UN);
|
|
121
|
+
} catch {
|
|
122
|
+
/* releasing on close anyway */
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
closeSync(fd);
|
|
127
|
+
} catch {
|
|
128
|
+
/* fd already gone */
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── portable fallback lock (no flock: Windows, or any platform without bun:ffi) ──
|
|
134
|
+
//
|
|
135
|
+
// WHY THIS EXISTS. Yielding `held: false` on Windows looked conservative, but it was
|
|
136
|
+
// not: `resolveCredential` then returns the SHARED bearer without ever calling
|
|
137
|
+
// /v1/device/exchange, so the tool never mints its own credential and never observes
|
|
138
|
+
// a `tool_revoked` verdict. Net effect — on Windows, removing Cursor did not stop it
|
|
139
|
+
// syncing. The per-tool credential guarantee was simply false there.
|
|
140
|
+
// (cursor/plugins#157, Bugbot "Lock miss skips tool revoke", Medium/Security.)
|
|
141
|
+
//
|
|
142
|
+
// The obvious alternative — call exchange without holding a lock — is WRONG: the
|
|
143
|
+
// server's exchange ROTATES the credential in place (mints a new token, deletes the
|
|
144
|
+
// previous), so an un-persisted mint on every hook run would churn a fresh token row
|
|
145
|
+
// per invocation, forever.
|
|
146
|
+
//
|
|
147
|
+
// So: give Windows a real mutex instead. O_EXCL create is atomic on every platform,
|
|
148
|
+
// including Windows. The header above rejects lockfiles because staleness rules are
|
|
149
|
+
// TOCTOU races — that critique is correct, and the reason it is ACCEPTABLE here is
|
|
150
|
+
// the blast radius, not the absence of the race: this lock is held only across one
|
|
151
|
+
// exchange (≤8s), the stale threshold is an order of magnitude beyond that, and if
|
|
152
|
+
// two processes ever did mint concurrently the loser simply holds a rotated-away
|
|
153
|
+
// token, 401s on its next call, clears its own tool file and re-mints. Transient
|
|
154
|
+
// churn that self-heals — not corruption, and not a brick. Weighed against
|
|
155
|
+
// revocation silently not working on an entire operating system, that trade is easy.
|
|
156
|
+
const STALE_MS = 60_000;
|
|
157
|
+
|
|
158
|
+
function staleMs(): number {
|
|
159
|
+
const n = parseInt(process.env.ATLASO_LOCK_STALE_MS ?? "", 10);
|
|
160
|
+
return Number.isFinite(n) && n > 0 ? n : STALE_MS;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Separate path from the flock lock file: the two mechanisms must never contend
|
|
164
|
+
* on the same inode, and this one is unlinked on release while that one never is. */
|
|
165
|
+
function exclPath(lockPath: string): string {
|
|
166
|
+
return lockPath + ".excl";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function withExclusiveFileLock<T>(
|
|
170
|
+
lockPath: string,
|
|
171
|
+
fn: (held: boolean) => Promise<T>,
|
|
172
|
+
): Promise<T> {
|
|
173
|
+
const p = exclPath(lockPath);
|
|
174
|
+
const deadline = Date.now() + lockTimeoutMs();
|
|
175
|
+
let held = false;
|
|
176
|
+
|
|
177
|
+
while (Date.now() < deadline) {
|
|
178
|
+
try {
|
|
179
|
+
// "wx" = O_CREAT|O_EXCL|O_WRONLY — atomic create-or-fail on all platforms.
|
|
180
|
+
const fd = openSync(p, "wx", 0o600);
|
|
181
|
+
try {
|
|
182
|
+
writeFileSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
183
|
+
} catch {
|
|
184
|
+
/* diagnostics only — holding the lock is what matters */
|
|
185
|
+
}
|
|
186
|
+
closeSync(fd);
|
|
187
|
+
held = true;
|
|
188
|
+
break;
|
|
189
|
+
} catch {
|
|
190
|
+
// Someone holds it, or it was orphaned by a process that died mid-exchange.
|
|
191
|
+
// Reclaim ONLY when far past any legitimate hold.
|
|
192
|
+
try {
|
|
193
|
+
if (Date.now() - statSync(p).mtimeMs > staleMs()) unlinkSync(p);
|
|
194
|
+
} catch {
|
|
195
|
+
/* vanished or unreadable — the next attempt settles it */
|
|
196
|
+
}
|
|
197
|
+
await sleep(SPIN_MS);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
return await fn(held);
|
|
203
|
+
} finally {
|
|
204
|
+
if (held) {
|
|
205
|
+
try {
|
|
206
|
+
unlinkSync(p); // release; an O_EXCL lock is held by the file's existence
|
|
207
|
+
} catch {
|
|
208
|
+
/* already reclaimed as stale — nothing to release */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Reset the cached flock probe. Tests only — lets one process exercise both paths. */
|
|
215
|
+
export function _resetFlockProbeForTests(): void {
|
|
216
|
+
_flock = undefined;
|
|
217
|
+
}
|