@solaqua/gji 0.12.0 → 0.12.2

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.
@@ -8,12 +8,11 @@ export interface CloneRequestOptions {
8
8
  }
9
9
  export interface CloneDirOptions extends CloneRequestOptions {
10
10
  platform?: NodeJS.Platform;
11
- runCommand?: (command: string, args: string[]) => Promise<void>;
11
+ runLinuxCommand?: (command: string, args: string[]) => Promise<void>;
12
12
  copyDirectory?: (source: string, destination: string) => Promise<void>;
13
13
  copyFile?: (source: string, destination: string) => Promise<void>;
14
14
  }
15
15
  export type CloneDirectory = (source: string, destination: string, options?: CloneRequestOptions) => Promise<CloneDirResult>;
16
- export declare function waitForCloneLock(destination: string, timeoutMs?: number): Promise<boolean>;
17
16
  export declare function cloneDir(source: string, destination: string, options?: CloneDirOptions): Promise<CloneDirResult>;
18
17
  export declare class CloneDestinationExistsError extends Error {
19
18
  readonly code = "GJI_CLONE_DESTINATION_EXISTS";
package/dist/dir-clone.js CHANGED
@@ -2,54 +2,18 @@ import { execFile } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { constants } from "node:fs";
4
4
  import { chmod, cp, lstat, mkdir, mkdtemp, opendir, readdir, readFile, readlink, realpath, rename, rm, rmdir, symlink, unlink, utimes, writeFile, } from "node:fs/promises";
5
- import { basename, dirname, join } from "node:path";
5
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { promisify } from "node:util";
7
7
  import { isAlreadyExistsError, isNotFoundError } from "./fs-utils.js";
8
8
  import { inspectDestination, openDestinationDirectory, } from "./safe-destination.js";
9
9
  const execFileAsync = promisify(execFile);
10
- const CLONE_LOCK_TTL_MS = 24 * 60 * 60 * 1000;
10
+ const CLONE_LOCK_TTL_MS = 5 * 60 * 1000;
11
+ const CLONE_GUARD_TTL_MS = 30 * 1000;
11
12
  const CLONE_LOCK_SUFFIX = ".gji-clone-lock";
12
13
  const SIZE_ESTIMATE_MAX_ENTRIES = 1_000_000;
13
14
  const SIZE_ESTIMATE_MAX_MS = 5_000;
14
- export async function waitForCloneLock(destination, timeoutMs = 5_000) {
15
- const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`;
16
- const deadline = Date.now() + timeoutMs;
17
- while (await cloneLockExists(lockPath)) {
18
- if (await cloneLockIsStale(lockPath))
19
- return true;
20
- if (Date.now() >= deadline)
21
- return false;
22
- await new Promise((resolve) => setTimeout(resolve, 25));
23
- }
24
- return true;
25
- }
26
- async function cloneLockIsStale(lockPath) {
27
- try {
28
- const freshnessPath = await lockFreshnessPath(lockPath);
29
- const stats = await lstat(freshnessPath);
30
- return Date.now() - stats.mtimeMs >= CLONE_LOCK_TTL_MS;
31
- }
32
- catch (error) {
33
- if (isNotFoundError(error))
34
- return false;
35
- return false;
36
- }
37
- }
38
- async function cloneLockExists(lockPath) {
39
- try {
40
- return await destinationExists(lockPath);
41
- }
42
- catch (error) {
43
- if ("code" in error &&
44
- error.code === "ENOTDIR") {
45
- return false;
46
- }
47
- throw error;
48
- }
49
- }
50
15
  export async function cloneDir(source, destination, options = {}) {
51
16
  const platform = options.platform ?? process.platform;
52
- const strategy = cloneStrategy(platform);
53
17
  if (!isClonePlatformSupported(platform)) {
54
18
  throw new CloneUnsupportedError(`platform ${platform} has no CoW strategy`);
55
19
  }
@@ -58,8 +22,12 @@ export async function cloneDir(source, destination, options = {}) {
58
22
  if (!sourceStats.isDirectory()) {
59
23
  throw new Error("source is not a directory");
60
24
  }
61
- if (await destinationExists(destination)) {
62
- throw new CloneDestinationExistsError(destination);
25
+ const destinationPath = await resolveProspectivePath(destination);
26
+ const destinationDistance = relative(sourcePath, destinationPath);
27
+ if (destinationDistance === "" ||
28
+ (destinationDistance !== ".." &&
29
+ !destinationDistance.startsWith(`..${sep}`))) {
30
+ throw new Error("clone destination must not be inside its source");
63
31
  }
64
32
  const startedAt = Date.now();
65
33
  const parent = dirname(destination);
@@ -77,78 +45,87 @@ export async function cloneDir(source, destination, options = {}) {
77
45
  }
78
46
  const operationParent = safeParent?.path ?? parent;
79
47
  const operationDestination = join(operationParent, basename(destination));
80
- if (await destinationExists(operationDestination)) {
81
- throw new CloneDestinationExistsError(destination);
82
- }
83
- const lockPath = `${destination}${CLONE_LOCK_SUFFIX}`;
84
- const lockToken = await acquireCloneLock(lockPath, destination);
85
- const stopLockHeartbeat = startLockHeartbeat(lockPath, lockToken);
48
+ const cloneLock = await acquireCloneLock(`${operationDestination}${CLONE_LOCK_SUFFIX}`, destination);
49
+ const stopLockHeartbeat = startLockHeartbeat(cloneLock);
86
50
  let temporaryRoot;
87
- let reservationPath;
88
- const reservationEntries = [];
51
+ let reservation;
52
+ const publishedEntries = [];
89
53
  try {
90
- reservationPath = await reserveDestination(operationDestination);
54
+ if (await destinationExists(operationDestination)) {
55
+ throw new CloneDestinationExistsError(destination);
56
+ }
57
+ reservation = await reserveDestination(operationDestination);
91
58
  temporaryRoot = await mkdtemp(join(operationParent, `.${basename(destination)}.gji-clone-`));
92
59
  const temporaryDestination = join(temporaryRoot, basename(destination));
93
- const copyDirectory = options.copyDirectory ??
94
- (platformIsDarwin(platform)
95
- ? runNativeCloneDirectory
96
- : async (source, target) => {
97
- if (!strategy) {
98
- throw new CloneUnsupportedError(`platform ${platform} has no CoW strategy`);
99
- }
100
- const runCommand = options.runCommand ?? runCloneCommand;
101
- await runCommand("cp", strategy(source, target));
102
- });
103
60
  try {
104
- await copyDirectory(sourcePath, temporaryDestination);
61
+ await (options.copyDirectory ??
62
+ ((sourcePath, targetPath) => copyDirectoryWithCow(sourcePath, targetPath, platform, options.runLinuxCommand)))(sourcePath, temporaryDestination);
105
63
  }
106
64
  catch (error) {
65
+ if (error instanceof CloneUnsupportedError)
66
+ throw error;
107
67
  if (isUnsupportedCloneError(error)) {
108
68
  throw new CloneUnsupportedError(toErrorMessage(error));
109
69
  }
110
70
  throw error;
111
71
  }
112
- await publishCloneContents(temporaryDestination, operationDestination, reservationPath, (entry) => reservationEntries.push(entry), options.copyFile ?? runForcedCloneFileCopy);
113
- reservationPath = undefined;
72
+ await publishCloneContents(temporaryDestination, operationDestination, reservation.path, (entry) => publishedEntries.push(entry), options.copyFile ?? runForcedCloneFileCopy);
73
+ reservation = undefined;
114
74
  }
115
75
  finally {
116
76
  stopLockHeartbeat();
117
- if (reservationPath) {
118
- await cleanupReservedDestination(operationDestination, reservationPath, reservationEntries);
119
- }
120
- try {
121
- if (temporaryRoot) {
122
- await rm(temporaryRoot, { force: true, recursive: true });
123
- }
124
- }
125
- catch {
126
- // Cleanup is best effort and must not mask the clone result.
77
+ if (reservation) {
78
+ await cleanupReservedDestination(operationDestination, reservation, publishedEntries);
127
79
  }
128
- try {
129
- await releaseCloneLock(lockPath, lockToken);
130
- }
131
- catch {
132
- // A stale lock is reclaimed on a later attempt.
80
+ if (temporaryRoot) {
81
+ await rm(temporaryRoot, { force: true, recursive: true }).catch(() => undefined);
133
82
  }
83
+ await releaseCloneLock(cloneLock).catch(() => undefined);
134
84
  }
135
- const bytes = options.measureBytes === false
136
- ? undefined
137
- : await estimateCloneBytes(sourcePath);
138
- return { bytes, ms: Date.now() - startedAt };
85
+ return cloneResult(sourcePath, startedAt, options.measureBytes);
139
86
  }
140
87
  finally {
141
88
  await safeParent?.close().catch(() => undefined);
142
89
  }
143
90
  }
144
- async function runNativeCloneDirectory(source, destination) {
145
- await cp(source, destination, {
146
- errorOnExist: true,
147
- force: false,
148
- mode: constants.COPYFILE_FICLONE_FORCE,
149
- preserveTimestamps: true,
150
- recursive: true,
151
- });
91
+ async function copyDirectoryWithCow(source, destination, platform, runLinuxCommand) {
92
+ if (platform === "darwin") {
93
+ await cp(source, destination, {
94
+ errorOnExist: true,
95
+ force: false,
96
+ mode: constants.COPYFILE_FICLONE_FORCE,
97
+ preserveTimestamps: true,
98
+ recursive: true,
99
+ });
100
+ return;
101
+ }
102
+ const strategy = cloneStrategy(platform);
103
+ if (!strategy) {
104
+ throw new CloneUnsupportedError(`platform ${platform} has no CoW strategy`);
105
+ }
106
+ await (runLinuxCommand ?? runCloneCommand)("cp", strategy(source, destination));
107
+ }
108
+ async function resolveProspectivePath(path) {
109
+ let current = resolve(path);
110
+ const missing = [];
111
+ while (true) {
112
+ try {
113
+ return join(await realpath(current), ...missing);
114
+ }
115
+ catch (error) {
116
+ if (!isNotFoundError(error))
117
+ throw error;
118
+ const parent = dirname(current);
119
+ if (parent === current)
120
+ throw error;
121
+ missing.unshift(basename(current));
122
+ current = parent;
123
+ }
124
+ }
125
+ }
126
+ async function cloneResult(source, startedAt, measureBytes) {
127
+ const bytes = measureBytes === false ? undefined : await estimateCloneBytes(source);
128
+ return { bytes, ms: Date.now() - startedAt };
152
129
  }
153
130
  export class CloneDestinationExistsError extends Error {
154
131
  code = "GJI_CLONE_DESTINATION_EXISTS";
@@ -175,7 +152,7 @@ export function isCloneDestinationExistsError(error) {
175
152
  return error instanceof CloneDestinationExistsError;
176
153
  }
177
154
  export function isCloneUnsupportedError(error) {
178
- return (error instanceof CloneUnsupportedError || isUnsupportedCloneError(error));
155
+ return error instanceof CloneUnsupportedError;
179
156
  }
180
157
  export function isCloneInProgressError(error) {
181
158
  return error instanceof CloneInProgressError;
@@ -247,35 +224,31 @@ async function runCloneCommand(command, args) {
247
224
  }
248
225
  }
249
226
  async function acquireCloneLock(lockPath, destination) {
227
+ const releaseGuard = await acquireCloneLockGuard(lockPath, destination);
228
+ try {
229
+ return await acquireCloneLockWithGuard(lockPath, destination);
230
+ }
231
+ finally {
232
+ await releaseGuard();
233
+ }
234
+ }
235
+ async function acquireCloneLockWithGuard(lockPath, destination) {
250
236
  const lockToken = randomUUID();
251
237
  for (let attempt = 0; attempt < 3; attempt += 1) {
238
+ const published = await publishCloneLock(lockPath, lockToken);
239
+ if (published)
240
+ return published;
241
+ let staleLock;
252
242
  try {
253
- await mkdir(lockPath);
254
- try {
255
- await writeFile(join(lockPath, ownerFileName(lockToken)), `${lockToken}\n`, {
256
- flag: "wx",
257
- });
258
- }
259
- catch (error) {
260
- await rm(lockPath, { force: true, recursive: true });
261
- throw error;
262
- }
263
- return lockToken;
264
- }
265
- catch (error) {
266
- if (!isAlreadyExistsError(error))
267
- throw error;
268
- }
269
- let lockStats;
270
- try {
271
- lockStats = await lstat(await lockFreshnessPath(lockPath));
243
+ staleLock = await inspectCloneLock(lockPath);
272
244
  }
273
245
  catch (error) {
274
246
  if (isNotFoundError(error))
275
247
  continue;
276
248
  throw error;
277
249
  }
278
- if (Date.now() - lockStats.mtimeMs < CLONE_LOCK_TTL_MS) {
250
+ if (staleLock.kind === "invalid" ||
251
+ Date.now() - staleLock.mtimeMs < CLONE_LOCK_TTL_MS) {
279
252
  throw new CloneInProgressError(destination);
280
253
  }
281
254
  const stalePath = `${lockPath}.stale-${randomUUID()}`;
@@ -288,27 +261,22 @@ async function acquireCloneLock(lockPath, destination) {
288
261
  throw error;
289
262
  }
290
263
  try {
291
- await mkdir(lockPath);
292
- try {
293
- await writeFile(join(lockPath, ownerFileName(lockToken)), `${lockToken}\n`, {
294
- flag: "wx",
295
- });
296
- }
297
- catch (error) {
298
- await rm(lockPath, { force: true, recursive: true });
299
- throw error;
264
+ const replacement = await publishCloneLock(lockPath, lockToken);
265
+ if (!replacement) {
266
+ await cleanupStaleCloneLock(stalePath, staleLock);
267
+ continue;
300
268
  }
301
269
  try {
302
- await rm(stalePath, { force: true, recursive: true });
270
+ await cleanupStaleCloneLock(stalePath, staleLock);
303
271
  }
304
272
  catch (cleanupError) {
305
- await releaseCloneLock(lockPath, lockToken).catch(() => undefined);
273
+ await releaseCloneLockWithGuard(replacement).catch(() => undefined);
306
274
  throw cleanupError;
307
275
  }
308
- return lockToken;
276
+ return replacement;
309
277
  }
310
278
  catch (error) {
311
- await rm(stalePath, { force: true, recursive: true }).catch(() => undefined);
279
+ await cleanupStaleCloneLock(stalePath, staleLock).catch(() => undefined);
312
280
  if (isAlreadyExistsError(error))
313
281
  continue;
314
282
  throw error;
@@ -316,18 +284,172 @@ async function acquireCloneLock(lockPath, destination) {
316
284
  }
317
285
  throw new CloneInProgressError(destination);
318
286
  }
319
- async function lockFreshnessPath(lockPath) {
287
+ async function acquireCloneLockGuard(lockPath, destination) {
288
+ const guardPath = `${lockPath}.guard`;
289
+ const deadline = Date.now() + 5_000;
290
+ while (true) {
291
+ try {
292
+ await mkdir(guardPath, { mode: 0o700 });
293
+ const markerName = `${process.pid}-${randomUUID()}`;
294
+ const markerPath = join(guardPath, markerName);
295
+ try {
296
+ await writeFile(markerPath, `${markerName}\n`, {
297
+ flag: "wx",
298
+ mode: 0o600,
299
+ });
300
+ }
301
+ catch (error) {
302
+ await rmdir(guardPath).catch(() => undefined);
303
+ throw error;
304
+ }
305
+ return async () => {
306
+ await unlink(markerPath).catch((error) => {
307
+ if (!isNotFoundError(error))
308
+ throw error;
309
+ });
310
+ await rmdir(guardPath).catch((error) => {
311
+ if (!isNotFoundError(error))
312
+ throw error;
313
+ });
314
+ };
315
+ }
316
+ catch (error) {
317
+ if (!isAlreadyExistsError(error))
318
+ throw error;
319
+ if (await reclaimAbandonedCloneGuard(guardPath))
320
+ continue;
321
+ if (Date.now() >= deadline) {
322
+ throw new CloneInProgressError(destination);
323
+ }
324
+ await new Promise((resolve) => setTimeout(resolve, 10));
325
+ }
326
+ }
327
+ }
328
+ async function reclaimAbandonedCloneGuard(guardPath) {
329
+ let expectedMarker;
330
+ try {
331
+ const entries = await readdir(guardPath);
332
+ if (entries.length > 1)
333
+ return false;
334
+ expectedMarker = entries[0];
335
+ const freshnessPath = expectedMarker
336
+ ? join(guardPath, expectedMarker)
337
+ : guardPath;
338
+ const stats = await lstat(freshnessPath);
339
+ if (Date.now() - stats.mtimeMs < CLONE_GUARD_TTL_MS)
340
+ return false;
341
+ if (expectedMarker) {
342
+ const ownerPid = Number(expectedMarker.split("-", 1)[0]);
343
+ if (!Number.isSafeInteger(ownerPid) || ownerPid <= 0)
344
+ return false;
345
+ if (processIsAlive(ownerPid))
346
+ return false;
347
+ }
348
+ }
349
+ catch (error) {
350
+ return isNotFoundError(error);
351
+ }
352
+ const stalePath = `${guardPath}.stale-${randomUUID()}`;
353
+ try {
354
+ await rename(guardPath, stalePath);
355
+ }
356
+ catch (error) {
357
+ if (isNotFoundError(error))
358
+ return true;
359
+ throw error;
360
+ }
361
+ const movedEntries = await readdir(stalePath).catch(() => []);
362
+ if (movedEntries.length !== (expectedMarker ? 1 : 0) ||
363
+ (expectedMarker && movedEntries[0] !== expectedMarker)) {
364
+ await rename(stalePath, guardPath).catch(() => undefined);
365
+ return false;
366
+ }
367
+ if (expectedMarker)
368
+ await unlink(join(stalePath, expectedMarker));
369
+ await rmdir(stalePath);
370
+ return true;
371
+ }
372
+ function processIsAlive(pid) {
373
+ try {
374
+ process.kill(pid, 0);
375
+ return true;
376
+ }
377
+ catch (error) {
378
+ return error.code === "EPERM";
379
+ }
380
+ }
381
+ async function publishCloneLock(lockPath, lockToken) {
382
+ try {
383
+ await mkdir(lockPath, { mode: 0o700 });
384
+ }
385
+ catch (error) {
386
+ if (isAlreadyExistsError(error))
387
+ return undefined;
388
+ throw error;
389
+ }
390
+ const markerPath = join(lockPath, lockToken);
391
+ try {
392
+ await writeFile(markerPath, `${lockToken}\n`, { flag: "wx", mode: 0o600 });
393
+ return {
394
+ lockPath,
395
+ markerName: lockToken,
396
+ markerPath,
397
+ token: lockToken,
398
+ };
399
+ }
400
+ catch (error) {
401
+ await rmdir(lockPath).catch(() => undefined);
402
+ throw error;
403
+ }
404
+ }
405
+ async function inspectCloneLock(lockPath) {
320
406
  try {
407
+ const stats = await lstat(lockPath);
408
+ if (!stats.isDirectory() || stats.isSymbolicLink()) {
409
+ return { kind: "invalid", mtimeMs: stats.mtimeMs };
410
+ }
321
411
  const entries = await readdir(lockPath);
322
- const owner = entries.find((entry) => entry.startsWith("owner-"));
323
- return owner ? join(lockPath, owner) : lockPath;
412
+ if (entries.length === 0)
413
+ return { kind: "empty", mtimeMs: stats.mtimeMs };
414
+ if (entries.length !== 1) {
415
+ return { kind: "invalid", mtimeMs: stats.mtimeMs };
416
+ }
417
+ const markerName = entries[0];
418
+ const token = markerName?.startsWith("owner-")
419
+ ? markerName.slice("owner-".length)
420
+ : markerName;
421
+ if (!token || !/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu.test(token)) {
422
+ return { kind: "invalid", mtimeMs: stats.mtimeMs };
423
+ }
424
+ const markerPath = join(lockPath, markerName);
425
+ const markerStats = await lstat(markerPath);
426
+ if (!markerStats.isFile() || markerStats.isSymbolicLink()) {
427
+ return { kind: "invalid", mtimeMs: markerStats.mtimeMs };
428
+ }
429
+ if ((await readFile(markerPath, "utf8")).trim() !== token) {
430
+ return { kind: "invalid", mtimeMs: markerStats.mtimeMs };
431
+ }
432
+ return {
433
+ kind: "owned",
434
+ lockPath,
435
+ markerName: markerName,
436
+ markerPath,
437
+ mtimeMs: markerStats.mtimeMs,
438
+ token,
439
+ };
324
440
  }
325
441
  catch (error) {
326
442
  if (isNotFoundError(error))
327
443
  throw error;
328
- return lockPath;
444
+ return { kind: "invalid", mtimeMs: Date.now() };
329
445
  }
330
446
  }
447
+ async function cleanupStaleCloneLock(lockPath, inspection) {
448
+ if (inspection.kind === "owned") {
449
+ await unlink(join(lockPath, inspection.markerName));
450
+ }
451
+ await rmdir(lockPath);
452
+ }
331
453
  async function publishCloneContents(temporaryDestination, destination, reservationPath, onEntryPublished, copyFileEntry) {
332
454
  const reservationName = basename(reservationPath);
333
455
  const destinationEntries = await readdir(destination);
@@ -337,7 +459,7 @@ async function publishCloneContents(temporaryDestination, destination, reservati
337
459
  }
338
460
  const temporaryEntries = await readdir(temporaryDestination);
339
461
  for (const entry of temporaryEntries) {
340
- await publishCloneEntry(join(temporaryDestination, entry), join(destination, entry), () => onEntryPublished(entry), copyFileEntry);
462
+ await publishCloneEntry(join(temporaryDestination, entry), join(destination, entry), (entry) => onEntryPublished(entry), copyFileEntry);
341
463
  }
342
464
  await unlink(reservationPath);
343
465
  }
@@ -353,7 +475,7 @@ async function publishCloneEntry(source, destination, onCreated, copyFileEntry)
353
475
  }
354
476
  throw error;
355
477
  }
356
- onCreated();
478
+ onCreated(await inspectOwnedCloneEntry(destination, "directory"));
357
479
  for (const entry of await readdir(source)) {
358
480
  await publishCloneEntry(join(source, entry), join(destination, entry), () => undefined, copyFileEntry);
359
481
  }
@@ -370,7 +492,7 @@ async function publishCloneEntry(source, destination, onCreated, copyFileEntry)
370
492
  }
371
493
  throw error;
372
494
  }
373
- onCreated();
495
+ onCreated(await inspectOwnedCloneEntry(destination, "symlink"));
374
496
  return;
375
497
  }
376
498
  if (!sourceStats.isFile()) {
@@ -388,7 +510,7 @@ async function publishCloneEntry(source, destination, onCreated, copyFileEntry)
388
510
  }
389
511
  throw error;
390
512
  }
391
- onCreated();
513
+ onCreated(await inspectOwnedCloneEntry(destination, "file"));
392
514
  await copyCloneMetadata(sourceStats, destination);
393
515
  }
394
516
  async function runForcedCloneFileCopy(source, destination) {
@@ -418,26 +540,35 @@ async function reserveDestination(destination) {
418
540
  await writeFile(reservationPath, "gji clone reservation\n", {
419
541
  flag: "wx",
420
542
  });
421
- return reservationPath;
543
+ return await inspectOwnedCloneEntry(reservationPath, "file");
422
544
  }
423
545
  catch (error) {
424
546
  await rmdir(destination).catch(() => undefined);
425
547
  throw error;
426
548
  }
427
549
  }
428
- async function cleanupReservedDestination(destination, reservationPath, reservationEntries) {
550
+ async function cleanupReservedDestination(destination, reservation, publishedEntries) {
551
+ for (const entry of [...publishedEntries, reservation].reverse()) {
552
+ await removeOwnedCloneEntry(entry);
553
+ }
554
+ await rmdir(destination).catch(() => undefined);
555
+ }
556
+ async function inspectOwnedCloneEntry(path, kind) {
557
+ const stats = await lstat(path, { bigint: true });
558
+ return { dev: stats.dev, ino: stats.ino, kind, path };
559
+ }
560
+ async function removeOwnedCloneEntry(entry) {
429
561
  try {
430
- const entries = await readdir(destination);
431
- const ownedEntries = new Set([
432
- basename(reservationPath),
433
- ...reservationEntries,
434
- ]);
435
- if (entries.every((entry) => ownedEntries.has(entry))) {
436
- await rm(destination, { force: true, recursive: true });
437
- }
562
+ const current = await lstat(entry.path, { bigint: true });
563
+ if (current.dev !== entry.dev || current.ino !== entry.ino)
564
+ return;
565
+ if (entry.kind === "directory")
566
+ await rmdir(entry.path);
567
+ else
568
+ await unlink(entry.path);
438
569
  }
439
570
  catch {
440
- // Preserve a destination that was changed by another process.
571
+ // Missing, replaced, or non-empty entries are no longer exclusively ours.
441
572
  }
442
573
  }
443
574
  async function destinationExists(path) {
@@ -451,37 +582,50 @@ async function destinationExists(path) {
451
582
  throw error;
452
583
  }
453
584
  }
454
- function startLockHeartbeat(lockPath, lockToken) {
585
+ function startLockHeartbeat(lock) {
455
586
  const timer = setInterval(() => {
456
- void refreshCloneLock(lockPath, lockToken);
587
+ void refreshCloneLock(lock);
457
588
  }, CLONE_LOCK_TTL_MS / 3);
458
589
  timer.unref?.();
459
590
  return () => clearInterval(timer);
460
591
  }
461
- async function refreshCloneLock(lockPath, lockToken) {
592
+ async function refreshCloneLock(lock) {
462
593
  try {
463
- const ownerPath = join(lockPath, ownerFileName(lockToken));
464
- await readFile(ownerPath, "utf8");
465
594
  const now = new Date();
466
- await utimes(ownerPath, now, now);
595
+ await utimes(lock.markerPath, now, now);
467
596
  }
468
597
  catch {
469
- // Lock refresh is advisory; the owner check prevents touching a replacement lock.
598
+ // A missing marker means this owner was already fenced out as stale.
599
+ }
600
+ }
601
+ async function releaseCloneLock(lock) {
602
+ const releaseGuard = await acquireCloneLockGuard(lock.lockPath, lock.lockPath);
603
+ try {
604
+ await releaseCloneLockWithGuard(lock);
605
+ }
606
+ finally {
607
+ await releaseGuard();
470
608
  }
471
609
  }
472
- async function releaseCloneLock(lockPath, lockToken) {
610
+ async function releaseCloneLockWithGuard(lock) {
473
611
  try {
474
- const ownerPath = join(lockPath, ownerFileName(lockToken));
475
- await unlink(ownerPath);
476
- await rmdir(lockPath);
612
+ await unlink(join(lock.lockPath, lock.token));
477
613
  }
478
614
  catch (error) {
479
- if (!isNotFoundError(error) && !isDirectoryNotEmptyError(error))
615
+ if (!isNotFoundError(error))
480
616
  throw error;
481
617
  }
482
- }
483
- function ownerFileName(lockToken) {
484
- return `owner-${lockToken}`;
618
+ try {
619
+ await rmdir(lock.lockPath);
620
+ }
621
+ catch (error) {
622
+ const code = "code" in error
623
+ ? error.code
624
+ : undefined;
625
+ if (!isNotFoundError(error) && code !== "ENOTEMPTY" && code !== "EEXIST") {
626
+ throw error;
627
+ }
628
+ }
485
629
  }
486
630
  function isUnsupportedCloneError(error) {
487
631
  if (!(error instanceof Error))
@@ -490,16 +634,8 @@ function isUnsupportedCloneError(error) {
490
634
  if (["EINVAL", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"].includes(code ?? "")) {
491
635
  return true;
492
636
  }
493
- return /clonefile|reflink|unsupported|operation not supported|not supported|invalid cross-device|cross-device/iu.test(error.message);
494
- }
495
- function isDirectoryNotEmptyError(error) {
496
- return (error instanceof Error &&
497
- "code" in error &&
498
- error.code === "ENOTEMPTY");
637
+ return /reflink|unsupported|operation not supported|not supported|invalid cross-device|cross-device/iu.test(error.message);
499
638
  }
500
639
  function toErrorMessage(error) {
501
640
  return error instanceof Error ? error.message : String(error);
502
641
  }
503
- function platformIsDarwin(platform) {
504
- return platform === "darwin";
505
- }