@cortexkit/antigravity-auth-core 3.0.29 → 3.0.31

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 (46) hide show
  1. package/dist/agy-request-metadata.d.ts.map +1 -1
  2. package/dist/agy-request-metadata.js +3 -0
  3. package/dist/agy-request-metadata.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 +4 -0
  12. package/dist/common-auth-embedded/claustrum/roster.js +34 -8
  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/file-read-cache.js +10 -5
  18. package/dist/common-auth-embedded/fs/private-files.d.ts +5 -0
  19. package/dist/common-auth-embedded/fs/private-files.js +7 -0
  20. package/dist/common-auth-embedded/fs/refresh-file-lock.js +142 -47
  21. package/dist/common-auth-embedded/fs/with-lock.js +2 -5
  22. package/dist/common-auth-embedded/routing/admission.d.ts +7 -2
  23. package/dist/common-auth-embedded/routing/admission.js +17 -7
  24. package/dist/common-auth-embedded/source-output.json +129 -97
  25. package/dist/common-auth-embedded/store/attribution.js +3 -2
  26. package/dist/common-auth-embedded/store/errors.d.ts +1 -1
  27. package/dist/common-auth-embedded/store/hooks.d.ts +5 -1
  28. package/dist/common-auth-embedded/store/hooks.js +20 -5
  29. package/dist/common-auth-embedded/store/index.d.ts +1 -0
  30. package/dist/common-auth-embedded/store/limits-namespace.d.ts +13 -0
  31. package/dist/common-auth-embedded/store/limits-namespace.js +46 -0
  32. package/dist/common-auth-embedded/store/pool.d.ts +10 -1
  33. package/dist/common-auth-embedded/store/pool.js +2 -0
  34. package/dist/common-auth-embedded/store/provider-state.js +5 -5
  35. package/dist/common-auth-embedded/store/refresh.js +1 -1
  36. package/dist/common-auth-embedded/store/schema.js +9 -4
  37. package/dist/common-auth-embedded/store/settings.d.ts +2 -0
  38. package/dist/common-auth-embedded/store/settings.js +11 -4
  39. package/dist/model-registry.d.ts +1 -1
  40. package/dist/model-registry.d.ts.map +1 -1
  41. package/dist/model-registry.js +7 -41
  42. package/dist/model-registry.js.map +1 -1
  43. package/dist/transform/model-resolver.d.ts.map +1 -1
  44. package/dist/transform/model-resolver.js +6 -4
  45. package/dist/transform/model-resolver.js.map +1 -1
  46. package/package.json +2 -2
