File size: 15,926 Bytes
4e23b01
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
// src/lockfile.ts
//
// A small exclusive file lock using O_EXCL creation. Used to prevent two
// processes from opening the same database directory for writing (which would
// corrupt it). A lock is considered stale and is taken over only when the
// recorded owner PID is no longer alive — never merely because it is old.
//
// Ownership is per INSTANCE, not per process: every acquire() mints a token
// (`${pid}:${uuid}`) carried by the lock/bid/watch files, and `mine` compares
// tokens, so two LockFile objects in one process are visible to each other
// instead of passing every pid-based check. A legacy lock line without a
// token is never "mine" and follows the pid-liveness stale rules unchanged —
// a live same-pid lock is still respected, exactly as before. Lifecycle ops
// (acquire/renew/release) are serialized through a per-instance promise
// chain, so no interleaving can re-publish the lock after it was released.

import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { renameReplace } from './rename-replace.js';
import { createSerializer } from './serialize.js';

export class LockError extends Error {
  readonly code = 'ELOCKED';
  constructor(message: string) {
    super(message);
    this.name = 'LockError';
  }
}

function pidAlive(pid: unknown): boolean {
  if (!pid || typeof pid !== 'number') return false;
  try {
    process.kill(pid, 0);
    return true;
  } catch (e) {
    return (e as NodeJS.ErrnoException).code === 'EPERM';
  }
}

// Track held locks so we can release them on process exit as a safety net.
const HELD = new Set<LockFile>();
// Distinct sidecar names per acquire attempt: two lock users in the same
// process (e.g. independent shard pools) must never share a tmp/bid/watch
// path, or one user's cleanup would delete the other's in-flight file.
let sidecarSeq = 0;
const nextSidecarSeq = (): number => ++sidecarSeq;
let exitHooked = false;
// Co-bidders all replace the stale corpse within the same wave (they woke on
// the same event); the settle pause before the winner claims the lock must
// outlast that wave so the last bidder to land is unambiguous. A fixed value
// (even a generous one) loses on shared CI runners that deschedule a bidder
// for hundreds of milliseconds inside its own atomic-op sequence, so the
// settle is ADAPTIVE: it scales with how long our own takeover attempt took
// (4x the wall clock of inspect+writeBid+rename — a stalled machine stalls
// every bidder), floored at 60ms and capped at 2s so a healthy takeover stays
// fast. Residual (bounded-delay, inherent to file-based takeover): a bidder
// whose writeBid is delayed past the winner's final verify can still
// double-win — the liveness-watch check at verification shrinks this window
// to "competitor had not even registered its watch yet" (the watch precedes
// the whole attempt), which requires a full-attempt+settle-sized skew and is
// effectively a process-level pause.
const TAKEOVER_SETTLE_BASE_MS = 60;
const TAKEOVER_SETTLE_MAX_MS = 2_000;
function hookExit(): void {
  if (exitHooked) return;
  exitHooked = true;
  process.on('beforeExit', () => {
    for (const lock of HELD) lock.releaseSync();
  });
}

export class LockFile {
  readonly path: string;
  held = false;
  /** Token of the current acquire attempt: minted fresh per attempt,
   *  carried by every file this instance publishes (lock/bid/watch), and the
   *  sole ownership criterion (`mine`). Null before the first acquire(). */
  private token: string | null = null;

  /** The held instance's ownership token (undefined unless currently held).
   *  Lets a host supervising this database from another thread learn WHO
   *  holds the lock file without parsing it — worker threads share the main
   *  process pid, so the pid in the lock line cannot distinguish them. */
  get heldToken(): string | undefined {
    return this.held && this.token !== null ? this.token : undefined;
  }
  /** Serializes acquire/renew/release (the shared promise-chain pattern of
   *  serialize.ts): each op's whole read-check-write completes before the next
   *  one starts, so a renew already in flight finishes before a release
   *  unlinks. */
  private readonly serialized = createSerializer();

  constructor(path: string) {
    this.path = path;
  }

  /** File body for every file this instance publishes (lock, bid, watch). */
  private payload(): string {
    return JSON.stringify({ pid: process.pid, ts: Date.now(), token: this.token });
  }

  /** Try to acquire the lock exactly once. Returns true when this call created
   *  the lock file, either directly or by winning a stale-lock takeover. Returns
   *  false whenever the lock was already held at attempt time — by a live owner
   *  or by a competing takeover. After observing a held lock this call never
   *  re-races: callers that want to wait retry acquire() at a higher level
   *  (see the cluster lock pool). */
  async acquire(): Promise<boolean> {
    return this.serialized(() => this.acquireOnce());
  }

