@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.
- package/dist/account-selector.d.ts.map +1 -1
- package/dist/account-selector.js +26 -3
- package/dist/account-selector.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +12 -0
- package/dist/common-auth-embedded/claustrum/consumer.js +36 -0
- package/dist/common-auth-embedded/claustrum/custody.d.ts +16 -4
- package/dist/common-auth-embedded/claustrum/custody.js +74 -9
- package/dist/common-auth-embedded/claustrum/hub.d.ts +3 -2
- package/dist/common-auth-embedded/claustrum/hub.js +8 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +1 -0
- package/dist/common-auth-embedded/claustrum/roster.d.ts +2 -0
- package/dist/common-auth-embedded/claustrum/roster.js +2 -0
- package/dist/common-auth-embedded/claustrum/usage.d.ts +41 -0
- package/dist/common-auth-embedded/claustrum/usage.js +145 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +5 -0
- package/dist/common-auth-embedded/commands/builtins.js +38 -0
- package/dist/common-auth-embedded/fs/private-files.d.ts +5 -0
- package/dist/common-auth-embedded/fs/private-files.js +7 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +142 -47
- package/dist/common-auth-embedded/fs/with-lock.js +2 -5
- package/dist/common-auth-embedded/routing/admission.d.ts +7 -2
- package/dist/common-auth-embedded/routing/admission.js +17 -7
- package/dist/common-auth-embedded/source-output.json +126 -94
- package/dist/common-auth-embedded/store/attribution.js +3 -2
- package/dist/common-auth-embedded/store/errors.d.ts +1 -1
- package/dist/common-auth-embedded/store/hooks.d.ts +5 -1
- package/dist/common-auth-embedded/store/hooks.js +20 -5
- package/dist/common-auth-embedded/store/index.d.ts +1 -0
- package/dist/common-auth-embedded/store/limits-namespace.d.ts +7 -0
- package/dist/common-auth-embedded/store/limits-namespace.js +34 -0
- package/dist/common-auth-embedded/store/pool.d.ts +10 -1
- package/dist/common-auth-embedded/store/pool.js +2 -0
- package/dist/common-auth-embedded/store/provider-state.js +5 -5
- package/dist/common-auth-embedded/store/refresh.js +1 -1
- package/dist/common-auth-embedded/store/schema.js +9 -4
- package/dist/common-auth-embedded/store/settings.d.ts +2 -0
- package/dist/common-auth-embedded/store/settings.js +11 -4
- 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. `
|
|
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
|
-
|
|
371
|
-
if (
|
|
372
|
-
|
|
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
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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 (
|
|
891
|
-
|
|
892
|
-
|
|
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
|
|
904
|
-
|
|
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 {
|
|
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 (!
|
|
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));
|