@@ -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',
@@ -1,9 +1,10 @@
1
- import { lstat } from 'node:fs/promises';
1
+ import { lstat, stat } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
- async function identity(path) {
3
+ async function identity(path, followSymlinks) {
4
4
  try {
5
- const metadata = await lstat(path);
6
- // Do not follow a symlink: its target can change without changing the link.
5
+ // Match the reader: private readers reject links, while readFile follows
6
+ // them and needs the target's identity, not the link's identity.
7
+ const metadata = await (followSymlinks ? stat(path) : lstat(path));
7
8
  return metadata.isFile() ? metadata : undefined;
8
9
  }
9
10
  catch (error) {
@@ -23,11 +24,15 @@ function sameIdentity(left, right) {
23
24
  }
24
25
  /** Process-local reads, capped at 64 path sets with one value per resolved set. */
25
26
  export class FileReadCache {
27
+ #followSymlinks;
26
28
  #entries = new Map();
29
+ constructor(options = {}) {
30
+ this.#followSymlinks = options.followSymlinks ?? false;
31
+ }
27
32
  async read(paths, read, validateHit) {
28
33
  const resolvedPaths = paths.map((path) => resolve(path));
29
34
  const key = JSON.stringify(resolvedPaths);
30
- const probed = await Promise.all(resolvedPaths.map(identity));
35
+ const probed = await Promise.all(resolvedPaths.map((path) => identity(path, this.#followSymlinks)));
31
36
  const identities = probed.every((entry) => entry !== undefined)
32
37
  ? probed
33
38
  : undefined;
@@ -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));
@@ -23,11 +23,16 @@ export interface AdmissionInput extends ExclusionInputs {
23
23
  scope?: string;
24
24
  /** Labels the scope requires an entry for; defaults to `['primary']`. */
25
25
  requiredLabels?: readonly string[];
26
+ /**
27
+ * One-dispatch exemptions keyed by row; waive only gate 4. The caller first
28
+ * validates the current account incarnation and checkpointed reset times.
29
+ */
30
+ liftedWindows?: ReadonlyMap<string, readonly WindowRef[]>;
26
31
  now: number;
27
32
  /**
28
- * Called synchronously, once per refusal at gates 2 to 4, with the row id.
33
+ * Called synchronously once per row needing a pull, including lifted windows.
29
34
  * Its return value is ignored and never awaited, so a pull the caller
30
- * starts from here cannot delay the refusal.
35
+ * starts from here cannot delay admission or a refusal.
31
36
  */
32
37
  requestPull?: (rowId: string) => void;
33
38
  }
@@ -14,7 +14,7 @@
14
14
  // 3. a required label with no entry is unknown: refused, pull requested;
15
15
  // 4. a reading at or beyond 100% whose reset is missing, unparsable or not
16
16
  // after `now` cannot confirm the exhaustion, so the window is unknown:
17
- // refused, pull requested;
17
+ // refused unless lifted for this wake dispatch; either way, pull requested;
18
18
  // 5. a reading at or beyond 100% with a future reset is exhausted: refused.
19
19
  // Gates 4 and 5 look at every projected limit, not only the required labels.
20
20
  // Stage 2 judges the credit budget across the stage-1 survivors: a row whose
@@ -36,7 +36,7 @@ export function exclusionFor(id, inputs, now) {
36
36
  }
37
37
  return undefined;
38
38
  }
39
- function judgeStageOne(id, projection, requiredLabels, now) {
39
+ function judgeStageOne(id, projection, requiredLabels, now, liftedWindows) {
40
40
  if (projection.limits.length === 0) {
41
41
  return {
42
42
  id,
@@ -60,7 +60,8 @@ function judgeStageOne(id, projection, requiredLabels, now) {
60
60
  }
61
61
  const exhausted = projection.limits.filter(readsExhausted);
62
62
  for (const limit of exhausted) {
63
- if (futureResetAt(limit, now) === undefined) {
63
+ if (futureResetAt(limit, now) === undefined &&
64
+ !liftedWindows.some((window) => window.scope === limit.scope && window.label === limit.label)) {
64
65
  return {
65
66
  id,
66
67
  stage: 1,
@@ -94,6 +95,7 @@ export function admit(input) {
94
95
  const excluded = [];
95
96
  const refused = [];
96
97
  const survivors = [];
98
+ const needsPull = new Set();
97
99
  for (const row of input.rows) {
98
100
  const exclusion = exclusionFor(row.id, input, input.now);
99
101
  if (exclusion) {
@@ -105,7 +107,12 @@ export function admit(input) {
105
107
  continue;
106
108
  }
107
109
  const projection = projectQuota(row.quota, scope);
108
- const refusal = judgeStageOne(row.id, projection, requiredLabels, input.now);
110
+ const lifted = input.liftedWindows?.get(row.id) ?? [];
111
+ if (projection.limits.some((limit) => readsExhausted(limit) &&
112
+ futureResetAt(limit, input.now) === undefined &&
113
+ lifted.some((window) => window.scope === limit.scope && window.label === limit.label)))
114
+ needsPull.add(row.id);
115
+ const refusal = judgeStageOne(row.id, projection, requiredLabels, input.now, lifted);
109
116
  if (refusal) {
110
117
  refused.push(refusal);
111
118
  continue;
@@ -133,9 +140,12 @@ export function admit(input) {
133
140
  const order = new Map(input.rows.map((row, index) => [row.id, index]));
134
141
  const byInput = (left, right) => (order.get(left.id) ?? 0) - (order.get(right.id) ?? 0);
135
142
  refused.sort(byInput);
136
- const pulls = refused
137
- .filter((refusal) => 'pullRequested' in refusal)
138
- .map((refusal) => refusal.id);
143
+ for (const refusal of refused)
144
+ if ('pullRequested' in refusal)
145
+ needsPull.add(refusal.id);
146
+ const pulls = input.rows
147
+ .filter((row) => needsPull.has(row.id))
148
+ .map((row) => row.id);
139
149
  for (const id of pulls)
140
150
  input.requestPull?.(id);
141
151
  return { admitted, refused, excluded, pulls };