  private async acquireOnce(): Promise<boolean> {
    // Re-entrant acquire on an already-held lock is an idempotent success:
    // the lock is ours, and re-minting the token here would make the later
    // release fail to recognize (and unlink) our own lock line.
    if (this.held) return true;
    this.token = `${process.pid}:${randomUUID()}`;
    // Register a "watch" BEFORE touching the lock: every contender is visible
    // to every other for its whole attempt, regardless of where the scheduler
    // stalls it. (Settle-window heuristics alone could not survive a bidder
    // descheduled before its bid write on a shard-parallel CI runner — see the
    // takeover loop below; a stalled contender is only in the way, not
    // invisible.)
    const watch = `${this.path}.watch-${process.pid}-${nextSidecarSeq()}`;
    await fs.writeFile(watch, this.payload());
    try {
      await this.reapDeadWatches();

      if (await this.tryCreate()) return true;

      // The lock exists. Only a DEAD owner's lock may be taken over; everything
      // else (a live owner, or a takeover bid made by another racer in the
      // meantime) is respected.
      const seen = await this.inspect();
      if (seen === null || seen.alive) return false;

      // Takeover via atomic bid-replace, NOT unlink-then-create. Unlinking a
      // stale lock and then racing to re-create it left a window in which a
      // loser could delete the winner's just-linked file, after which several
      // processes all believed they held the lock. Rename atomically replaces
      // the corpse with our bid.
      //
      // Windows cannot rename over a destination while ANY process holds it
      // open (co-racers reading/stat'ing the corpse make the rename EPERM), so
      // the rename is retried with jitter. Crucially, each retry re-inspects
      // the corpse first: a blind retry loop could land our bid seconds late,
      // OVERWRITING an already-verified winner's lock line and double-holding
      // (exactly the failure this loop is careful not to reintroduce).
      const bid = `${this.path}.bid-${process.pid}-${nextSidecarSeq()}`;
      const attemptStart = Date.now();
      try {
        await fs.writeFile(bid, this.payload());
        for (let attempt = 0; ; attempt++) {
          // The corpse must still be there and dead. A competitor who landed
          // wins by being alive in the file now — back off instead of
          // overwriting their lock. (Unconditional, not just win32: the same
          // overwrite hazard exists on POSIX when a co-bidder is descheduled
          // between its first inspect and its rename.)
          const gate = await this.inspect();
          if (gate === null || gate.alive || gate.mine) {
            await fs.unlink(bid).catch(() => {});
            return false;
          }
          try {
            await fs.rename(bid, this.path);
            break;
          } catch (e) {
            const code = (e as NodeJS.ErrnoException).code;
            const epermRetryable =
              code === 'EPERM' && process.platform === 'win32' && attempt < 50;
            if (!epermRetryable) {
              await fs.unlink(bid).catch(() => {});
              // EEXIST races another creator; a persistent EPERM (Windows
              // retries exhausted) means some holder kept the path pinned —
              // either way the corpse could not be displaced this round, so
              // decline like a live lock and let callers retry higher up.
              if (code === 'EEXIST' || code === 'EPERM') return false;
              throw e;
            }
            await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30)));
          }
        }
      } catch (e) {
        await fs.unlink(bid).catch(() => {});
        throw e;
      }

      // Adaptive settle: scale with how long our own attempt took (a stalled
      // machine stalls every bidder), floored and capped (see the constants).
      const elapsedMs = Date.now() - attemptStart;
      let settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, Math.max(TAKEOVER_SETTLE_BASE_MS, elapsedMs * 4));
      for (;;) {
        await new Promise((resolve) => setTimeout(resolve, settleMs));
        const cur = await this.inspect();
        if (cur === null || !cur.mine) return false;
        // Any live foreign watch means a contender is still in flight (its
        // registration precedes its whole attempt): wait for its loop to
        // finish instead of claiming on stale evidence. This is the check
        // that makes exactly-one a construction, not a timing bet.
        if (!(await this.hasLiveForeignWatch())) break;
        settleMs = Math.min(TAKEOVER_SETTLE_MAX_MS, settleMs * 2);
      }
      this.markHeld();
      return true;
    } finally {
      await fs.unlink(watch).catch(() => {});
    }
  }

  /** Delete watch registrations whose owner pid is no longer alive. */
  private async reapDeadWatches(): Promise<void> {
    const dir = path.dirname(this.path);
    const prefix = `${path.basename(this.path)}.watch-`;
    for (const f of await fs.readdir(dir).catch(() => [] as string[])) {
      if (!f.startsWith(prefix)) continue;
      const pid = Number(f.slice(prefix.length).split('-')[0]);
      if (Number.isInteger(pid) && pid !== process.pid && !pidAlive(pid)) {
        await fs.unlink(path.join(dir, f)).catch(() => {});
      }
    }
  }

  /** True when any OTHER owner's liveness watch exists (reaping dead ones on
   *  sight). "Foreign" is by token, not pid: a same-process competitor's
   *  registration counts, so the settle loop waits for the competitor's whole
   *  attempt to finish instead of claiming on stale evidence. A legacy
   *  tokenless watch line cannot be told apart from our own when its pid is
   *  ours, so it keeps the old pid-based exclusion. */
  private async hasLiveForeignWatch(): Promise<boolean> {
    const dir = path.dirname(this.path);
    const prefix = `${path.basename(this.path)}.watch-`;
    for (const f of await fs.readdir(dir).catch(() => [] as string[])) {
      if (!f.startsWith(prefix)) continue;
      const pid = Number(f.slice(prefix.length).split('-')[0]);
      if (!Number.isInteger(pid)) continue;
      let token: string | undefined;
      try {
        token = (JSON.parse(await fs.readFile(path.join(dir, f), 'utf8')) as { token?: string }).token;
      } catch {
        token = undefined; // unreadable/partial line: fall back to the pid in the name
      }
      if (token !== undefined ? token === this.token : pid === process.pid) continue;
      if (pidAlive(pid)) return true;
      await fs.unlink(path.join(dir, f)).catch(() => {});
    }
    return false;
  }

  /** Atomic create-if-absent publish: tmp write + hard link (EEXIST-safe). */
  private async tryCreate(): Promise<boolean> {
    const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`;
    try {
      await fs.writeFile(tmp, this.payload());
      await fs.link(tmp, this.path);
      this.markHeld();
      return true;
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
      return false;
    } finally {
      await fs.unlink(tmp).catch(() => {});
    }
  }

  /** Read the lock file and decide its state. null = the file vanished.
   *  `mine` is decided by the owner token, `alive` still by pid liveness: a
   *  legacy tokenless line is never mine and follows the stale rules. */
  private async inspect(): Promise<{ ino: number | bigint; alive: boolean; mine: boolean } | null> {
    let raw: string;
    let st: { ino: number | bigint };
    try {
      [raw, st] = await Promise.all([fs.readFile(this.path, 'utf8'), fs.stat(this.path)]);
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
      throw e;
    }
    let pid: number | undefined;
    let token: string | undefined;
    try {
      const parsed = JSON.parse(raw) as { pid?: number; token?: string };
      pid = parsed.pid;
      token = parsed.token;
    } catch {
      pid = undefined; // unparsable content looks abandoned, same as a dead PID
    }
    return { ino: st.ino, alive: pidAlive(pid), mine: this.token !== null && token === this.token };
  }

  private inspectSync(): { ino: number | bigint; alive: boolean; mine: boolean } | null {
    let raw: string;
    let st: { ino: number | bigint };
    try {
      raw = fsSync.readFileSync(this.path, 'utf8');
      st = fsSync.statSync(this.path);
    } catch (e) {
      if ((e as NodeJS.ErrnoException).code === 'ENOENT') return null;
      throw e;
    }
    let pid: number | undefined;
    let token: string | undefined;
    try {
      const parsed = JSON.parse(raw) as { pid?: number; token?: string };
      pid = parsed.pid;
      token = parsed.token;
    } catch {
      pid = undefined;
    }
    return { ino: st.ino, alive: pidAlive(pid), mine: this.token !== null && token === this.token };
  }

  /** Refresh the lock timestamp (proves liveness to processes inspecting the
   *  lock file). No-op when the lock is not held. Uses write-tmp-then-rename
   *  so a crash mid-renew cannot leave a truncated, "stale-looking" lock file
   *  behind for a lock that is actually still owned. Serialized with
   *  acquire/release: `held` is re-checked inside the chain, and a release
   *  queued behind this renew unlinks only after the rename landed. */
  async renew(): Promise<void> {
    return this.serialized(async () => {
      if (!this.held) return;
      const tmp = `${this.path}.tmp-${process.pid}-${nextSidecarSeq()}`;
      await fs.writeFile(tmp, this.payload());
      // Windows: replacing our own lock can still clash with a co-process's
      // readFile/stat of it (EPERM) — the helper rides out such transients.
      await renameReplace(tmp, this.path, { retries: 20 });
    });
  }

  private markHeld(): void {
    this.held = true;
    HELD.add(this);
    hookExit();
  }

  async release(): Promise<void> {
    return this.serialized(async () => {
      if (!this.held) return;
      // Unlink ONLY the file this instance actually owns. The content at this
      // path may have been replaced since we acquired it (a supervisor re-plant a
      // dead-man's marker, a concurrent takeover…), and deleting such a file
      // would drop a lock that no longer belongs to us.
      const cur = await this.inspect();
      if (cur?.mine) await fs.unlink(this.path).catch(() => {});
      this.held = false;
      HELD.delete(this);
    });
  }

  /** Best-effort sync release for the exit hook. */
  releaseSync(): void {
    if (!this.held) return;
    try {
      const cur = this.inspectSync();
      if (cur?.mine) fsSync.unlinkSync(this.path);
    } catch {
      /* ignore */
    }
    this.held = false;
    HELD.delete(this);
  }
}