@cortexkit/antigravity-auth-core 3.0.28 → 3.0.30

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.
Files changed (38) hide show
  1. package/dist/account-selector.d.ts.map +1 -1
  2. package/dist/account-selector.js +26 -3
  3. package/dist/account-selector.js.map +1 -1
  4. package/dist/common-auth-embedded/claustrum/consumer.d.ts +12 -0
  5. package/dist/common-auth-embedded/claustrum/consumer.js +36 -0
  6. package/dist/common-auth-embedded/claustrum/custody.d.ts +16 -4
  7. package/dist/common-auth-embedded/claustrum/custody.js +74 -9
  8. package/dist/common-auth-embedded/claustrum/hub.d.ts +3 -2
  9. package/dist/common-auth-embedded/claustrum/hub.js +8 -0
  10. package/dist/common-auth-embedded/claustrum/index.d.ts +1 -0
  11. package/dist/common-auth-embedded/claustrum/roster.d.ts +2 -0
  12. package/dist/common-auth-embedded/claustrum/roster.js +2 -0
  13. package/dist/common-auth-embedded/claustrum/usage.d.ts +41 -0
  14. package/dist/common-auth-embedded/claustrum/usage.js +145 -0
  15. package/dist/common-auth-embedded/commands/builtins.d.ts +5 -0
  16. package/dist/common-auth-embedded/commands/builtins.js +38 -0
  17. package/dist/common-auth-embedded/fs/private-files.d.ts +5 -0
  18. package/dist/common-auth-embedded/fs/private-files.js +7 -0
  19. package/dist/common-auth-embedded/fs/refresh-file-lock.js +142 -47
  20. package/dist/common-auth-embedded/fs/with-lock.js +2 -5
  21. package/dist/common-auth-embedded/routing/admission.d.ts +7 -2
  22. package/dist/common-auth-embedded/routing/admission.js +17 -7
  23. package/dist/common-auth-embedded/source-output.json +126 -94
  24. package/dist/common-auth-embedded/store/attribution.js +3 -2
  25. package/dist/common-auth-embedded/store/errors.d.ts +1 -1
  26. package/dist/common-auth-embedded/store/hooks.d.ts +5 -1
  27. package/dist/common-auth-embedded/store/hooks.js +20 -5
  28. package/dist/common-auth-embedded/store/index.d.ts +1 -0
  29. package/dist/common-auth-embedded/store/limits-namespace.d.ts +7 -0
  30. package/dist/common-auth-embedded/store/limits-namespace.js +34 -0
  31. package/dist/common-auth-embedded/store/pool.d.ts +10 -1
  32. package/dist/common-auth-embedded/store/pool.js +2 -0
  33. package/dist/common-auth-embedded/store/provider-state.js +5 -5
  34. package/dist/common-auth-embedded/store/refresh.js +1 -1
  35. package/dist/common-auth-embedded/store/schema.js +9 -4
  36. package/dist/common-auth-embedded/store/settings.d.ts +2 -0
  37. package/dist/common-auth-embedded/store/settings.js +11 -4
  38. package/package.json +1 -1
@@ -107,11 +107,12 @@ export interface ClaustrumHubMemberOptions<_Q = QuotaObservation> {
107
107
  logger?: ClaustrumLogger;
108
108
  }
109
109
  /** What a lease may do with the shared custody. */
110
- export type ClaustrumLeasedCustody = Pick<ClaustrumScopedCustody, 'authorize' | 'reportFailure' | 'forget' | 'keptReceiptState'>;
110
+ export type ClaustrumLeasedCustody = Pick<ClaustrumScopedCustody, 'authorize' | 'reportFailure' | 'reportUsage' | 'usageReportState' | 'forget' | 'keptReceiptState'>;
111
111
  /**
112
112
  * One acquisition of the hub's shared custody. `custody` throws `closed`
113
113
  * after `release()`, and once the connection it belongs to ended for any
114
- * reason, vault mode going off included. `release()` is idempotent.
114
+ * reason, vault mode going off included. Best-effort `reportUsage` instead
115
+ * ignores reports through an ended lease. `release()` is idempotent.
115
116
  */
