@cortexkit/antigravity-auth-core 3.0.12 → 3.0.13
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 +9 -1
- package/dist/account-selector.d.ts.map +1 -1
- package/dist/account-selector.js +23 -8
- package/dist/account-selector.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +77 -2
- package/dist/common-auth-embedded/claustrum/consumer.js +320 -35
- package/dist/common-auth-embedded/claustrum/custody.d.ts +36 -1
- package/dist/common-auth-embedded/claustrum/custody.js +95 -11
- package/dist/common-auth-embedded/claustrum/enrollment.js +37 -12
- package/dist/common-auth-embedded/claustrum/hub.d.ts +195 -0
- package/dist/common-auth-embedded/claustrum/hub.js +988 -0
- package/dist/common-auth-embedded/claustrum/index.d.ts +13 -3
- package/dist/common-auth-embedded/claustrum/index.js +37 -4
- package/dist/common-auth-embedded/claustrum/quota-writer.d.ts +8 -1
- package/dist/common-auth-embedded/claustrum/quota-writer.js +34 -12
- package/dist/common-auth-embedded/claustrum/roster.d.ts +49 -1
- package/dist/common-auth-embedded/claustrum/roster.js +187 -12
- package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
- package/dist/common-auth-embedded/fs/file-read-cache.js +57 -0
- package/dist/common-auth-embedded/fs/refresh-file-lock.d.ts +1 -1
- package/dist/common-auth-embedded/fs/refresh-file-lock.js +20 -11
- package/dist/common-auth-embedded/quota/commit-interval.d.ts +9 -0
- package/dist/common-auth-embedded/quota/commit-interval.js +18 -0
- package/dist/common-auth-embedded/source-output.json +115 -67
- package/dist/common-auth-embedded/store/mutate.d.ts +2 -0
- package/dist/common-auth-embedded/store/mutate.js +4 -0
- package/dist/common-auth-embedded/store/pool.d.ts +6 -1
- package/dist/common-auth-embedded/store/pool.js +1 -1
- package/dist/common-auth-embedded/store/quota-writer.js +27 -13
- package/dist/common-auth-embedded/store/settings.d.ts +1 -1
- package/dist/common-auth-embedded/store/settings.js +16 -6
- package/dist/model-registry.d.ts +2 -2
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js +6 -4
- package/dist/model-registry.js.map +1 -1
- package/dist/quota-manager.d.ts +1 -1
- package/dist/quota-manager.d.ts.map +1 -1
- package/dist/quota-manager.js +12 -4
- package/dist/quota-manager.js.map +1 -1
- package/dist/quota-presentation.d.ts.map +1 -1
- package/dist/quota-presentation.js +8 -0
- package/dist/quota-presentation.js.map +1 -1
- package/dist/quota-types.d.ts +1 -1
- package/dist/quota-types.d.ts.map +1 -1
- package/dist/quota-types.js +5 -0
- package/dist/quota-types.js.map +1 -1
- package/dist/vault-account-source.d.ts +6 -1
- package/dist/vault-account-source.d.ts.map +1 -1
- package/dist/vault-account-source.js +9 -0
- package/dist/vault-account-source.js.map +1 -1
- package/package.json +1 -1
|
@@ -2,21 +2,31 @@ import { type ClaustrumClientOptions } from '@cortexkit/claustrum-client';
|
|
|
2
2
|
import type { ClaustrumScopedClient } from './custody.js';
|
|
3
3
|
export { ClaustrumCredentialError, type ClaustrumReporterSource, type EnrollmentTokenFile, } from '@cortexkit/claustrum-client';
|
|
4
4
|
export { ClaustrumUnreachableError, classifyVaultFailure, VAULT_FAILURE_CLASSES, type VaultFailureClass, } from './availability.js';
|
|
5
|
-
export { ClaustrumConsumer, type ClaustrumConsumerOptions, type ClaustrumWatchState, type SendOptions, } from './consumer.js';
|
|
6
|
-
export { type AccountIdentitySource, type ClaustrumFamily, type ClaustrumRosterWatch, type ClaustrumRosterWatchOptions, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, type KeptReceiptState, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultGrantReach, type VaultGrantTuple, type VaultInventory, } from './custody.js';
|
|
5
|
+
export { ClaustrumConsumer, type ClaustrumConsumerOptions, type ClaustrumWatchState, DEFAULT_CONNECT_TIMEOUT_MS, type SendOptions, } from './consumer.js';
|
|
6
|
+
export { type AccountIdentitySource, type ClassifyingClaustrumFamily, type ClaustrumFamily, type ClaustrumFamilyInput, type ClaustrumRosterWatch, type ClaustrumRosterWatchOptions, type ClaustrumScopedAttempt, type ClaustrumScopedClient, ClaustrumScopedCustody, type ClaustrumScopedIdentity, decideScopedRetryAfter401, type IdentityParser, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, type KeptReceiptState, type ScopedRetryReason, SERVING_MARGIN_MS, type SkippedVaultReason, type SkippedVaultRecord, type VaultCredential, type VaultCredentialType, type VaultGrantReach, type VaultGrantTuple, type VaultInventory, type VaultRowFacts, } from './custody.js';
|
|
7
7
|
export { type ClaustrumEnrollmentClient, type ClaustrumEnrollmentConnection, type ClaustrumEnrollmentDisconnectResult, ClaustrumEnrollmentManager, type ClaustrumEnrollmentPaths, type ClaustrumEnrollmentResetResult, type ClaustrumEnrollmentStatus, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, type EnrollmentDisposition, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
|
|
8
8
|
export { ClaustrumConsumerError, type ClaustrumConsumerFailureKind, type ClaustrumLogger, } from './errors.js';
|
|
9
9
|
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, type HostSlotContent, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
10
|
+
export { ClaustrumDiscoveryHub, type ClaustrumDiscoveryHubOptions, type ClaustrumHubMemberOptions, type ClaustrumHubRefusals, type ClaustrumLeasedCustody, type CustodyLease, } from './hub.js';
|
|
10
11
|
export { acquireInsulaQuotaSource, type InsulaQuotaContext, type InsulaQuotaLookup, type InsulaQuotaMiss, type InsulaQuotaOptions, type InsulaQuotaReading, type InsulaQuotaResult, type InsulaQuotaSource, type InsulaUsageEntry, type InsulaUsageTransport, type InsulaUsageWatch, type InsulaUsageWindow, type InsulaWatchOptions, } from './insula.js';
|
|
11
12
|
export { acceptAccount, type DeclinedAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
12
13
|
export { drainVaultQuota } from './quota-writer.js';
|
|
13
|
-
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, vaultRoutingRows, } from './roster.js';
|
|
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';
|
|
14
15
|
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
|
+
/** How long `connectClaustrumScopedClient` waits by default. */
|
|
17
|
+
export declare const SCOPED_CONNECT_TIMEOUT_MS = 10000;
|
|
15
18
|
/**
|
|
16
19
|
* Connect the client that lists and fetches this consumer's vault credentials
|
|
17
20
|
* on the request path. `connectionFile` is required: this library
|
|
18
21
|
* reads no environment, so the plugin resolves the vault's connection file.
|
|
22
|
+
*
|
|
23
|
+
* The wait is bounded by `connectTimeoutMs` (default 10 s) so a connect that
|
|
24
|
+
* never settles cannot leave a consumer with a pending connection forever; the
|
|
25
|
+
* caller then gets an unreachable error and may try again. A socket handshake
|
|
26
|
+
* has its own limit, but local reads before it (the connection file) do not,
|
|
27
|
+
* and a client that arrives after this deadline is closed, not returned.
|
|
19
28
|
*/
|
|
20
29
|
export declare function connectClaustrumScopedClient(options: ClaustrumClientOptions & {
|
|
21
30
|
connectionFile: string;
|
|
31
|
+
connectTimeoutMs?: number;
|
|
22
32
|
}): Promise<ClaustrumScopedClient>;
|
|
@@ -4,25 +4,58 @@
|
|
|
4
4
|
// declined-account interlock and the host-slot guard. Needs the optional
|
|
5
5
|
// peer `@cortexkit/claustrum-client`.
|
|
6
6
|
import { ClaustrumClient, } from '@cortexkit/claustrum-client';
|
|
7
|
+
import { ClaustrumUnreachableError } from './availability.js';
|
|
7
8
|
import { insulaUsageTransport } from './insula.js';
|
|
8
9
|
export { ClaustrumCredentialError, } from '@cortexkit/claustrum-client';
|
|
9
10
|
export { ClaustrumUnreachableError, classifyVaultFailure, VAULT_FAILURE_CLASSES, } from './availability.js';
|
|
10
|
-
export { ClaustrumConsumer, } from './consumer.js';
|
|
11
|
+
export { ClaustrumConsumer, DEFAULT_CONNECT_TIMEOUT_MS, } from './consumer.js';
|
|
11
12
|
export { ClaustrumScopedCustody, decideScopedRetryAfter401, isScopedCredentialRotation, KEPT_RECEIPT_MARGIN_MS, SERVING_MARGIN_MS, } from './custody.js';
|
|
12
13
|
export { ClaustrumEnrollmentManager, classifyEnrollmentError, connectClaustrumEnrollmentClient, disconnectClaustrumEnrollment, enrollmentAuthority, enrollmentName, getClaustrumEnrollmentPaths, hostEnrollmentPaths, RETRYABLE_ENROLLMENT_CODES, readClaustrumEnrollmentStatus, readClaustrumEnrollmentToken, resetClaustrumEnrollmentState, TERMINAL_ENROLLMENT_CODES, } from './enrollment.js';
|
|
13
14
|
export { ClaustrumConsumerError, } from './errors.js';
|
|
14
15
|
export { assertHostSlotMatchesMode, assertNotCustodyPlaceholder, CUSTODY_PLACEHOLDER_PREFIX, classifyHostSlot, custodyPlaceholder, custodyPlaceholderKey, isCustodyPlaceholder, isCustodyPlaceholderValue, } from './host-slot.js';
|
|
16
|
+
export { ClaustrumDiscoveryHub, } from './hub.js';
|
|
15
17
|
export { acquireInsulaQuotaSource, } from './insula.js';
|
|
16
18
|
export { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
17
19
|
export { drainVaultQuota } from './quota-writer.js';
|
|
18
|
-
export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, vaultRoutingRows, } from './roster.js';
|
|
20
|
+
export { acceptVaultRoute, DEFAULT_ROUTE_PREFIX, declineVaultRoute, mutateVaultRoster, projectVaultRoster, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, reorderVaultRoster, resolveVaultPrimary, vaultRosterSeed, vaultRoutingRows, } from './roster.js';
|
|
19
21
|
export { ChangeNoticeReader, RESUBSCRIBE_BACKOFF_MS, SAFETY_POLL_MS, ScopedRosterWatch, WATCH_RETRY_MS, } from './watch.js';
|
|
22
|
+
/** How long `connectClaustrumScopedClient` waits by default. */
|
|
23
|
+
export const SCOPED_CONNECT_TIMEOUT_MS = 10_000;
|
|
20
24
|
/**
|
|
21
25
|
* Connect the client that lists and fetches this consumer's vault credentials
|
|
22
26
|
* on the request path. `connectionFile` is required: this library
|
|
23
27
|
* reads no environment, so the plugin resolves the vault's connection file.
|
|
28
|
+
*
|
|
29
|
+
* The wait is bounded by `connectTimeoutMs` (default 10 s) so a connect that
|
|
30
|
+
* never settles cannot leave a consumer with a pending connection forever; the
|
|
31
|
+
* caller then gets an unreachable error and may try again. A socket handshake
|
|
32
|
+
* has its own limit, but local reads before it (the connection file) do not,
|
|
33
|
+
* and a client that arrives after this deadline is closed, not returned.
|
|
24
34
|
*/
|
|
25
35
|
export async function connectClaustrumScopedClient(options) {
|
|
26
|
-
const client =
|
|
27
|
-
|
|
36
|
+
const { connectTimeoutMs, ...client } = options;
|
|
37
|
+
const limit = connectTimeoutMs ?? SCOPED_CONNECT_TIMEOUT_MS;
|
|
38
|
+
const connecting = ClaustrumClient.connect(client);
|
|
39
|
+
let expired = false;
|
|
40
|
+
let timer;
|
|
41
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
expired = true;
|
|
44
|
+
reject(new ClaustrumUnreachableError());
|
|
45
|
+
}, limit);
|
|
46
|
+
timer.unref?.();
|
|
47
|
+
});
|
|
48
|
+
try {
|
|
49
|
+
const client = await Promise.race([connecting, deadline]);
|
|
50
|
+
return Object.assign(client, { insula: insulaUsageTransport(options) });
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// A connection that arrives after the deadline still owns a socket.
|
|
54
|
+
void connecting.then((client) => client.close(), () => { });
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
if (!expired && timer)
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
28
61
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type QuotaCommitTimer } from '../quota/commit-interval.js';
|
|
1
2
|
import { type QuotaObservation } from '../quota/index.js';
|
|
2
3
|
import { type QuotaReceipt, type VaultRosterFile } from './roster.js';
|
|
3
4
|
export type VaultQuotaPublication = {
|
|
@@ -8,11 +9,17 @@ export type VaultQuotaPublication = {
|
|
|
8
9
|
};
|
|
9
10
|
type Listener = (publication: VaultQuotaPublication) => void;
|
|
10
11
|
/** Retain the shared writer; the last consumer's close drains it. */
|
|
11
|
-
export declare function retainVaultQuotaWriter(path: string, listener: Listener): () => Promise<void>;
|
|
12
|
+
export declare function retainVaultQuotaWriter(path: string, listener: Listener, clock?: QuotaCommitTimer): () => Promise<void>;
|
|
12
13
|
/** Enqueue without filesystem work; each quota field retains its own receipt fence. */
|
|
13
14
|
export declare function enqueueVaultQuota(path: string, publication: VaultQuotaPublication): Promise<boolean>;
|
|
14
15
|
/** Overlay uncommitted observations when a consumer reloads the durable roster. */
|
|
15
16
|
export declare function pendingVaultQuota(path: string, roster: VaultRosterFile | undefined): VaultRosterFile | undefined;
|
|
17
|
+
/**
|
|
18
|
+
* Whether this process holds quota readings for `path` that no commit of the
|
|
19
|
+
* durable roster has written yet, so a reader of the file alone is not seeing
|
|
20
|
+
* everything this process would publish.
|
|
21
|
+
*/
|
|
22
|
+
export declare function hasPendingVaultQuota(path: string): boolean;
|
|
16
23
|
/** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
|
|
17
24
|
export declare function drainVaultQuota(path: string, timeoutMs?: number): Promise<void>;
|
|
18
25
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { createLogger } from '../logger/index.js';
|
|
3
|
+
import { QUOTA_COMMIT_INTERVAL_MS, quotaCommitTimer, } from '../quota/commit-interval.js';
|
|
3
4
|
import { isQuotaObservation } from '../quota/index.js';
|
|
4
5
|
import { quotaFields } from '../quota/pending.js';
|
|
5
6
|
import { applyVaultQuota, mutateVaultRoster, } from './roster.js';
|
|
@@ -7,7 +8,7 @@ import { applyVaultQuota, mutateVaultRoster, } from './roster.js';
|
|
|
7
8
|
const writers = new Map();
|
|
8
9
|
const logger = createLogger('claustrum');
|
|
9
10
|
const COALESCE_MS = 100;
|
|
10
|
-
function writerFor(path) {
|
|
11
|
+
function writerFor(path, clock = quotaCommitTimer) {
|
|
11
12
|
const key = resolve(path);
|
|
12
13
|
let writer = writers.get(key);
|
|
13
14
|
if (!writer) {
|
|
@@ -17,6 +18,7 @@ function writerFor(path) {
|
|
|
17
18
|
listeners: new Set(),
|
|
18
19
|
wake: new Set(),
|
|
19
20
|
abort: new AbortController(),
|
|
21
|
+
clock,
|
|
20
22
|
failures: 0,
|
|
21
23
|
draining: false,
|
|
22
24
|
order: 0,
|
|
@@ -26,8 +28,8 @@ function writerFor(path) {
|
|
|
26
28
|
return writer;
|
|
27
29
|
}
|
|
28
30
|
/** Retain the shared writer; the last consumer's close drains it. */
|
|
29
|
-
export function retainVaultQuotaWriter(path, listener) {
|
|
30
|
-
const writer = writerFor(path);
|
|
31
|
+
export function retainVaultQuotaWriter(path, listener, clock) {
|
|
32
|
+
const writer = writerFor(path, clock);
|
|
31
33
|
writer.listeners.add(listener);
|
|
32
34
|
let released;
|
|
33
35
|
return () => (released ??= (() => {
|
|
@@ -93,37 +95,57 @@ export function pendingVaultQuota(path, roster) {
|
|
|
93
95
|
}
|
|
94
96
|
return roster;
|
|
95
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether this process holds quota readings for `path` that no commit of the
|
|
100
|
+
* durable roster has written yet, so a reader of the file alone is not seeing
|
|
101
|
+
* everything this process would publish.
|
|
102
|
+
*/
|
|
103
|
+
export function hasPendingVaultQuota(path) {
|
|
104
|
+
return !!writers.get(resolve(path))?.pending.size;
|
|
105
|
+
}
|
|
96
106
|
function schedule(writer, delay) {
|
|
97
107
|
if (writer.running ||
|
|
98
108
|
writer.timer ||
|
|
99
109
|
!writer.pending.size ||
|
|
100
110
|
writer.abort.signal.aborted)
|
|
101
111
|
return;
|
|
102
|
-
|
|
112
|
+
const remaining = writer.draining
|
|
113
|
+
? 0
|
|
114
|
+
: (writer.committedAt ?? -Infinity) +
|
|
115
|
+
QUOTA_COMMIT_INTERVAL_MS -
|
|
116
|
+
writer.clock.now();
|
|
117
|
+
writer.timer = writer.clock.schedule(Math.max(delay, remaining), () => {
|
|
103
118
|
writer.timer = undefined;
|
|
104
|
-
flush(writer);
|
|
105
|
-
}
|
|
119
|
+
return flush(writer);
|
|
120
|
+
});
|
|
106
121
|
if (!writer.draining)
|
|
107
122
|
writer.timer.unref?.();
|
|
108
123
|
}
|
|
124
|
+
function cancelTimer(writer) {
|
|
125
|
+
writer.timer?.cancel();
|
|
126
|
+
writer.timer = undefined;
|
|
127
|
+
}
|
|
109
128
|
function flush(writer) {
|
|
110
129
|
if (writer.running || writer.abort.signal.aborted)
|
|
111
|
-
return;
|
|
130
|
+
return writer.running ?? Promise.resolve();
|
|
112
131
|
const batch = new Map([...writer.pending].map(([account, pending]) => [
|
|
113
132
|
account,
|
|
114
133
|
new Map(pending),
|
|
115
134
|
]));
|
|
116
135
|
writer.running = (async () => {
|
|
117
136
|
try {
|
|
118
|
-
await mutateVaultRoster(writer.path, (current) => {
|
|
137
|
+
const committed = await mutateVaultRoster(writer.path, (current) => {
|
|
119
138
|
let next = current;
|
|
120
139
|
for (const field of orderedFields(batch)) {
|
|
121
140
|
if (field.priorReading)
|
|
122
141
|
next = applyVaultQuota(next, field.priorReading).roster;
|
|
123
142
|
next = applyVaultQuota(next, field.publication).roster;
|
|
124
143
|
}
|
|
125
|
-
|
|
144
|
+
const changed = JSON.stringify(next) !== JSON.stringify(current);
|
|
145
|
+
return { ...(changed && { next }), result: changed };
|
|
126
146
|
}, { signal: writer.abort.signal });
|
|
147
|
+
if (committed)
|
|
148
|
+
writer.committedAt = writer.clock.now();
|
|
127
149
|
for (const [account, fields] of batch) {
|
|
128
150
|
const pending = writer.pending.get(account);
|
|
129
151
|
for (const [key, field] of fields) {
|
|
@@ -155,6 +177,7 @@ function flush(writer) {
|
|
|
155
177
|
schedule(writer, backoff);
|
|
156
178
|
}
|
|
157
179
|
})();
|
|
180
|
+
return writer.running;
|
|
158
181
|
}
|
|
159
182
|
/** Wait at most timeoutMs for detached publication; shutdown loss is acceptable. */
|
|
160
183
|
export async function drainVaultQuota(path, timeoutMs = 1_000) {
|
|
@@ -163,8 +186,7 @@ export async function drainVaultQuota(path, timeoutMs = 1_000) {
|
|
|
163
186
|
if (!writer)
|
|
164
187
|
return;
|
|
165
188
|
writer.draining = true;
|
|
166
|
-
|
|
167
|
-
writer.timer = undefined;
|
|
189
|
+
cancelTimer(writer);
|
|
168
190
|
let deadline;
|
|
169
191
|
let wake;
|
|
170
192
|
const drained = new Promise((done) => {
|
|
@@ -194,7 +216,7 @@ export async function drainVaultQuota(path, timeoutMs = 1_000) {
|
|
|
194
216
|
// must not discard its observations or stop its retries.
|
|
195
217
|
if (!writer.listeners.size) {
|
|
196
218
|
writer.abort.abort();
|
|
197
|
-
|
|
219
|
+
cancelTimer(writer);
|
|
198
220
|
writer.pending.clear();
|
|
199
221
|
if (writers.get(key) === writer)
|
|
200
222
|
writers.delete(key);
|
|
@@ -62,7 +62,25 @@ export interface ProjectionOptions {
|
|
|
62
62
|
routePrefix?: string;
|
|
63
63
|
mapAccount?: AccountMapper;
|
|
64
64
|
now?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Preferences carried over from another roster file, used only when there
|
|
67
|
+
* is no previous roster: its declines become this roster's declines, and
|
|
68
|
+
* its account order sorts the rows this inventory produces. No row of the
|
|
69
|
+
* seed is ever projected, so a seed cannot make an account routable.
|
|
70
|
+
*/
|
|
71
|
+
seed?: VaultRosterSeed;
|
|
72
|
+
}
|
|
73
|
+
/** The preferences one roster file can pass to a first projection elsewhere. */
|
|
74
|
+
export interface VaultRosterSeed {
|
|
75
|
+
readonly declined: readonly DeclinedAccount[];
|
|
76
|
+
/** Accounts in the seed roster's order, matched by account, then by id. */
|
|
77
|
+
readonly order: readonly {
|
|
78
|
+
readonly credentialId: string;
|
|
79
|
+
readonly accountIdentity?: string;
|
|
80
|
+
}[];
|
|
65
81
|
}
|
|
82
|
+
/** Declines and account order of `roster`, and nothing else of it. */
|
|
83
|
+
export declare function vaultRosterSeed(roster: VaultRosterFile): VaultRosterSeed;
|
|
66
84
|
export declare const DEFAULT_ROUTE_PREFIX = "vault:";
|
|
67
85
|
/**
|
|
68
86
|
* Project the vault's list onto the previous roster. Pure: callers serialize
|
|
@@ -87,6 +105,12 @@ export declare function projectVaultRoster(previous: VaultRosterFile | undefined
|
|
|
87
105
|
* reaches admission.
|
|
88
106
|
*/
|
|
89
107
|
export declare function vaultRoutingRows(roster: VaultRosterFile | undefined): RoutingRow[];
|
|
108
|
+
/**
|
|
109
|
+
* `roster` as `readVaultRoster` decodes it from the file that holds it, so a
|
|
110
|
+
* committed roster and a later read of its file compare equal. Package
|
|
111
|
+
* internal: not exported from `/claustrum`.
|
|
112
|
+
*/
|
|
113
|
+
export declare function decodedVaultRoster(roster: VaultRosterFile): VaultRosterFile;
|
|
90
114
|
export declare function readVaultRoster(path: string): Promise<VaultRosterFile | undefined>;
|
|
91
115
|
/** The ownership assertion available to a roster mutation callback. */
|
|
92
116
|
export interface VaultRosterLockContext {
|
|
@@ -190,6 +214,16 @@ export declare function applyVaultQuota(current: VaultRosterFile | undefined, in
|
|
|
190
214
|
kept: boolean;
|
|
191
215
|
};
|
|
192
216
|
export declare function forgetPendingVaultDiscovery(path: string): void;
|
|
217
|
+
/**
|
|
218
|
+
* Whether `error` from a roster commit at `path` means another writer had the
|
|
219
|
+
* roster at that moment, not that the commit itself failed: the discovery
|
|
220
|
+
* lease was busy before any roster existed, the write lock stayed busy, or a
|
|
221
|
+
* lock this commit held was lost. On Windows the same lock steps can reject
|
|
222
|
+
* with a PrivateFileError instead (a lock file that could not be read or
|
|
223
|
+
* replaced while another process held it), so a PrivateFileError naming one
|
|
224
|
+
* of this roster's lock files counts the same on every OS.
|
|
225
|
+
*/
|
|
226
|
+
export declare function isVaultRosterLockRefusal(path: string, error: unknown): boolean;
|
|
193
227
|
/**
|
|
194
228
|
* Discover and commit the roster. The lease covers the list call and the
|
|
195
229
|
* commit, not just the write: an opaque view cannot tell a delayed old reply
|
|
@@ -197,7 +231,8 @@ export declare function forgetPendingVaultDiscovery(path: string): void;
|
|
|
197
231
|
* failed list, or custody switching off mid-run, never writes anything; a
|
|
198
232
|
* peer holding the lease means its last committed roster is served instead.
|
|
199
233
|
* A write-lock timeout keeps the discovered inventory for the next poll,
|
|
200
|
-
* unless a peer has superseded its base view in the meantime.
|
|
234
|
+
* unless a peer has superseded its base view in the meantime. A list that
|
|
235
|
+
* would write the file the roster already holds takes no write lock at all.
|
|
201
236
|
*/
|
|
202
237
|
export declare function refreshVaultRoster(options: {
|
|
203
238
|
path: string;
|
|
@@ -206,6 +241,19 @@ export declare function refreshVaultRoster(options: {
|
|
|
206
241
|
signal?: AbortSignal;
|
|
207
242
|
projection?: ProjectionOptions | (() => ProjectionOptions);
|
|
208
243
|
onInventory?: (inventory: VaultInventory) => void;
|
|
244
|
+
/**
|
|
245
|
+
* For a caller that listed before taking this path's discovery lease (the
|
|
246
|
+
* discovery hub lists once for several rosters): the view this roster file
|
|
247
|
+
* held before that list. `custody.discover` then returns that list. It is
|
|
248
|
+
* held to the rule a kept discovery is held to below: it is projected only
|
|
249
|
+
* while the file still holds this view or the list's own view. Otherwise
|
|
250
|
+
* another process committed since the list, and the file is returned
|
|
251
|
+
* unchanged. No discovery is kept for a later call, because such a caller
|
|
252
|
+
* lists again.
|
|
253
|
+
*/
|
|
254
|
+
listedOver?: {
|
|
255
|
+
readonly view: string | undefined;
|
|
256
|
+
};
|
|
209
257
|
}): Promise<VaultRosterFile | undefined>;
|
|
210
258
|
/**
|
|
211
259
|
* A host-owned main account as last verified from the vault: the plugin's
|
|
@@ -1,12 +1,36 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { stat } from 'node:fs/promises';
|
|
2
3
|
import { resolve } from 'node:path';
|
|
3
|
-
import { acquireRefreshFileLock, LockContentionError, withLock, writeJsonAtomic, } from '../fs/index.js';
|
|
4
|
+
import { acquireRefreshFileLock, LockContentionError, LockOwnershipError, lockPathFor, PrivateFileError, withLock, writeJsonAtomic, } from '../fs/index.js';
|
|
4
5
|
import { readPrivateText, windowsPrivateFiles } from '../fs/private-files.js';
|
|
5
6
|
import { decodeQuotaMap, isQuotaObservation, mergeQuotaObservation, } from '../quota/index.js';
|
|
6
7
|
import { ClaustrumConsumerError } from './errors.js';
|
|
7
8
|
import { acceptAccount, declineAccount, isDeclined, } from './interlock.js';
|
|
8
|
-
import { enqueueVaultQuota, pendingVaultQuota, } from './quota-writer.js';
|
|
9
|
+
import { enqueueVaultQuota, hasPendingVaultQuota, pendingVaultQuota, } from './quota-writer.js';
|
|
9
10
|
export { drainVaultQuota } from './quota-writer.js';
|
|
11
|
+
/** Declines and account order of `roster`, and nothing else of it. */
|
|
12
|
+
export function vaultRosterSeed(roster) {
|
|
13
|
+
return {
|
|
14
|
+
declined: roster.declined.map((entry) => ({ ...entry })),
|
|
15
|
+
order: roster.rows.flatMap((row) => memberIds(row).map((credentialId) => ({
|
|
16
|
+
credentialId,
|
|
17
|
+
...(row.accountIdentity !== undefined && {
|
|
18
|
+
accountIdentity: row.accountIdentity,
|
|
19
|
+
}),
|
|
20
|
+
}))),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** Where a projected row falls in the seed's order; unknown rows go last. */
|
|
24
|
+
function seedIndex(seed, row) {
|
|
25
|
+
const ids = memberIds(row);
|
|
26
|
+
const byAccount = row.accountIdentity === undefined
|
|
27
|
+
? -1
|
|
28
|
+
: seed.order.findIndex((entry) => entry.accountIdentity === row.accountIdentity);
|
|
29
|
+
const index = byAccount >= 0
|
|
30
|
+
? byAccount
|
|
31
|
+
: seed.order.findIndex((entry) => ids.includes(entry.credentialId));
|
|
32
|
+
return index >= 0 ? index : Infinity;
|
|
33
|
+
}
|
|
10
34
|
export const DEFAULT_ROUTE_PREFIX = 'vault:';
|
|
11
35
|
function hash(value) {
|
|
12
36
|
return createHash('sha256').update(value).digest('hex');
|
|
@@ -47,8 +71,9 @@ export function projectVaultRoster(previous, inventory, options = {}) {
|
|
|
47
71
|
const prefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
|
|
48
72
|
const reserved = options.reservedRouteIds ?? new Set();
|
|
49
73
|
const previousRows = previous?.rows ?? [];
|
|
74
|
+
const seed = previous ? undefined : options.seed;
|
|
50
75
|
// Declines follow the account and outlive its records, so none is pruned.
|
|
51
|
-
const declined = [...(previous?.declined ?? [])];
|
|
76
|
+
const declined = [...(previous?.declined ?? seed?.declined ?? [])];
|
|
52
77
|
const complete = inventory.skipped.length === 0;
|
|
53
78
|
const listed = new Set(inventory.credentials.map((credential) => credential.credentialId));
|
|
54
79
|
const rejectedIds = new Set(inventory.skipped.flatMap((record) => record.credentialId === undefined ? [] : [record.credentialId]));
|
|
@@ -146,6 +171,11 @@ export function projectVaultRoster(previous, inventory, options = {}) {
|
|
|
146
171
|
...(!chosen.claimed && identity !== undefined && { unclaimed: true }),
|
|
147
172
|
},
|
|
148
173
|
});
|
|
174
|
+
// With no previous roster every row is new; a seed from another key
|
|
175
|
+
// sorts them by that key's order instead of by credential id.
|
|
176
|
+
const pushed = projected.at(-1);
|
|
177
|
+
if (seed && pushed)
|
|
178
|
+
pushed.previousIndex = seedIndex(seed, pushed.row);
|
|
149
179
|
}
|
|
150
180
|
// An account the reply does not account for keeps its last good projection:
|
|
151
181
|
// a rejected record must not read as the account having been removed. A
|
|
@@ -293,6 +323,14 @@ function decodeRoster(value) {
|
|
|
293
323
|
declined,
|
|
294
324
|
};
|
|
295
325
|
}
|
|
326
|
+
/**
|
|
327
|
+
* `roster` as `readVaultRoster` decodes it from the file that holds it, so a
|
|
328
|
+
* committed roster and a later read of its file compare equal. Package
|
|
329
|
+
* internal: not exported from `/claustrum`.
|
|
330
|
+
*/
|
|
331
|
+
export function decodedVaultRoster(roster) {
|
|
332
|
+
return decodeRoster(JSON.parse(JSON.stringify(roster)));
|
|
333
|
+
}
|
|
296
334
|
export async function readVaultRoster(path) {
|
|
297
335
|
let text;
|
|
298
336
|
try {
|
|
@@ -560,6 +598,102 @@ const pendingDiscoveries = new Map();
|
|
|
560
598
|
export function forgetPendingVaultDiscovery(path) {
|
|
561
599
|
pendingDiscoveries.delete(resolve(path));
|
|
562
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* Whether `error` from a roster commit at `path` means another writer had the
|
|
603
|
+
* roster at that moment, not that the commit itself failed: the discovery
|
|
604
|
+
* lease was busy before any roster existed, the write lock stayed busy, or a
|
|
605
|
+
* lock this commit held was lost. On Windows the same lock steps can reject
|
|
606
|
+
* with a PrivateFileError instead (a lock file that could not be read or
|
|
607
|
+
* replaced while another process held it), so a PrivateFileError naming one
|
|
608
|
+
* of this roster's lock files counts the same on every OS.
|
|
609
|
+
*/
|
|
610
|
+
export function isVaultRosterLockRefusal(path, error) {
|
|
611
|
+
const target = resolve(path);
|
|
612
|
+
if (error instanceof ClaustrumConsumerError)
|
|
613
|
+
return error.kind === 'roster-busy';
|
|
614
|
+
if (error instanceof LockContentionError ||
|
|
615
|
+
error instanceof LockOwnershipError)
|
|
616
|
+
return resolve(error.details.target) === target;
|
|
617
|
+
if (error instanceof PrivateFileError) {
|
|
618
|
+
const failed = resolve(error.path);
|
|
619
|
+
return [WRITE_LOCK.name, DISCOVERY_LEASE.name].some((name) => {
|
|
620
|
+
const lock = lockPathFor(target, name);
|
|
621
|
+
// The lock file itself, its eviction marker and the temporary files
|
|
622
|
+
// its owner record is staged in all start with the lock path.
|
|
623
|
+
return failed === lock || failed.startsWith(`${lock}.`);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
628
|
+
const UNLOCKED_READ_ATTEMPTS = 4;
|
|
629
|
+
/**
|
|
630
|
+
* What identifies one version of the file at `path`: the roster is always
|
|
631
|
+
* replaced by a rename, so any write gives it a different inode, and an
|
|
632
|
+
* in-place rewrite a different size or modification time. Undefined when it
|
|
633
|
+
* cannot be inspected, which callers must read as 'may have changed'.
|
|
634
|
+
*/
|
|
635
|
+
async function fileStamp(path) {
|
|
636
|
+
try {
|
|
637
|
+
const { dev, ino, size, mtimeMs } = await stat(path);
|
|
638
|
+
return { dev, ino, size, mtimeMs };
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return undefined;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
/** Whether two `fileStamp` readings are of the same file version. */
|
|
645
|
+
function sameFileStamp(left, right) {
|
|
646
|
+
return (left !== undefined &&
|
|
647
|
+
right !== undefined &&
|
|
648
|
+
left.dev === right.dev &&
|
|
649
|
+
left.ino === right.ino &&
|
|
650
|
+
left.size === right.size &&
|
|
651
|
+
left.mtimeMs === right.mtimeMs);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Whether a roster list that lands unlocked would write exactly the file at
|
|
655
|
+
* `path`. `overlay` names what this process holds besides the file: `target` is
|
|
656
|
+
* the file's own roster, which the locked commit compares against, and `local`,
|
|
657
|
+
* when different, is that roster plus unwritten quota readings, which must
|
|
658
|
+
* equal the commit's result too. Both must equal `next(target)`'s bytes, which
|
|
659
|
+
* is the write the commit would make; any difference, a missing or unreadable
|
|
660
|
+
* file, or a read that a concurrent reader keeps changing all mean 'commit'.
|
|
661
|
+
*/
|
|
662
|
+
async function readUnlockedCommitTarget(path, overlay, next) {
|
|
663
|
+
for (let attempt = 0; attempt < UNLOCKED_READ_ATTEMPTS; attempt++) {
|
|
664
|
+
// Roster files are replaced by rename, so each attempt reads one whole
|
|
665
|
+
// file either way.
|
|
666
|
+
const before = await fileStamp(path);
|
|
667
|
+
const current = await readVaultRoster(path);
|
|
668
|
+
let target;
|
|
669
|
+
let local;
|
|
670
|
+
let changed;
|
|
671
|
+
try {
|
|
672
|
+
;
|
|
673
|
+
({ target, local } = overlay(current));
|
|
674
|
+
const written = JSON.stringify(next(target));
|
|
675
|
+
changed =
|
|
676
|
+
JSON.stringify(target) !== written ||
|
|
677
|
+
(local !== undefined && JSON.stringify(local) !== written);
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
return 'commit';
|
|
681
|
+
}
|
|
682
|
+
// No lock was taken, so another writer may have replaced the file while
|
|
683
|
+
// it was being projected. The answer must describe the file as it is now,
|
|
684
|
+
// otherwise a later list of the same roster would find it changed again.
|
|
685
|
+
if (!sameFileStamp(before, await fileStamp(path)))
|
|
686
|
+
continue;
|
|
687
|
+
if (changed)
|
|
688
|
+
return 'commit';
|
|
689
|
+
// Quota readings this process enqueued while the file was being projected
|
|
690
|
+
// are not part of `current`, and a list that returns unchanged while they
|
|
691
|
+
// wait would be the only chance to serve them as this list's roster. They
|
|
692
|
+
// keep their own commit either way, so this path takes the locked one.
|
|
693
|
+
return hasPendingVaultQuota(path) ? 'commit' : 'unchanged';
|
|
694
|
+
}
|
|
695
|
+
return 'commit';
|
|
696
|
+
}
|
|
563
697
|
/**
|
|
564
698
|
* Discover and commit the roster. The lease covers the list call and the
|
|
565
699
|
* commit, not just the write: an opaque view cannot tell a delayed old reply
|
|
@@ -567,7 +701,8 @@ export function forgetPendingVaultDiscovery(path) {
|
|
|
567
701
|
* failed list, or custody switching off mid-run, never writes anything; a
|
|
568
702
|
* peer holding the lease means its last committed roster is served instead.
|
|
569
703
|
* A write-lock timeout keeps the discovered inventory for the next poll,
|
|
570
|
-
* unless a peer has superseded its base view in the meantime.
|
|
704
|
+
* unless a peer has superseded its base view in the meantime. A list that
|
|
705
|
+
* would write the file the roster already holds takes no write lock at all.
|
|
571
706
|
*/
|
|
572
707
|
export async function refreshVaultRoster(options) {
|
|
573
708
|
options.signal?.throwIfAborted();
|
|
@@ -589,10 +724,11 @@ export async function refreshVaultRoster(options) {
|
|
|
589
724
|
return persisted;
|
|
590
725
|
}
|
|
591
726
|
const key = resolve(options.path);
|
|
727
|
+
const listedOver = options.listedOver;
|
|
592
728
|
let discovered;
|
|
593
729
|
try {
|
|
594
730
|
const previous = await readVaultRoster(options.path);
|
|
595
|
-
const pending = pendingDiscoveries.get(key);
|
|
731
|
+
const pending = listedOver ? undefined : pendingDiscoveries.get(key);
|
|
596
732
|
// Opaque view cursors cannot be ordered. Reuse a discovery only while no
|
|
597
733
|
// peer has committed a different view since it was read.
|
|
598
734
|
discovered =
|
|
@@ -606,20 +742,58 @@ export async function refreshVaultRoster(options) {
|
|
|
606
742
|
};
|
|
607
743
|
pendingDiscoveries.delete(key);
|
|
608
744
|
const { inventory } = discovered;
|
|
609
|
-
|
|
745
|
+
// Called, not awaited: a caller noting the list here may wait for this
|
|
746
|
+
// roster's write lock, and this refresh decides below whether it needs it.
|
|
747
|
+
void options.onInventory?.(inventory);
|
|
610
748
|
options.signal?.throwIfAborted();
|
|
749
|
+
const projection = typeof options.projection === 'function'
|
|
750
|
+
? options.projection()
|
|
751
|
+
: options.projection;
|
|
752
|
+
const next = (current) => projectVaultRoster(current, inventory, projection);
|
|
753
|
+
const isActive = async () => {
|
|
754
|
+
options.signal?.throwIfAborted();
|
|
755
|
+
await lease.assertOwned();
|
|
756
|
+
return active();
|
|
757
|
+
};
|
|
758
|
+
// The write lock costs a lock file, an eviction marker and staging files in
|
|
759
|
+
// the roster's directory on every poll, so it is skipped when the list
|
|
760
|
+
// would write the file that is already there. The read deciding that takes
|
|
761
|
+
// no lock: a writer that commits between the read and this decision only
|
|
762
|
+
// makes our comparison stale, and a stale comparison never writes (the
|
|
763
|
+
// commit below judges the file again under the lock). Only equality with
|
|
764
|
+
// every byte the commit would write skips; every other answer commits.
|
|
765
|
+
const decision = await readUnlockedCommitTarget(options.path, (current) => {
|
|
766
|
+
// A hub member listed over a base view of its file: the file's own view
|
|
767
|
+
// is what the commit compares, so the overlay is the plain file. The
|
|
768
|
+
// locked commit below still applies the base-view rule.
|
|
769
|
+
if (listedOver)
|
|
770
|
+
return { target: current };
|
|
771
|
+
// This process's unwritten quota readings are part of the roster it
|
|
772
|
+
// serves, so a list that matches everything but them still commits.
|
|
773
|
+
const local = pendingVaultQuota(options.path, current);
|
|
774
|
+
return { target: current, ...(local !== current && { local }) };
|
|
775
|
+
}, next);
|
|
776
|
+
if (decision === 'unchanged') {
|
|
777
|
+
if (!(await isActive()))
|
|
778
|
+
return undefined;
|
|
779
|
+
// Returned as a later read of the file: that is what today's locked
|
|
780
|
+
// unchanged commit returns, and memory keeps only what was committed.
|
|
781
|
+
return await readVaultRoster(options.path);
|
|
782
|
+
}
|
|
611
783
|
return await mutateVaultRoster(options.path, async (current) => {
|
|
612
784
|
if (!(await active()))
|
|
613
785
|
return { result: undefined };
|
|
614
786
|
options.signal?.throwIfAborted();
|
|
615
787
|
await lease.assertOwned();
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
788
|
+
if (listedOver &&
|
|
789
|
+
current?.view !== listedOver.view &&
|
|
790
|
+
current?.view !== inventory.view)
|
|
791
|
+
return { result: current };
|
|
792
|
+
const projected = next(current);
|
|
793
|
+
const changed = JSON.stringify(current) !== JSON.stringify(projected);
|
|
794
|
+
return { ...(changed && { next: projected }), result: projected };
|
|
622
795
|
}, {
|
|
796
|
+
signal: options.signal,
|
|
623
797
|
beforePublish: async () => {
|
|
624
798
|
options.signal?.throwIfAborted();
|
|
625
799
|
await lease.assertOwned();
|
|
@@ -629,6 +803,7 @@ export async function refreshVaultRoster(options) {
|
|
|
629
803
|
catch (error) {
|
|
630
804
|
if (error instanceof LockContentionError &&
|
|
631
805
|
discovered &&
|
|
806
|
+
!listedOver &&
|
|
632
807
|
!options.signal?.aborted &&
|
|
633
808
|
(await active()))
|
|
634
809
|
pendingDiscoveries.set(key, discovered);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** Retired generations report separately until their owner closes and shares one warning. */
|
|
2
|
+
export function shutdownWarning(logger, shutdown) {
|
|
3
|
+
let warned = false;
|
|
4
|
+
return (pendingOperations) => {
|
|
5
|
+
if (warned)
|
|
6
|
+
return;
|
|
7
|
+
if (!shutdown || shutdown.aborted)
|
|
8
|
+
warned = true;
|
|
9
|
+
logger.warn('claustrum shutdown drain timed out', { pendingOperations });
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Give cancelled background work two seconds to release its handles without letting
|
|
14
|
+
* an unresponsive connector or disk hold the host's shutdown indefinitely.
|
|
15
|
+
*/
|
|
16
|
+
export async function drainShutdown(pending, onTimeout) {
|
|
17
|
+
if (!pending.size)
|
|
18
|
+
return;
|
|
19
|
+
let timer;
|
|
20
|
+
let expired = false;
|
|
21
|
+
const drained = (async () => {
|
|
22
|
+
while (!expired && pending.size)
|
|
23
|
+
await Promise.allSettled([...pending]);
|
|
24
|
+
})();
|
|
25
|
+
try {
|
|
26
|
+
await Promise.race([
|
|
27
|
+
drained,
|
|
28
|
+
new Promise((resolve) => {
|
|
29
|
+
timer = setTimeout(() => {
|
|
30
|
+
expired = true;
|
|
31
|
+
resolve();
|
|
32
|
+
}, 2_000);
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
if (expired && pending.size)
|
|
36
|
+
onTimeout(pending.size);
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
expired = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|