116
117
  export interface CustodyLease {
117
118
  readonly custody: ClaustrumLeasedCustody;
@@ -523,6 +523,14 @@ export class ClaustrumDiscoveryHub {
523
523
  check();
524
524
  return custody.reportFailure(attempt, status, source);
525
525
  },
526
+ reportUsage: (input) => {
527
+ if (!state.released && !generation.ended)
528
+ custody.reportUsage(input);
529
+ },
530
+ usageReportState: () => {
531
+ check();
532
+ return custody.usageReportState();
533
+ },
526
534
  forget: (credentialId) => {
527
535
  check();
528
536
  custody.forget(credentialId);
@@ -12,6 +12,7 @@ export { acquireInsulaQuotaSource, type InsulaQuotaContext, type InsulaQuotaLook
12
12
  export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
13
13
  export { drainVaultQuota } from './quota-writer.js';
14
14
  export { type AccountMapper, acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, type MutateVaultRosterOptions, mutateVaultRoster, type ProjectionOptions, projectVaultRoster, type QuotaReceipt, type ReorderVaultRosterOptions, type ReorderVaultRosterResult, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, type VaultPrimary, type VaultPrimaryBinding, type VaultPrimaryUnavailableReason, type VaultRosterFile, type VaultRosterLockContext, type VaultRosterRow, type VaultRosterSeed, vaultRosterSeed, vaultRoutingRows, } from './roster.js';
15
+ export type { ClaustrumUsageReading, ClaustrumUsageReportState, } from './usage.js';
15
16
  export { type ChangeNotice, ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, type ScopedRosterWatchOptions, type ScopedWatchEnd, type ScopedWatchHandle, type ScopedWatchNoticeShape, type ScopedWatchSource, WATCH_RETRY_MS, type WatchMode, } from './watch.js';
16
17
  /** How long `connectClaustrumScopedClient` waits by default. */
17
18
  export declare const SCOPED_CONNECT_TIMEOUT_MS = 10000;
@@ -48,6 +48,8 @@ export interface VaultRosterFile {
48
48
  complete: boolean;
49
49
  /** The unusable records of an incomplete reply: a safe id and a fixed reason. */
50
50
  rejected?: SkippedVaultRecord[];
51
+ /** Opaque limits data; roster-only writers preserve even newer schemas. */
52
+ limits?: unknown;
51
53
  rows: VaultRosterRow[];
52
54
  declined: DeclinedAccount[];
53
55
  }
@@ -209,6 +209,7 @@ export function projectVaultRoster(previous, inventory, options = {}) {
209
209
  return {
210
210
  version: 1,
211
211
  view: inventory.view,
212
+ ...(previous?.limits !== undefined && { limits: previous.limits }),
212
213
  complete,
213
214
  ...(!complete && {
214
215
  rejected: inventory.skipped.map((record) => ({
@@ -319,6 +320,7 @@ function decodeRoster(value) {
319
320
  // that was used whole, so it reads as complete.
320
321
  complete: value.complete !== false,
321
322
  ...(rejected && { rejected }),
323
+ ...(value.limits !== undefined && { limits: value.limits }),
322
324
  rows,
323
325
  declined,
324
326
  };
@@ -0,0 +1,41 @@
1
+ import type { ClaustrumLogger } from './errors.js';
2
+ /** Reply quota metadata, never authorization headers or bearer material. */
3
+ export interface ClaustrumUsageReading {
4
+ provider: string;
5
+ observedAtMs: number;
6
+ headers?: Record<string, string>;
7
+ frame?: unknown;
8
+ account?: string;
9
+ }
10
+ /** Structural to also typecheck with the claustrum-client 0.6.2 peer minimum. */
11
+ export interface ScopedUsageSource {
12
+ reportUsageScoped?(input: {
13
+ credentialId: string;
14
+ reading: Record<string, unknown>;
15
+ enrollmentToken?: string;
16
+ }): Promise<void>;
17
+ }
18
+ export interface ClaustrumUsageReportState {
19
+ readonly pending: number;
20
+ readonly inFlight: boolean;
21
+ readonly dropped: number;
22
+ }
23
+ /** One queue per custody connection, shared by every hub member on it. */
24
+ export declare class ScopedUsageReporter {
25
+ #private;
26
+ private readonly options;
27
+ constructor(options: {
28
+ client: ScopedUsageSource;
29
+ owns: (credentialId: string) => boolean;
30
+ token: () => Promise<string>;
31
+ logger: ClaustrumLogger;
32
+ setTimeout: typeof setTimeout;
33
+ clearTimeout: typeof clearTimeout;
34
+ });
35
+ state(): ClaustrumUsageReportState;
36
+ report(credentialId: string, reading: ClaustrumUsageReading): void;
37
+ forget(credentialId: string): void;
38
+ /** Start the final flush; the custody's existing shutdown drain bounds it. */
39
+ close(): Promise<void> | undefined;
40
+ drop(): void;
41
+ }
@@ -0,0 +1,145 @@
1
+ import { ClaustrumCredentialError } from '@cortexkit/claustrum-client';
2
+ function plain(value) {
3
+ return (value !== null &&
4
+ typeof value === 'object' &&
5
+ (Object.getPrototypeOf(value) === Object.prototype ||
6
+ Object.getPrototypeOf(value) === null));
7
+ }
8
+ /** One queue per custody connection, shared by every hub member on it. */
9
+ export class ScopedUsageReporter {
10
+ options;
11
+ #queue = new Map();
12
+ #latest = new Map();
13
+ #errors = new Set();
14
+ #timer;
15
+ #running;
16
+ #closing = false;
17
+ #stopped = false;
18
+ #dropped = 0;
19
+ constructor(options) {
20
+ this.options = options;
21
+ }
22
+ state() {
23
+ return {
24
+ pending: this.#queue.size,
25
+ inFlight: this.#running !== undefined,
26
+ dropped: this.#dropped,
27
+ };
28
+ }
29
+ report(credentialId, reading) {
30
+ if (this.#closing || !this.options.client.reportUsageScoped)
31
+ return;
32
+ try {
33
+ if (!this.options.owns(credentialId)) {
34
+ this.#dropped++;
35
+ return;
36
+ }
37
+ if (!plain(reading) || 'credentialId' in reading || 'vouched' in reading)
38
+ throw new Error('invalid reading');
39
+ const json = JSON.stringify(reading);
40
+ if (Buffer.byteLength(json, 'utf8') > 15_360)
41
+ throw new Error('oversized reading');
42
+ // Snapshot now: a caller mutating its reply metadata later cannot change
43
+ // an already validated queued reading, including its ordering timestamp.
44
+ const copy = JSON.parse(json);
45
+ if (!plain(copy) ||
46
+ 'credentialId' in copy ||
47
+ 'vouched' in copy ||
48
+ typeof copy.observedAtMs !== 'number' ||
49
+ !Number.isFinite(copy.observedAtMs))
50
+ throw new Error('invalid reading');
51
+ if (copy.observedAtMs <= (this.#latest.get(credentialId) ?? -Infinity)) {
52
+ this.#dropped++;
53
+ return;
54
+ }
55
+ if (this.#queue.has(credentialId))
56
+ this.#dropped++;
57
+ this.#latest.set(credentialId, copy.observedAtMs);
58
+ this.#queue.set(credentialId, copy);
59
+ if (!this.#timer && !this.#running) {
60
+ this.#timer = this.options.setTimeout(() => {
61
+ this.#timer = undefined;
62
+ this.#flush();
63
+ }, 1_000);
64
+ this.#timer.unref?.();
65
+ }
66
+ }
67
+ catch {
68
+ this.#dropped++;
69
+ }
70
+ }
71
+ forget(credentialId) {
72
+ if (this.#queue.delete(credentialId))
73
+ this.#dropped++;
74
+ this.#latest.delete(credentialId);
75
+ }
76
+ #flush() {
77
+ if (this.#running || !this.#queue.size || this.#stopped)
78
+ return this.#running;
79
+ // Start on a microtask even during close, so all failures stay off the
80
+ // provider request path and only one send can be in flight on this client.
81
+ const running = Promise.resolve().then(async () => {
82
+ while (!this.#stopped && this.#queue.size) {
83
+ const entry = this.#queue.entries().next().value;
84
+ if (!entry)
85
+ break;
86
+ const [credentialId, reading] = entry;
87
+ this.#queue.delete(credentialId);
88
+ try {
89
+ const enrollmentToken = await this.options.token();
90
+ if (this.#stopped || !this.options.owns(credentialId)) {
91
+ if (!this.#stopped)
92
+ this.#dropped++;
93
+ continue;
94
+ }
95
+ await this.options.client.reportUsageScoped?.({
96
+ credentialId,
97
+ reading,
98
+ enrollmentToken,
99
+ });
100
+ }
101
+ catch (error) {
102
+ if (!this.#stopped)
103
+ this.#dropped++;
104
+ this.#log(error);
105
+ }
106
+ }
107
+ });
108
+ this.#running = running;
109
+ void running.finally(() => {
110
+ this.#running = undefined;
111
+ });
112
+ return running;
113
+ }
114
+ #log(error) {
115
+ // Only codes from credential refusals are exposed. Transport messages,
116
+ // arbitrary thrown objects and reply contents can contain secrets.
117
+ let code = 'transport_error';
118
+ try {
119
+ if (error instanceof ClaustrumCredentialError)
120
+ code = /^[a-z][a-z0-9_]{0,63}$/.test(error.code) ? error.code : code;
121
+ if (this.#errors.has(code))
122
+ return;
123
+ this.#errors.add(code);
124
+ this.options.logger.debug('claustrum usage report dropped', { code });
125
+ }
126
+ catch {
127
+ // A caller's logger must not turn best-effort reporting into a rejection.
128
+ }
129
+ }
130
+ /** Start the final flush; the custody's existing shutdown drain bounds it. */
131
+ close() {
132
+ this.#closing = true;
133
+ if (this.#timer)
134
+ this.options.clearTimeout(this.#timer);
135
+ this.#timer = undefined;
136
+ return this.#flush();
137
+ }
138
+ drop() {
139
+ this.#stopped = true;
140
+ this.#dropped += this.#queue.size + (this.#running ? 1 : 0);
141
+ this.#running = undefined;
142
+ this.#queue.clear();
143
+ this.#latest.clear();
144
+ }
145
+ }
@@ -41,6 +41,11 @@ export interface QuotaSectionOptions {
41
41
  check?(ids: readonly string[], invocation: CommandInvocation): Promise<void>;
42
42
  }
43
43
  export interface RoutingSectionOptions {
44
+ /**
45
+ * Show how long the plugin may wait for the current account's limit to
46
+ * reset before switching. Opt in only when the plugin supports that policy.
47
+ */
48
+ sameAccountWait?: boolean;
44
49
  /**
45
50
  * Further `routing.mode` values the plugin routes as `ordered` with the
46
51
  * former main row moved (`main-first`, `fallback-first`), offered between
@@ -468,6 +468,44 @@ function routingSection(options, snap) {
468
468
  : `Order set to ${result.ids.map(nameOf).join(', ')}.`;
469
469
  },
470
470
  });
471
+ if (options.routing?.sameAccountWait) {
472
+ const waitLabel = 'Longest wait on the same account';
473
+ const waitChoices = [
474
+ { value: '0', label: "Don't wait" },
475
+ { value: '15', label: '15 minutes' },
476
+ { value: 'max', label: 'Until the cache expires' },
477
+ ];
478
+ const rawWait = record(snap.settings.routing).maxSameAccountWait;
479
+ const wait = String(rawWait === 0 || rawWait === 'max' ? rawWait : 15);
480
+ const waitName = (value) => waitChoices.find((choice) => choice.value === value)?.label ?? value;
481
+ lines.push(`${waitLabel}: ${waitName(wait)}`);
482
+ actions.push({
483
+ id: 'maxSameAccountWait',
484
+ label: waitLabel,
485
+ group: ACTIONS_GROUP,
486
+ knobs: [
487
+ {
488
+ kind: 'choice',
489
+ id: 'maxSameAccountWait',
490
+ label: waitLabel,
491
+ choices: waitChoices,
492
+ value: wait,
493
+ },
494
+ ],
495
+ run: async ({ values }) => {
496
+ const value = String(values.maxSameAccountWait);
497
+ const maxSameAccountWait = value === 'max' ? 'max' : Number(value);
498
+ await store.updateSettings((settings) => {
499
+ settings.routing = {
500
+ ...record(settings.routing),
501
+ maxSameAccountWait,
502
+ };
503
+ return undefined;
504
+ }, locks);
505
+ return `${waitLabel} set to ${waitName(value)}.`;
506
+ },
507
+ });
508
+ }
471
509
  return {
472
510
  id: 'routing',
473
511
  slot: 'routing',
@@ -21,6 +21,11 @@ export declare class PrivateFileError extends Error {
21
21
  orphanPath?: string;
22
22
  constructor(path: string, stage: string, cause: unknown);
23
23
  }
24
+ /** Native sharing and delete-pending errors reveal neither ownership nor expiry. */
25
+ export declare function isWindowsSharingViolation(error: unknown): error is PrivateFileError & {
26
+ reason: 'io-error';
27
+ win32Code: 32 | 303;
28
+ };
24
29
  export declare function privateOperation<T>(path: string, stage: string, operation: () => Promise<T>): Promise<T>;
25
30
  /** Tests only: policy options used by safe-parent checks that pass none. */
26
31
  export declare function setSafeParentOptionsForTest(options?: PosixOwnerOnlyPathAsyncOptions): void;
@@ -93,6 +93,13 @@ export class PrivateFileError extends Error {
93
93
  this.reason = detail?.reason;
94
94
  }
95
95
  }
96
+ /** Native sharing and delete-pending errors reveal neither ownership nor expiry. */
97
+ export function isWindowsSharingViolation(error) {
98
+ return (windowsPrivateFiles &&
99
+ error instanceof PrivateFileError &&
100
+ error.reason === 'io-error' &&
101
+ (error.win32Code === 32 || error.win32Code === 303));
102
+ }
96
103
  export async function privateOperation(path, stage, operation) {
97
104
  try {
98
105
  return await operation();
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { lstat, mkdir, open, readFile, rename, rm, stat, } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
- import { createPrivateBytes, createPrivateDirectoryExclusive, ensurePrivateDirectory, PrivateFileError, privateOperation, readPrivateText, verifyPrivate, windowsPrivateFiles, writePrivateAtomic, } from './private-files.js';
4
+ import { createPrivateBytes, createPrivateDirectoryExclusive, ensurePrivateDirectory, isWindowsSharingViolation, PrivateFileError, privateOperation, readPrivateText, verifyPrivate, windowsPrivateFiles, writePrivateAtomic, } from './private-files.js';
5
5
  import { LockOwnershipError, lockPathFor } from './with-lock.js';
6
6
  /**
7
7
  * Create a new owner-only file and write `text` into it, failing with EEXIST if
@@ -367,54 +367,109 @@ export async function acquireRefreshFileLockWithClock(options) {
367
367
  else
368
368
  await rm(path, { recursive: true, force: true }).catch(() => { });
369
369
  }
370
- async function releaseEvictionMarker() {
371
- if (await ownsEvictionMarker()) {
372
- await removeLeaseObject(evictPath);
370
+ function isRetryableRemoval(error, path) {
371
+ if (!windowsPrivateFiles ||
372
+ !(error instanceof PrivateFileError) ||
373
+ error.stage !== 'remove' ||
374
+ error.path !== path)
375
+ return false;
376
+ // Native sharing/delete-pending errors use isWindowsSharingViolation.
377
+ // node:fs rm supplies only errno: EBUSY/EPERM can also mean a sharing refusal.
378
+ return (isWindowsSharingViolation(error) ||
379
+ (error.win32Code === undefined &&
380
+ error.reason === undefined &&
381
+ (error.code === 'EBUSY' || error.code === 'EPERM')));
382
+ }
383
+ async function removeOwnedLeaseObject(path, stillOwned, deadline = performance.now() + EVICT_TTL + 1_000) {
384
+ let original;
385
+ let delayMs = 5;
386
+ while (await stillOwned()) {
387
+ try {
388
+ await removeLeaseObject(path);
389
+ return;
390
+ }
391
+ catch (error) {
392
+ if (!isRetryableRemoval(error, path))
393
+ throw error;
394
+ original ??= error;
395
+ }
396
+ const remainingMs = deadline - performance.now();
397
+ if (remainingMs <= 0)
398
+ throw original;
399
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)));
400
+ if (performance.now() >= deadline)
401
+ throw original;
402
+ delayMs = Math.min(delayMs * 2, 50);
373
403
  }
374
404
  }
405
+ async function releaseEvictionMarker(deadline) {
406
+ let ownedDirectory;
407
+ await removeOwnedLeaseObject(evictPath, async () => {
408
+ if (!windowsPrivateFiles)
409
+ return ownsEvictionMarker();
410
+ const current = await lstat(evictPath).catch((error) => {
411
+ if (error.code !== 'ENOENT')
412
+ throw error;
413
+ return undefined;
414
+ });
415
+ if (!current ||
416
+ (ownedDirectory && !sameMarkerDirectory(ownedDirectory, current)))
417
+ return false;
418
+ if (await ownsEvictionMarker()) {
419
+ ownedDirectory = current;
420
+ return true;
421
+ }
422
+ if (!ownedDirectory)
423
+ return false;
424
+ // Recursive rm can delete owner.json before the directory delete fails.
425
+ // Only that same, now-ownerless directory is still ours to clean up.
426
+ try {
427
+ await stat(evictOwnerPath);
428
+ return false;
429
+ }
430
+ catch (error) {
431
+ if (error.code !== 'ENOENT')
432
+ throw error;
433
+ return true;
434
+ }
435
+ }, deadline);
436
+ }
375
437
  // Returns whether the directory at the marker path was still the one this
376
438
  // process created when its owner write failed. Only then is the failure ours
377
439
  // to report: a gone or replaced directory is a lost race.
378
- async function removeOwnMarkerAfterFailedOwnerWrite(created) {
440
+ async function removeOwnMarkerAfterFailedOwnerWrite(created, deadline) {
379
441
  // Best effort only: never throws, so the original owner-write error below
380
442
  // keeps explaining the failure. Removes the directory this process just
381
443
  // created when its owner write failed, and leaves a contender's
382
444
  // replacement alone when the identities differ.
383
445
  if (!created)
384
446
  return false;
385
- let current;
386
- try {
387
- const st = await lstat(evictPath);
388
- current = { dev: st.dev, ino: st.ino, birthtimeMs: st.birthtimeMs };
389
- }
390
- catch {
391
- // Renamed away by a contender's steal: nothing of ours left to clean.
392
- return false;
393
- }
394
- if (!sameMarkerDirectory(created, current))
395
- return false;
396
- // Remove only an ownerless directory. An owner file that appeared after
397
- // our failed write belongs to someone: ours goes through
398
- // releaseEvictionMarker in the caller, and a contender's must stay, since
399
- // it may be holding the marker right now.
447
+ let stillOurs = false;
400
448
  try {
401
- await stat(evictOwnerPath);
402
- return true;
403
- }
404
- catch (error) {
405
- if (error.code !== 'ENOENT')
406
- return true;
407
- }
408
- try {
409
- await removeLeaseObject(evictPath);
449
+ await removeOwnedLeaseObject(evictPath, async () => {
450
+ stillOurs = false;
451
+ const st = await lstat(evictPath).catch(() => undefined);
452
+ if (!st || !sameMarkerDirectory(created, st))
453
+ return false;
454
+ stillOurs = true;
455
+ // Recheck both identity and absence of an owner before every attempt;
456
+ // a contender's replacement or newly published owner must stay.
457
+ try {
458
+ await stat(evictOwnerPath);
459
+ return false;
460
+ }
461
+ catch (error) {
462
+ return error.code === 'ENOENT';
463
+ }
464
+ }, deadline);
410
465
  }
411
466
  catch {
412
467
  // A removal failure keeps the stale marker recoverable later; it must
413
468
  // never mask the original owner-write error.
414
469
  }
415
- return true;
470
+ return stillOurs;
416
471
  }
417
- async function tryAcquireEvictionMarker() {
472
+ async function tryAcquireEvictionMarker(deadline) {
418
473
  if (windowsPrivateFiles)
419
474
  await createPrivateDirectoryExclusive(evictPath);
420
475
  else
@@ -455,11 +510,11 @@ export async function acquireRefreshFileLockWithClock(options) {
455
510
  // A race code while our own directory is still in place is not a lost
456
511
  // race but a write that fails here every time: report it, rather than
457
512
  // retrying the same failure until a deadline hides the cause.
458
- const stillOurs = await removeOwnMarkerAfterFailedOwnerWrite(created);
513
+ const stillOurs = await removeOwnMarkerAfterFailedOwnerWrite(created, deadline);
459
514
  if (isLostMarkerRaceError(error) && !stillOurs)
460
515
  return false;
461
516
  try {
462
- await releaseEvictionMarker();
517
+ await releaseEvictionMarker(deadline);
463
518
  }
464
519
  catch {
465
520
  // Removal must never mask the original owner-write error.
@@ -470,7 +525,7 @@ export async function acquireRefreshFileLockWithClock(options) {
470
525
  await options.onStep('eviction-marker-acquired');
471
526
  return true;
472
527
  }
473
- async function recoverStaleEvictionMarker() {
528
+ async function recoverStaleEvictionMarker(deadline) {
474
529
  let evictStat;
475
530
  try {
476
531
  evictStat = await stat(evictPath);
@@ -529,12 +584,26 @@ export async function acquireRefreshFileLockWithClock(options) {
529
584
  }
530
585
  if (options.onStep)
531
586
  await options.onStep('stale-marker-claimed');
532
- await removeLeaseObject(claimedPath);
587
+ const claimedIdentity = windowsPrivateFiles
588
+ ? await lstat(claimedPath)
589
+ : undefined;
590
+ // The unique renamed claim is ours, not the current marker path where a
591
+ // successor may already be working.
592
+ await removeOwnedLeaseObject(claimedPath, async () => {
593
+ if (!claimedIdentity)
594
+ return true;
595
+ const current = await lstat(claimedPath).catch((error) => {
596
+ if (error.code !== 'ENOENT')
597
+ throw error;
598
+ return undefined;
599
+ });
600
+ return !!current && sameMarkerDirectory(claimedIdentity, current);
601
+ }, deadline);
533
602
  return 'recovered';
534
603
  }
535
- async function withEvictionMarker(action) {
604
+ async function withEvictionMarker(action, deadline) {
536
605
  try {
537
- if (!(await tryAcquireEvictionMarker()))
606
+ if (!(await tryAcquireEvictionMarker(deadline)))
538
607
  return false;
539
608
  }
540
609
  catch (error) {
@@ -555,12 +624,28 @@ export async function acquireRefreshFileLockWithClock(options) {
555
624
  }
556
625
  throw error;
557
626
  }
627
+ let actionFailed = false;
628
+ let actionError;
558
629
  try {
559
630
  await action();
560
631
  }
561
- finally {
562
- await releaseEvictionMarker();
632
+ catch (error) {
633
+ actionFailed = true;
634
+ actionError = error;
635
+ }
636
+ try {
637
+ await releaseEvictionMarker(deadline);
638
+ }
639
+ catch (error) {
640
+ // Cleanup sharing failures must not replace the lock's original
641
+ // sharing refusal when both removals exhaust the same deadline.
642
+ if (isRetryableRemoval(actionError, lockPath) &&
643
+ isRetryableRemoval(error, evictPath))
644
+ throw actionError;
645
+ throw error;
563
646
  }
647
+ if (actionFailed)
648
+ throw actionError;
564
649
  return true;
565
650
  }
566
651
  // Marker loss after a write may mean another contender has taken over.
@@ -847,6 +932,7 @@ export async function acquireRefreshFileLockWithClock(options) {
847
932
  // wall-clock adjustments cannot extend the retry deadline.
848
933
  const deadline = performance.now() + EVICT_TTL + 1_000;
849
934
  let delayMs = 5;
935
+ let removalError;
850
936
  for (;;) {
851
937
  try {
852
938
  let removalConfirmed = false;
@@ -873,7 +959,7 @@ export async function acquireRefreshFileLockWithClock(options) {
873
959
  return;
874
960
  await removeLeaseObject(lockPath);
875
961
  removalConfirmed = true;
876
- });
962
+ }, deadline);
877
963
  if (markerAcquired) {
878
964
  if (windowsPrivateFiles && !removalConfirmed)
879
965
  throw new PrivateFileError(lockPath, 'release-not-confirmed', {
@@ -884,25 +970,34 @@ export async function acquireRefreshFileLockWithClock(options) {
884
970
  }
885
971
  if (signal?.aborted)
886
972
  return;
887
- await recoverStaleEvictionMarker();
973
+ await recoverStaleEvictionMarker(deadline);
888
974
  }
889
975
  catch (error) {
890
- if (windowsPrivateFiles)
891
- throw error;
892
- return;
976
+ if (!isRetryableRemoval(error, lockPath)) {
977
+ if (windowsPrivateFiles)
978
+ throw error;
979
+ return;
980
+ }
981
+ // Like rename, a fenced delete can be refused by a reader's Windows
982
+ // handle without invalidating ownership. Drop the marker, back off,
983
+ // and re-confirm the owner under a new marker before trying again.
984
+ removalError ??= error;
893
985
  }
894
986
  const remainingMs = deadline - performance.now();
895
987
  if (remainingMs <= 0)
896
988
  break;
897
989
  await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)));
990
+ if (windowsPrivateFiles && performance.now() >= deadline)
991
+ break;
898
992
  delayMs = Math.min(delayMs * 2, 50);
899
993
  }
900
994
  // Do not delete by pathname without the marker: bounded retries leave the
901
995
  // lease to expire rather than risking removal of a successor's lock.
902
996
  if (windowsPrivateFiles)
903
- throw new PrivateFileError(lockPath, 'release-not-confirmed', {
904
- code: 'ETIMEDOUT',
905
- });
997
+ throw (removalError ??
998
+ new PrivateFileError(lockPath, 'release-not-confirmed', {
999
+ code: 'ETIMEDOUT',
1000
+ }));
906
1001
  },
907
1002
  };
908
1003
  }
@@ -1,5 +1,5 @@
1
1
  import { join, resolve } from 'node:path';
2
- import { PrivateFileError, windowsPrivateFiles } from './private-files.js';
2
+ import { isWindowsSharingViolation, } from './private-files.js';
3
3
  import { acquireRefreshFileLock } from './refresh-file-lock.js';
4
4
  export function lockPathFor(target, name) {
5
5
  return `${target}.${name}.lock`;
@@ -21,10 +21,7 @@ export class LockOwnershipError extends Error {
21
21
  }
22
22
  }
23
23
  function isChangingLockRead(error, target, name) {
24
- if (!windowsPrivateFiles ||
25
- !(error instanceof PrivateFileError) ||
26
- error.reason !== 'io-error' ||
27
- (error.win32Code !== 32 && error.win32Code !== 303) ||
24
+ if (!isWindowsSharingViolation(error) ||
28
25
  (error.stage !== 'read' && error.stage !== 'verify'))
29
26
  return false;
30
27
  const path = resolve(lockPathFor(target, name));