@cortexkit/antigravity-auth-core 3.0.11 → 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/agy-transport.d.ts.map +1 -1
- package/dist/agy-transport.js +4 -0
- package/dist/agy-transport.js.map +1 -1
- package/dist/common-auth-embedded/claustrum/consumer.d.ts +83 -2
- package/dist/common-auth-embedded/claustrum/consumer.js +337 -37
- 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/errors.d.ts +1 -1
- 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 +38 -66
- package/dist/common-auth-embedded/claustrum/roster.d.ts +80 -1
- package/dist/common-auth-embedded/claustrum/roster.js +259 -12
- package/dist/common-auth-embedded/claustrum/shutdown.js +42 -0
- package/dist/common-auth-embedded/commands/builtins.d.ts +37 -0
- package/dist/common-auth-embedded/commands/builtins.js +95 -13
- package/dist/common-auth-embedded/commands/index.d.ts +1 -1
- 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/quota/pending.js +48 -0
- package/dist/common-auth-embedded/source-output.json +152 -88
- package/dist/common-auth-embedded/store/attribution.d.ts +5 -0
- package/dist/common-auth-embedded/store/attribution.js +39 -25
- 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 +22 -0
- package/dist/common-auth-embedded/store/pool.js +18 -3
- package/dist/common-auth-embedded/store/pull.js +3 -1
- package/dist/common-auth-embedded/store/quota-writer.js +312 -0
- package/dist/common-auth-embedded/store/runtime.d.ts +2 -0
- 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/pre-response-deadline.d.ts.map +1 -1
- package/dist/pre-response-deadline.js +6 -5
- package/dist/pre-response-deadline.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
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
// One vault connection, one watch and one list for every provider a plugin
|
|
2
|
+
// serves on a host. The plugin still runs one ClaustrumConsumer per provider,
|
|
3
|
+
// each with its own roster file; the hub builds those consumers as its
|
|
4
|
+
// members, lists once, commits each member's share of the listing to that
|
|
5
|
+
// member's roster and pushes it into the member's memory.
|
|
6
|
+
//
|
|
7
|
+
// Several processes on one profile (an OpenCode server, Pi, a second
|
|
8
|
+
// OpenCode) each run their own hub and list for themselves. Nothing elects a
|
|
9
|
+
// leader between them: each hub commits every member through the same
|
|
10
|
+
// per-path discovery lease and roster write lock that plain consumers in
|
|
11
|
+
// different processes already share, plus the base-view rule, so a commit
|
|
12
|
+
// never replaces a view another process committed after this hub listed.
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { createLogger } from '../logger/index.js';
|
|
17
|
+
import { combineAbortSignals } from '../util/any-signal.js';
|
|
18
|
+
import { ClaustrumUnreachableError } from './availability.js';
|
|
19
|
+
import { createHubMember, DEFAULT_CONNECT_TIMEOUT_MS, } from './consumer.js';
|
|
20
|
+
import { ClaustrumScopedCustody, } from './custody.js';
|
|
21
|
+
import { ClaustrumConsumerError } from './errors.js';
|
|
22
|
+
import { DEFAULT_ROUTE_PREFIX, decodedVaultRoster, forgetPendingVaultDiscovery, isVaultRosterLockRefusal, readVaultRoster, refreshVaultRoster, vaultRosterSeed, } from './roster.js';
|
|
23
|
+
import { drainShutdown, shutdownWarning } from './shutdown.js';
|
|
24
|
+
import { SAFETY_POLL_MS } from './watch.js';
|
|
25
|
+
/** Lowercase ASCII, safe as a file name on every OS. */
|
|
26
|
+
const FILE_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
27
|
+
/** Device names Windows reserves in every directory, with any extension. */
|
|
28
|
+
const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\.|$)/;
|
|
29
|
+
/** A roster key's directory name: the first 16 hex digits of a SHA-256. */
|
|
30
|
+
const FINGERPRINT = /^[0-9a-f]{16}$/;
|
|
31
|
+
/** A failing member is listed for on each of this many ticks before backing off. */
|
|
32
|
+
const PENDING_EVERY_TICK = 3;
|
|
33
|
+
/**
|
|
34
|
+
* `value` as JSON with every object's keys sorted, so equal inputs give equal
|
|
35
|
+
* text in every process. Undefined for anything that is not plain JSON
|
|
36
|
+
* (functions, undefined, non-finite numbers, class instances, cycles).
|
|
37
|
+
*/
|
|
38
|
+
function canonicalJson(value, path = new Set()) {
|
|
39
|
+
if (value === null)
|
|
40
|
+
return 'null';
|
|
41
|
+
if (typeof value === 'string' || typeof value === 'boolean')
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
if (typeof value === 'number')
|
|
44
|
+
return Number.isFinite(value) ? JSON.stringify(value) : undefined;
|
|
45
|
+
if (typeof value !== 'object' || path.has(value))
|
|
46
|
+
return undefined;
|
|
47
|
+
const inner = new Set(path).add(value);
|
|
48
|
+
const parts = [];
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
for (let index = 0; index < value.length; index++) {
|
|
51
|
+
const part = canonicalJson(value[index], inner);
|
|
52
|
+
if (part === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
parts.push(part);
|
|
55
|
+
}
|
|
56
|
+
return `[${parts.join(',')}]`;
|
|
57
|
+
}
|
|
58
|
+
const prototype = Object.getPrototypeOf(value);
|
|
59
|
+
if (prototype !== Object.prototype && prototype !== null)
|
|
60
|
+
return undefined;
|
|
61
|
+
for (const key of Object.keys(value).sort()) {
|
|
62
|
+
const part = canonicalJson(value[key], inner);
|
|
63
|
+
if (part === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
parts.push(`${JSON.stringify(key)}:${part}`);
|
|
66
|
+
}
|
|
67
|
+
return `{${parts.join(',')}}`;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The roster key's fingerprint: a hash of everything that decides which rows
|
|
71
|
+
* a member's roster holds and how they are projected, other than the host
|
|
72
|
+
* generation (the directory above it) and the reserved route ids (local pool
|
|
73
|
+
* rows, which change at runtime; see `#collides`).
|
|
74
|
+
*/
|
|
75
|
+
function rosterFingerprint(options, members) {
|
|
76
|
+
const input = canonicalJson({
|
|
77
|
+
format: 1,
|
|
78
|
+
classifier: options.classifierInput,
|
|
79
|
+
members: [...members].sort(),
|
|
80
|
+
routePrefix: options.routePrefix ?? DEFAULT_ROUTE_PREFIX,
|
|
81
|
+
// A function cannot be compared across processes; its source text
|
|
82
|
+
// changes with the plugin build that ships it.
|
|
83
|
+
mapAccount: options.mapAccount ? String(options.mapAccount) : null,
|
|
84
|
+
});
|
|
85
|
+
if (input === undefined)
|
|
86
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum hub classifierInput must be plain JSON');
|
|
87
|
+
return createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
88
|
+
}
|
|
89
|
+
function fileId(value, what) {
|
|
90
|
+
if (typeof value !== 'string' ||
|
|
91
|
+
!FILE_ID.test(value) ||
|
|
92
|
+
WINDOWS_DEVICE.test(value))
|
|
93
|
+
throw new ClaustrumConsumerError('invalid-state', `Claustrum hub ${what} must be a lowercase file-safe id`);
|
|
94
|
+
return value;
|
|
95
|
+
}
|
|
96
|
+
function closed(message = 'Claustrum custody lease is closed') {
|
|
97
|
+
return new ClaustrumConsumerError('closed', message);
|
|
98
|
+
}
|
|
99
|
+
function notActive() {
|
|
100
|
+
return new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
|
|
101
|
+
}
|
|
102
|
+
/** An unreachable vault, raised when a connect passed its time limit. */
|
|
103
|
+
function unreachableConnect() {
|
|
104
|
+
return new ClaustrumUnreachableError();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Follow `pending`, but reject as soon as `signal` aborts. `pending` is
|
|
108
|
+
* still observed afterwards, so its rejection is never unhandled.
|
|
109
|
+
*/
|
|
110
|
+
function untilAborted(pending, signal) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const abort = () => reject(signal.reason);
|
|
113
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
114
|
+
pending.then((value) => {
|
|
115
|
+
signal.removeEventListener('abort', abort);
|
|
116
|
+
resolve(value);
|
|
117
|
+
}, (error) => {
|
|
118
|
+
signal.removeEventListener('abort', abort);
|
|
119
|
+
reject(error);
|
|
120
|
+
});
|
|
121
|
+
if (signal.aborted)
|
|
122
|
+
abort();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Each row's credential id and enabled state, serialized. A change between two
|
|
127
|
+
* rosters means kept receipts must be retained against the new rows.
|
|
128
|
+
*/
|
|
129
|
+
function enabledRows(roster) {
|
|
130
|
+
return JSON.stringify((roster?.rows ?? []).map((row) => [row.credentialId, row.enabled]));
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* One discovery hub per host process: the only vault connection and the only
|
|
134
|
+
* `ClaustrumScopedCustody`, one vault watch and one list per refresh, fanned
|
|
135
|
+
* out to one roster per provider. Members (`member()`) are ordinary
|
|
136
|
+
* `ClaustrumConsumer`s that never connect, list or watch themselves; each
|
|
137
|
+
* holds a custody lease. The connection stays open while vault mode is on,
|
|
138
|
+
* because the hub's own discovery holds a lease of its own, and closes when
|
|
139
|
+
* the last lease of a connection is released, when vault mode goes off, or
|
|
140
|
+
* at `close()`.
|
|
141
|
+
*/
|
|
142
|
+
export class ClaustrumDiscoveryHub {
|
|
143
|
+
#options;
|
|
144
|
+
#logger;
|
|
145
|
+
#warnShutdown;
|
|
146
|
+
#shutdown = new AbortController();
|
|
147
|
+
#members = new Map();
|
|
148
|
+
#leases = new WeakMap();
|
|
149
|
+
#generations = 0;
|
|
150
|
+
#current;
|
|
151
|
+
/** The pending connect, with the generation it was started for. */
|
|
152
|
+
#connecting;
|
|
153
|
+
/** The lease the hub's own discovery and watch hold. */
|
|
154
|
+
#own;
|
|
155
|
+
#watch;
|
|
156
|
+
#run;
|
|
157
|
+
/** The hub's last authority read; undefined before the first one. */
|
|
158
|
+
#active;
|
|
159
|
+
/** Counts inactive transitions, so local work started before one stops. */
|
|
160
|
+
#epoch = 0;
|
|
161
|
+
#committed;
|
|
162
|
+
#discoveredAt;
|
|
163
|
+
#timer;
|
|
164
|
+
#started = false;
|
|
165
|
+
#pending = new Set();
|
|
166
|
+
#disposing;
|
|
167
|
+
/** This host generation's roster directory, which holds every key. */
|
|
168
|
+
#generationDirectory;
|
|
169
|
+
/** This hub's roster key: the fingerprint directory under it. */
|
|
170
|
+
#key;
|
|
171
|
+
constructor(options) {
|
|
172
|
+
if (!options.family || !('classify' in options.family))
|
|
173
|
+
throw new ClaustrumConsumerError('wrong-consumer', 'Claustrum discovery hub requires a classifying family');
|
|
174
|
+
const hostGeneration = fileId(options.hostGeneration, 'host generation');
|
|
175
|
+
const providers = options.members.map((member) => fileId(member, 'member'));
|
|
176
|
+
if (new Set(providers).size !== providers.length)
|
|
177
|
+
throw new ClaustrumConsumerError('invalid-state', 'Claustrum hub members must be distinct');
|
|
178
|
+
this.#options = options;
|
|
179
|
+
this.#logger = options.logger ?? createLogger('claustrum');
|
|
180
|
+
this.#warnShutdown = shutdownWarning(this.#logger, this.#shutdown.signal);
|
|
181
|
+
// The roster key: the host generation, then a fingerprint of the
|
|
182
|
+
// classifier's whole input and the projection inputs. Every member keeps
|
|
183
|
+
// the path it is given here for its whole life, and every lock and
|
|
184
|
+
// temporary file of a roster sits beside it, inside the key's directory.
|
|
185
|
+
this.#generationDirectory = join(options.stateDir, 'rosters', hostGeneration);
|
|
186
|
+
this.#key = rosterFingerprint(options, providers);
|
|
187
|
+
const directory = join(this.#generationDirectory, this.#key);
|
|
188
|
+
for (const provider of providers)
|
|
189
|
+
this.#members.set(provider, {
|
|
190
|
+
provider,
|
|
191
|
+
path: join(directory, `${provider}.json`),
|
|
192
|
+
closed: false,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
#track(pending) {
|
|
196
|
+
this.#pending.add(pending);
|
|
197
|
+
void pending.then(() => this.#pending.delete(pending), () => this.#pending.delete(pending));
|
|
198
|
+
return pending;
|
|
199
|
+
}
|
|
200
|
+
#assertOpen() {
|
|
201
|
+
if (this.#shutdown.signal.aborted)
|
|
202
|
+
throw closed('Claustrum discovery hub is closed');
|
|
203
|
+
}
|
|
204
|
+
#entry(provider) {
|
|
205
|
+
const entry = this.#members.get(provider);
|
|
206
|
+
if (!entry)
|
|
207
|
+
throw new ClaustrumConsumerError('wrong-consumer', 'Claustrum hub has no such member');
|
|
208
|
+
return entry;
|
|
209
|
+
}
|
|
210
|
+
/** The roster file member `provider` reads and the hub commits. */
|
|
211
|
+
rosterPath(provider) {
|
|
212
|
+
return this.#entry(provider).path;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Build the consumer for `provider`, once. It is an ordinary
|
|
216
|
+
* `ClaustrumConsumer` whose roster path is `rosterPath(provider)`; it
|
|
217
|
+
* connects, lists and watches only through this hub.
|
|
218
|
+
*/
|
|
219
|
+
member(provider, options) {
|
|
220
|
+
this.#assertOpen();
|
|
221
|
+
const entry = this.#entry(provider);
|
|
222
|
+
if (entry.hooks)
|
|
223
|
+
throw new ClaustrumConsumerError('wrong-consumer', 'Claustrum hub member was already built');
|
|
224
|
+
// The type has none of these; refuse them when they arrive through a cast
|
|
225
|
+
// rather than let a member read a path or source the hub does not own.
|
|
226
|
+
for (const owned of [
|
|
227
|
+
'rosterPath',
|
|
228
|
+
'tokenPath',
|
|
229
|
+
'family',
|
|
230
|
+
'connect',
|
|
231
|
+
'isCustodyActive',
|
|
232
|
+
'insula',
|
|
233
|
+
'pollIntervalMs',
|
|
234
|
+
'routePrefix',
|
|
235
|
+
'mapAccount',
|
|
236
|
+
])
|
|
237
|
+
if (owned in options)
|
|
238
|
+
throw new ClaustrumConsumerError('wrong-consumer', `Claustrum hub members cannot take ${owned}`);
|
|
239
|
+
const port = {
|
|
240
|
+
provider: entry.provider,
|
|
241
|
+
start: () => this.start(),
|
|
242
|
+
refresh: () => this.#refresh(),
|
|
243
|
+
readAuthority: () => this.#readAuthority(),
|
|
244
|
+
acquire: (signal) => this.acquire(signal),
|
|
245
|
+
leaseLive: (lease) => this.#leaseLive(lease),
|
|
246
|
+
grantReach: (view) => view !== undefined && this.#committed?.view === view
|
|
247
|
+
? this.#committed.reach
|
|
248
|
+
: undefined,
|
|
249
|
+
watchMode: () => this.watchState(),
|
|
250
|
+
keptReceiptState: () => this.keptReceiptState(),
|
|
251
|
+
retainUnion: () => this.#retainUnion(),
|
|
252
|
+
forget: (credentialId) => this.#current?.custody?.forget(credentialId),
|
|
253
|
+
detach: () => {
|
|
254
|
+
entry.closed = true;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
const consumerOptions = {
|
|
258
|
+
rosterPath: entry.path,
|
|
259
|
+
...(options.reservedRouteIds && {
|
|
260
|
+
reservedRouteIds: options.reservedRouteIds,
|
|
261
|
+
}),
|
|
262
|
+
...(this.#options.routePrefix !== undefined && {
|
|
263
|
+
routePrefix: this.#options.routePrefix,
|
|
264
|
+
}),
|
|
265
|
+
...(this.#options.mapAccount && { mapAccount: this.#options.mapAccount }),
|
|
266
|
+
...(options.onRoster && { onRoster: options.onRoster }),
|
|
267
|
+
...(options.onError && { onError: options.onError }),
|
|
268
|
+
logger: options.logger ?? this.#logger,
|
|
269
|
+
...(this.#options.now && { now: this.#options.now }),
|
|
270
|
+
};
|
|
271
|
+
const { consumer, hooks } = createHubMember(port, consumerOptions);
|
|
272
|
+
entry.hooks = hooks;
|
|
273
|
+
return consumer;
|
|
274
|
+
}
|
|
275
|
+
// --- leases ---
|
|
276
|
+
#startGeneration() {
|
|
277
|
+
const generation = {
|
|
278
|
+
id: ++this.#generations,
|
|
279
|
+
count: 0,
|
|
280
|
+
ended: false,
|
|
281
|
+
controller: new AbortController(),
|
|
282
|
+
};
|
|
283
|
+
this.#current = generation;
|
|
284
|
+
return generation;
|
|
285
|
+
}
|
|
286
|
+
#live(generation) {
|
|
287
|
+
return (!generation.ended &&
|
|
288
|
+
this.#current === generation &&
|
|
289
|
+
!this.#shutdown.signal.aborted);
|
|
290
|
+
}
|
|
291
|
+
#leaseLive(lease) {
|
|
292
|
+
const state = this.#leases.get(lease);
|
|
293
|
+
return !!state && !state.released && !state.generation.ended;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* End a generation: its signal aborts, every lease of it is revoked, its
|
|
297
|
+
* watch stops and its custody closes (with every kept receipt and the
|
|
298
|
+
* client). A connect still pending closes its own client when it resolves.
|
|
299
|
+
*/
|
|
300
|
+
#end(generation, reason = closed()) {
|
|
301
|
+
if (generation.ended)
|
|
302
|
+
return;
|
|
303
|
+
generation.ended = true;
|
|
304
|
+
generation.count = 0;
|
|
305
|
+
if (this.#current === generation)
|
|
306
|
+
this.#current = undefined;
|
|
307
|
+
if (this.#own && this.#leases.get(this.#own)?.generation === generation)
|
|
308
|
+
this.#own = undefined;
|
|
309
|
+
if (this.#watch?.generation === generation) {
|
|
310
|
+
this.#watch.watch.close();
|
|
311
|
+
this.#watch = undefined;
|
|
312
|
+
}
|
|
313
|
+
generation.controller.abort(reason);
|
|
314
|
+
if (generation.custody) {
|
|
315
|
+
generation.custody.close();
|
|
316
|
+
this.#track(generation.custody.dispose(this.#warnShutdown));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/** Return one waiting acquirer's count to a generation that still runs. */
|
|
320
|
+
#giveBack(generation) {
|
|
321
|
+
if (generation.ended)
|
|
322
|
+
return;
|
|
323
|
+
generation.count--;
|
|
324
|
+
if (generation.count <= 0)
|
|
325
|
+
this.#end(generation);
|
|
326
|
+
}
|
|
327
|
+
#connect(generation) {
|
|
328
|
+
const pending = this.#connecting;
|
|
329
|
+
if (pending?.generation === generation && pending.promise)
|
|
330
|
+
return pending.promise;
|
|
331
|
+
const entry = { generation, timedOut: false };
|
|
332
|
+
// Every acquirer waits on this one connect, so it is bounded: a connect
|
|
333
|
+
// that never settles would otherwise hold them all for the hub's life.
|
|
334
|
+
const connectTimeoutMs = this.#options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
335
|
+
let timer;
|
|
336
|
+
const connect = this.#options.connect().then((client) => {
|
|
337
|
+
// A connect resolves into its generation only while that generation
|
|
338
|
+
// is current and still wanted; otherwise its client never serves.
|
|
339
|
+
if (entry.timedOut) {
|
|
340
|
+
client.close();
|
|
341
|
+
throw unreachableConnect();
|
|
342
|
+
}
|
|
343
|
+
if (!this.#live(generation) || generation.count <= 0) {
|
|
344
|
+
client.close();
|
|
345
|
+
throw closed('Claustrum connection is no longer wanted');
|
|
346
|
+
}
|
|
347
|
+
generation.custody = new ClaustrumScopedCustody({
|
|
348
|
+
client,
|
|
349
|
+
family: this.#options.family,
|
|
350
|
+
tokenPath: this.#options.tokenPath,
|
|
351
|
+
parseIdentity: this.#options.parseIdentity,
|
|
352
|
+
requireAssertion: this.#options.requireAssertion,
|
|
353
|
+
now: this.#options.now,
|
|
354
|
+
logger: this.#logger,
|
|
355
|
+
setTimeoutImpl: this.#options.setTimeoutImpl,
|
|
356
|
+
clearTimeoutImpl: this.#options.clearTimeoutImpl,
|
|
357
|
+
});
|
|
358
|
+
return generation.custody;
|
|
359
|
+
});
|
|
360
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
361
|
+
timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
|
|
362
|
+
entry.timedOut = true;
|
|
363
|
+
if (!this.#live(generation))
|
|
364
|
+
return;
|
|
365
|
+
this.#logger.warn('vault connect timed out', {
|
|
366
|
+
reason: 'unreachable',
|
|
367
|
+
connectTimeoutMs,
|
|
368
|
+
});
|
|
369
|
+
reject(unreachableConnect());
|
|
370
|
+
}, connectTimeoutMs);
|
|
371
|
+
timer.unref?.();
|
|
372
|
+
});
|
|
373
|
+
entry.promise = this.#track(Promise.race([connect, deadline]).finally(() => {
|
|
374
|
+
if (timer)
|
|
375
|
+
(this.#options.clearTimeoutImpl ?? clearTimeout)(timer);
|
|
376
|
+
}));
|
|
377
|
+
// Clear only this connect: an older generation's connect settling late
|
|
378
|
+
// must leave a newer generation's pending connect in place.
|
|
379
|
+
const settled = entry.promise.finally(() => {
|
|
380
|
+
if (this.#connecting === entry)
|
|
381
|
+
this.#connecting = undefined;
|
|
382
|
+
});
|
|
383
|
+
settled.catch(() => { });
|
|
384
|
+
this.#connecting = entry;
|
|
385
|
+
return entry.promise;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Take a lease on the current connection, connecting first when there is
|
|
389
|
+
* none. The acquirer's count is taken before anything is awaited, so a
|
|
390
|
+
* release elsewhere meanwhile cannot end the connection under it. An abort
|
|
391
|
+
* or a failed connect gives that count back; an acquirer whose connection
|
|
392
|
+
* ended while it waited tries once more on the current one. Rejects with
|
|
393
|
+
* `not-active` while vault mode is off.
|
|
394
|
+
*/
|
|
395
|
+
acquire(signal) {
|
|
396
|
+
try {
|
|
397
|
+
this.#assertOpen();
|
|
398
|
+
signal?.throwIfAborted();
|
|
399
|
+
if (this.#active === false)
|
|
400
|
+
throw notActive();
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
return Promise.reject(error);
|
|
404
|
+
}
|
|
405
|
+
const generation = this.#current ?? this.#startGeneration();
|
|
406
|
+
generation.count++;
|
|
407
|
+
return this.#settle(generation, signal, true);
|
|
408
|
+
}
|
|
409
|
+
async #settle(generation, signal, retry) {
|
|
410
|
+
let custody = generation.custody;
|
|
411
|
+
if (!custody) {
|
|
412
|
+
const combined = combineAbortSignals([
|
|
413
|
+
generation.controller.signal,
|
|
414
|
+
...(signal ? [signal] : []),
|
|
415
|
+
]);
|
|
416
|
+
try {
|
|
417
|
+
custody = await untilAborted(this.#connect(generation), combined.signal);
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
if (!generation.ended) {
|
|
421
|
+
// Aborted, or the connect failed: this acquirer holds nothing.
|
|
422
|
+
this.#giveBack(generation);
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
return this.#again(signal, retry);
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
combined.dispose();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
if (generation.ended)
|
|
432
|
+
return this.#again(signal, retry);
|
|
433
|
+
if (signal?.aborted) {
|
|
434
|
+
this.#giveBack(generation);
|
|
435
|
+
throw signal.reason;
|
|
436
|
+
}
|
|
437
|
+
return this.#lease(generation, custody);
|
|
438
|
+
}
|
|
439
|
+
/** An acquirer whose generation ended while it waited. */
|
|
440
|
+
#again(signal, retry) {
|
|
441
|
+
signal?.throwIfAborted();
|
|
442
|
+
this.#assertOpen();
|
|
443
|
+
if (this.#active === false)
|
|
444
|
+
throw notActive();
|
|
445
|
+
if (!retry)
|
|
446
|
+
throw closed();
|
|
447
|
+
const generation = this.#current ?? this.#startGeneration();
|
|
448
|
+
generation.count++;
|
|
449
|
+
return this.#settle(generation, signal, false);
|
|
450
|
+
}
|
|
451
|
+
#lease(generation, custody) {
|
|
452
|
+
const state = { generation, custody, released: false };
|
|
453
|
+
const check = () => {
|
|
454
|
+
if (state.released || generation.ended)
|
|
455
|
+
throw closed();
|
|
456
|
+
};
|
|
457
|
+
const view = Object.freeze({
|
|
458
|
+
authorize: async (identity, signal, routeId) => {
|
|
459
|
+
check();
|
|
460
|
+
// Work in flight stops when the generation ends.
|
|
461
|
+
const combined = combineAbortSignals([
|
|
462
|
+
generation.controller.signal,
|
|
463
|
+
...(signal ? [signal] : []),
|
|
464
|
+
]);
|
|
465
|
+
try {
|
|
466
|
+
return await custody.authorize(identity, combined.signal, routeId);
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
combined.dispose();
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
reportFailure: async (attempt, status, source) => {
|
|
473
|
+
check();
|
|
474
|
+
return custody.reportFailure(attempt, status, source);
|
|
475
|
+
},
|
|
476
|
+
forget: (credentialId) => {
|
|
477
|
+
check();
|
|
478
|
+
custody.forget(credentialId);
|
|
479
|
+
},
|
|
480
|
+
keptReceiptState: () => {
|
|
481
|
+
check();
|
|
482
|
+
return custody.keptReceiptState();
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
const lease = Object.freeze({
|
|
486
|
+
custody: view,
|
|
487
|
+
generation: generation.id,
|
|
488
|
+
release: () => {
|
|
489
|
+
if (state.released)
|
|
490
|
+
return;
|
|
491
|
+
state.released = true;
|
|
492
|
+
this.#giveBack(generation);
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
this.#leases.set(lease, state);
|
|
496
|
+
return lease;
|
|
497
|
+
}
|
|
498
|
+
/** The current connection and its lease count, for status and tests. */
|
|
499
|
+
leaseState() {
|
|
500
|
+
const current = this.#current;
|
|
501
|
+
return Object.freeze({
|
|
502
|
+
generation: current?.id,
|
|
503
|
+
count: current?.count ?? 0,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
// --- authority ---
|
|
507
|
+
/**
|
|
508
|
+
* Read authority. The first false read after a true one (or before any)
|
|
509
|
+
* runs the inactive transition; nothing else does.
|
|
510
|
+
*/
|
|
511
|
+
async #readAuthority() {
|
|
512
|
+
const active = (await this.#options.isCustodyActive?.()) ?? true;
|
|
513
|
+
if (this.#shutdown.signal.aborted)
|
|
514
|
+
return false;
|
|
515
|
+
const was = this.#active;
|
|
516
|
+
this.#active = active;
|
|
517
|
+
if (!active && was !== false)
|
|
518
|
+
this.#deactivate();
|
|
519
|
+
return active;
|
|
520
|
+
}
|
|
521
|
+
/** Vault mode went off. Kept receipts never survive into local mode. */
|
|
522
|
+
#deactivate() {
|
|
523
|
+
this.#epoch++;
|
|
524
|
+
const current = this.#current;
|
|
525
|
+
if (current)
|
|
526
|
+
this.#end(current, notActive());
|
|
527
|
+
this.#own = undefined;
|
|
528
|
+
this.#watch?.watch.close();
|
|
529
|
+
this.#watch = undefined;
|
|
530
|
+
this.#committed = undefined;
|
|
531
|
+
this.#discoveredAt = undefined;
|
|
532
|
+
for (const entry of this.#members.values()) {
|
|
533
|
+
entry.seen = undefined;
|
|
534
|
+
entry.pending = undefined;
|
|
535
|
+
entry.hooks?.reset();
|
|
536
|
+
forgetPendingVaultDiscovery(entry.path);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// --- discovery ---
|
|
540
|
+
/** Members built and not closed, in the order the hub was given them. */
|
|
541
|
+
#open() {
|
|
542
|
+
return [...this.#members.values()].filter((entry) => entry.hooks && !entry.closed);
|
|
543
|
+
}
|
|
544
|
+
/** The hub's own lease, taken in the current generation when needed. */
|
|
545
|
+
async #ownLease() {
|
|
546
|
+
const held = this.#own;
|
|
547
|
+
const heldState = held && this.#leases.get(held);
|
|
548
|
+
if (held && heldState && this.#leaseLive(held))
|
|
549
|
+
return heldState;
|
|
550
|
+
this.#own = undefined;
|
|
551
|
+
const lease = await this.acquire(this.#shutdown.signal);
|
|
552
|
+
const raced = this.#own && this.#leases.get(this.#own);
|
|
553
|
+
if (this.#own && raced && this.#leaseLive(this.#own)) {
|
|
554
|
+
lease.release();
|
|
555
|
+
return raced;
|
|
556
|
+
}
|
|
557
|
+
this.#own = lease;
|
|
558
|
+
const state = this.#leases.get(lease);
|
|
559
|
+
if (!state)
|
|
560
|
+
throw closed();
|
|
561
|
+
return state;
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* One coalesced refresh. A run whose generation ended is never joined: a
|
|
565
|
+
* refresh after vault mode went off and on again starts a new one.
|
|
566
|
+
*/
|
|
567
|
+
#refresh() {
|
|
568
|
+
try {
|
|
569
|
+
this.#assertOpen();
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
return Promise.reject(error);
|
|
573
|
+
}
|
|
574
|
+
const running = this.#run;
|
|
575
|
+
if (running && !running.generation?.ended)
|
|
576
|
+
return running.promise;
|
|
577
|
+
const run = {};
|
|
578
|
+
const promise = this.#track(this.#discover(run).finally(() => {
|
|
579
|
+
if (this.#run === run)
|
|
580
|
+
this.#run = undefined;
|
|
581
|
+
}));
|
|
582
|
+
run.promise = promise;
|
|
583
|
+
this.#run = run;
|
|
584
|
+
return promise;
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Read each member's base view, list once, then commit each member's share
|
|
588
|
+
* of the listing to its roster, push it into the member, and retain the
|
|
589
|
+
* union. Each publication step checks first that the generation it started
|
|
590
|
+
* in is still current. A member that this list did not commit is marked
|
|
591
|
+
* pending, so a later tick lists again.
|
|
592
|
+
*/
|
|
593
|
+
async #discover(run) {
|
|
594
|
+
if (!(await this.#readAuthority()))
|
|
595
|
+
return undefined;
|
|
596
|
+
const own = await this.#ownLease();
|
|
597
|
+
const generation = own.generation;
|
|
598
|
+
run.generation = generation;
|
|
599
|
+
if (!this.#live(generation))
|
|
600
|
+
return undefined;
|
|
601
|
+
// What each member's file held before the list. Another process may
|
|
602
|
+
// commit while this list runs; the base-view rule then keeps its newer
|
|
603
|
+
// roster rather than replacing it with this older listing.
|
|
604
|
+
const bases = new Map();
|
|
605
|
+
for (const entry of this.#members.values())
|
|
606
|
+
bases.set(entry, await readVaultRoster(entry.path).then((file) => (file ? { file } : {}), (error) => error instanceof Error ? error : new Error(String(error))));
|
|
607
|
+
if (!this.#live(generation))
|
|
608
|
+
return undefined;
|
|
609
|
+
let inventory;
|
|
610
|
+
try {
|
|
611
|
+
inventory = await own.custody.discover(generation.controller.signal);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (!this.#live(generation))
|
|
615
|
+
return undefined;
|
|
616
|
+
// Every pending member missed this list too.
|
|
617
|
+
for (const entry of this.#members.values())
|
|
618
|
+
if (entry.pending)
|
|
619
|
+
this.#markPending(entry);
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
622
|
+
const errors = new Map();
|
|
623
|
+
let complete = true;
|
|
624
|
+
for (const entry of this.#open()) {
|
|
625
|
+
if (!this.#live(generation))
|
|
626
|
+
return undefined;
|
|
627
|
+
const hooks = entry.hooks;
|
|
628
|
+
if (!hooks || entry.closed)
|
|
629
|
+
continue;
|
|
630
|
+
const share = {
|
|
631
|
+
...inventory,
|
|
632
|
+
credentials: inventory.credentials.filter((credential) => credential.member === entry.provider),
|
|
633
|
+
skipped: inventory.skipped.filter((record) => record.member === entry.provider),
|
|
634
|
+
};
|
|
635
|
+
const base = bases.get(entry);
|
|
636
|
+
let roster;
|
|
637
|
+
try {
|
|
638
|
+
if (!base)
|
|
639
|
+
throw closed('Claustrum hub member has no base view');
|
|
640
|
+
if (base instanceof Error)
|
|
641
|
+
throw base;
|
|
642
|
+
// A member's first roster under this key carries the declines and
|
|
643
|
+
// order of its newest roster under an older key.
|
|
644
|
+
const seed = base.file ? undefined : await this.#seed(entry);
|
|
645
|
+
roster = await refreshVaultRoster({
|
|
646
|
+
path: entry.path,
|
|
647
|
+
custody: { discover: async () => share },
|
|
648
|
+
// Generation g's commit checks that g is still current, not only
|
|
649
|
+
// that vault mode is on, so off-then-on cannot let it into g+1.
|
|
650
|
+
isActive: () => this.#live(generation) && this.#active === true,
|
|
651
|
+
signal: generation.controller.signal,
|
|
652
|
+
projection: () => ({ ...hooks.projection(), ...(seed && { seed }) }),
|
|
653
|
+
listedOver: { view: base.file?.view },
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
if (!this.#live(generation))
|
|
658
|
+
return undefined;
|
|
659
|
+
complete = false;
|
|
660
|
+
this.#markPending(entry);
|
|
661
|
+
// Another writer holding or taking this roster's locks is not this
|
|
662
|
+
// member's failure: the member keeps what it has and is retried.
|
|
663
|
+
if (isVaultRosterLockRefusal(entry.path, error))
|
|
664
|
+
this.#logger.debug('claustrum hub member commit refused', {
|
|
665
|
+
member: entry.provider,
|
|
666
|
+
});
|
|
667
|
+
else
|
|
668
|
+
errors.set(entry.provider, error);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (!this.#live(generation))
|
|
672
|
+
return undefined;
|
|
673
|
+
// Not this list's roster: vault mode went off, the file was removed
|
|
674
|
+
// since the list, or another process holds or committed it (a peer's
|
|
675
|
+
// newer view, or a peer that stopped mid-commit holding the lease).
|
|
676
|
+
if (roster?.view !== inventory.view) {
|
|
677
|
+
complete = false;
|
|
678
|
+
this.#markPending(entry);
|
|
679
|
+
}
|
|
680
|
+
else
|
|
681
|
+
entry.pending = undefined;
|
|
682
|
+
if (!roster)
|
|
683
|
+
continue;
|
|
684
|
+
if (this.#collides(hooks, roster)) {
|
|
685
|
+
// Another process projected a route id that is a local row here.
|
|
686
|
+
complete = false;
|
|
687
|
+
this.#markPending(entry);
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
// The push: memory holds the committed roster before refresh resolves.
|
|
691
|
+
entry.seen = JSON.stringify(decodedVaultRoster(roster));
|
|
692
|
+
if (!entry.closed)
|
|
693
|
+
hooks.install(roster);
|
|
694
|
+
}
|
|
695
|
+
if (!this.#live(generation))
|
|
696
|
+
return undefined;
|
|
697
|
+
this.#retainUnion();
|
|
698
|
+
// The committed view, and the refusals and grant reach tied to it, move
|
|
699
|
+
// only when every member committed this listing.
|
|
700
|
+
if (complete)
|
|
701
|
+
this.#committed = Object.freeze({
|
|
702
|
+
view: inventory.view,
|
|
703
|
+
...(inventory.grantReach && { reach: inventory.grantReach }),
|
|
704
|
+
refused: Object.freeze([...(inventory.refused ?? [])]),
|
|
705
|
+
notMine: inventory.notMine ?? 0,
|
|
706
|
+
});
|
|
707
|
+
this.#discoveredAt = (this.#options.now ?? Date.now)();
|
|
708
|
+
return { errors };
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* The preferences a member's first roster under this key starts from: the
|
|
712
|
+
* declines and order of the newest roster of the same host generation and
|
|
713
|
+
* member under any other key, the layout without a fingerprint included.
|
|
714
|
+
* Those files are only read. Undefined when there is none.
|
|
715
|
+
*/
|
|
716
|
+
async #seed(entry) {
|
|
717
|
+
const name = `${entry.provider}.json`;
|
|
718
|
+
const candidates = [join(this.#generationDirectory, name)];
|
|
719
|
+
try {
|
|
720
|
+
for (const key of await readdir(this.#generationDirectory))
|
|
721
|
+
if (key !== this.#key && FINGERPRINT.test(key))
|
|
722
|
+
candidates.push(join(this.#generationDirectory, key, name));
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// No directory for this host generation yet: nothing to seed from.
|
|
726
|
+
}
|
|
727
|
+
let newest;
|
|
728
|
+
for (const path of candidates) {
|
|
729
|
+
try {
|
|
730
|
+
const modified = (await stat(path)).mtimeMs;
|
|
731
|
+
const roster = await readVaultRoster(path);
|
|
732
|
+
if (roster && (!newest || modified > newest.modified))
|
|
733
|
+
newest = { modified, roster };
|
|
734
|
+
}
|
|
735
|
+
catch {
|
|
736
|
+
// A missing or unreadable older roster seeds nothing.
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return newest && vaultRosterSeed(newest.roster);
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Whether `roster` routes through an id this member's plugin uses for a
|
|
743
|
+
* local row. Only a roster another process projected can: this process's
|
|
744
|
+
* own projection gives such a route a new id. Rather than serve it, the
|
|
745
|
+
* hub lists, and its own projection renames that route.
|
|
746
|
+
*/
|
|
747
|
+
#collides(hooks, roster) {
|
|
748
|
+
const reserved = hooks.projection().reservedRouteIds;
|
|
749
|
+
return (!!reserved?.size && roster.rows.some((row) => reserved.has(row.routeId)));
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* This member missed a list: due again on each of the next three ticks,
|
|
753
|
+
* then after 2, 4, 8 ... ticks, up to the five-minute safety poll, so one
|
|
754
|
+
* broken member file never keeps the hub listing every tick.
|
|
755
|
+
*/
|
|
756
|
+
#markPending(entry) {
|
|
757
|
+
const failures = (entry.pending?.failures ?? 0) + 1;
|
|
758
|
+
const tick = Math.max(1, this.#options.pollIntervalMs ?? 5_000);
|
|
759
|
+
const longest = Math.max(1, Math.ceil(SAFETY_POLL_MS / tick));
|
|
760
|
+
const interval = failures <= PENDING_EVERY_TICK
|
|
761
|
+
? 1
|
|
762
|
+
: Math.min(2 ** (failures - PENDING_EVERY_TICK), longest);
|
|
763
|
+
entry.pending = { failures, wait: interval - 1 };
|
|
764
|
+
}
|
|
765
|
+
/** Retain kept receipts against every open member's roster in memory. */
|
|
766
|
+
#retainUnion() {
|
|
767
|
+
const custody = this.#current?.custody;
|
|
768
|
+
if (!custody)
|
|
769
|
+
return;
|
|
770
|
+
custody.retain(this.#open().flatMap((entry) => entry.hooks?.roster()?.rows ?? []));
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Take member files another writer changed. The same view as in memory
|
|
774
|
+
* means a decline, accept, reorder or quota reading, which moves no view:
|
|
775
|
+
* it is installed, as a plain consumer's `authorize` and `decline` read
|
|
776
|
+
* such a file back (`consumer.ts`, `authorize` and `decline`). A different
|
|
777
|
+
* view, or a member with no roster in memory, means another process
|
|
778
|
+
* committed a listing: the member is marked pending and the next tick
|
|
779
|
+
* lists, then commits over that file or takes it, as a plain consumer's
|
|
780
|
+
* refresh does (`refreshVaultRoster`, which serves a peer's roster while
|
|
781
|
+
* the peer holds the discovery lease). Local reads only.
|
|
782
|
+
*/
|
|
783
|
+
async #install() {
|
|
784
|
+
const epoch = this.#epoch;
|
|
785
|
+
for (const entry of this.#open()) {
|
|
786
|
+
let file;
|
|
787
|
+
try {
|
|
788
|
+
file = await readVaultRoster(entry.path);
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
entry.hooks?.onError(error);
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (epoch !== this.#epoch || this.#active !== true)
|
|
795
|
+
return;
|
|
796
|
+
const hooks = entry.hooks;
|
|
797
|
+
if (!file || !hooks || entry.closed)
|
|
798
|
+
continue;
|
|
799
|
+
const content = JSON.stringify(file);
|
|
800
|
+
if (content === entry.seen)
|
|
801
|
+
continue;
|
|
802
|
+
const memory = hooks.roster();
|
|
803
|
+
if (!memory || memory.view !== file.view || this.#collides(hooks, file)) {
|
|
804
|
+
entry.pending ??= { failures: 0, wait: 0 };
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
const before = enabledRows(memory);
|
|
808
|
+
entry.seen = content;
|
|
809
|
+
hooks.install(file);
|
|
810
|
+
if (enabledRows(hooks.roster()) !== before)
|
|
811
|
+
this.#retainUnion();
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Whether a tick must list now: the consumer's rule, once for every
|
|
816
|
+
* member, and also when a pending member's wait is over. Called once per
|
|
817
|
+
* tick, which is what counts a pending member's wait down.
|
|
818
|
+
*/
|
|
819
|
+
#listDue() {
|
|
820
|
+
let pending = false;
|
|
821
|
+
for (const entry of this.#members.values()) {
|
|
822
|
+
if (!entry.pending)
|
|
823
|
+
continue;
|
|
824
|
+
if (entry.pending.wait <= 0)
|
|
825
|
+
pending = true;
|
|
826
|
+
else
|
|
827
|
+
entry.pending.wait--;
|
|
828
|
+
}
|
|
829
|
+
if (this.#watch?.watch.mode !== 'watching' || pending)
|
|
830
|
+
return true;
|
|
831
|
+
const now = (this.#options.now ?? Date.now)();
|
|
832
|
+
return (this.#discoveredAt === undefined ||
|
|
833
|
+
now - this.#discoveredAt >= SAFETY_POLL_MS);
|
|
834
|
+
}
|
|
835
|
+
/** Open the one vault watch on the hub's own lease, once per generation. */
|
|
836
|
+
#openWatch() {
|
|
837
|
+
const own = this.#own;
|
|
838
|
+
const state = own && this.#leases.get(own);
|
|
839
|
+
if (!own || !state || !this.#leaseLive(own) || this.#active !== true)
|
|
840
|
+
return;
|
|
841
|
+
const generation = state.generation;
|
|
842
|
+
if (!this.#live(generation) || this.#watch?.generation === generation)
|
|
843
|
+
return;
|
|
844
|
+
const watch = state.custody.watchRoster({
|
|
845
|
+
view: () => this.#committed?.view,
|
|
846
|
+
onView: () => this.#track((async () => {
|
|
847
|
+
// A refresh already in flight may have listed before the change was
|
|
848
|
+
// announced, so wait for it and list again.
|
|
849
|
+
while (this.#run)
|
|
850
|
+
await this.#run.promise.catch(() => { });
|
|
851
|
+
if (!this.#live(generation))
|
|
852
|
+
return;
|
|
853
|
+
await this.#list();
|
|
854
|
+
})()),
|
|
855
|
+
// The vault refused the watch: custody has dropped every kept receipt,
|
|
856
|
+
// which every member served from, as a refused list would.
|
|
857
|
+
onRefused: (error) => this.#everyone(error),
|
|
858
|
+
// The watch alone failed; listing goes on and every member is served.
|
|
859
|
+
onError: (error) => this.#error(error),
|
|
860
|
+
});
|
|
861
|
+
this.#watch = { generation, watch };
|
|
862
|
+
}
|
|
863
|
+
#error(error) {
|
|
864
|
+
if (!this.#shutdown.signal.aborted)
|
|
865
|
+
this.#options.onError?.(error);
|
|
866
|
+
}
|
|
867
|
+
/** A failure of every member's discovery: the hub's and each member's. */
|
|
868
|
+
#everyone(error) {
|
|
869
|
+
if (this.#shutdown.signal.aborted)
|
|
870
|
+
return;
|
|
871
|
+
this.#options.onError?.(error);
|
|
872
|
+
for (const entry of this.#open())
|
|
873
|
+
entry.hooks?.onError(error);
|
|
874
|
+
}
|
|
875
|
+
/** Each member's own commit error goes to that member's `onError` only. */
|
|
876
|
+
#publish(outcome) {
|
|
877
|
+
for (const [provider, error] of outcome?.errors ?? [])
|
|
878
|
+
this.#members.get(provider)?.hooks?.onError(error);
|
|
879
|
+
}
|
|
880
|
+
/** One list the hub itself decided on, with its errors published. */
|
|
881
|
+
async #list() {
|
|
882
|
+
let outcome;
|
|
883
|
+
try {
|
|
884
|
+
outcome = await this.#refresh();
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
this.#everyone(error);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
this.#publish(outcome);
|
|
891
|
+
}
|
|
892
|
+
async #tick() {
|
|
893
|
+
try {
|
|
894
|
+
if (await this.#readAuthority()) {
|
|
895
|
+
await this.#install();
|
|
896
|
+
if (this.#listDue())
|
|
897
|
+
await this.#list();
|
|
898
|
+
this.#openWatch();
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
catch (error) {
|
|
902
|
+
this.#error(error);
|
|
903
|
+
}
|
|
904
|
+
if (this.#shutdown.signal.aborted)
|
|
905
|
+
return;
|
|
906
|
+
const delay = this.#options.pollIntervalMs ?? 5_000;
|
|
907
|
+
if (delay <= 0)
|
|
908
|
+
return;
|
|
909
|
+
this.#timer = (this.#options.setTimeoutImpl ?? setTimeout)(() => {
|
|
910
|
+
this.#timer = undefined;
|
|
911
|
+
void this.#track(this.#tick());
|
|
912
|
+
}, delay);
|
|
913
|
+
this.#timer.unref?.();
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Start the hub's tick, which runs until `close()`. While vault mode is
|
|
917
|
+
* off a tick only reads authority and makes no vault request.
|
|
918
|
+
*/
|
|
919
|
+
start() {
|
|
920
|
+
if (this.#started)
|
|
921
|
+
return;
|
|
922
|
+
this.#assertOpen();
|
|
923
|
+
this.#started = true;
|
|
924
|
+
void this.#track(this.#tick());
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* List once and commit every member, as a tick does. Resolves once every
|
|
928
|
+
* member's memory holds its new roster. Rejects when the list fails; a
|
|
929
|
+
* member's own commit error goes to that member's `onError`.
|
|
930
|
+
*/
|
|
931
|
+
async refresh() {
|
|
932
|
+
this.#publish(await this.#refresh());
|
|
933
|
+
}
|
|
934
|
+
// --- accessors, without I/O ---
|
|
935
|
+
/**
|
|
936
|
+
* Rows the last discovery every member committed refused, with their fixed
|
|
937
|
+
* codes, and how many rows it left to other plugins. Undefined while
|
|
938
|
+
* unknown: before a complete discovery, and while vault mode is off.
|
|
939
|
+
*/
|
|
940
|
+
refusals() {
|
|
941
|
+
const committed = this.#committed;
|
|
942
|
+
return (committed &&
|
|
943
|
+
Object.freeze({
|
|
944
|
+
view: committed.view,
|
|
945
|
+
refused: committed.refused,
|
|
946
|
+
notMine: committed.notMine,
|
|
947
|
+
}));
|
|
948
|
+
}
|
|
949
|
+
/** The grant reach of the same discovery as `refusals()`. */
|
|
950
|
+
grantReach() {
|
|
951
|
+
return this.#committed?.reach;
|
|
952
|
+
}
|
|
953
|
+
/** How the hub learns about changes; `polling` until a watch is open. */
|
|
954
|
+
watchState() {
|
|
955
|
+
const open = this.#watch;
|
|
956
|
+
return open && this.#live(open.generation) ? open.watch.mode : 'polling';
|
|
957
|
+
}
|
|
958
|
+
/** The shared custody's kept receipts, host-wide. */
|
|
959
|
+
keptReceiptState() {
|
|
960
|
+
return (this.#current?.custody?.keptReceiptState() ??
|
|
961
|
+
Object.freeze({ active: false, since: null }));
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Stop the tick and the watch and close the connection. Members are not
|
|
965
|
+
* closed; they fail `closed` from then on. Use `dispose()` to also allow
|
|
966
|
+
* in-flight discovery up to two seconds to release its file handles.
|
|
967
|
+
*/
|
|
968
|
+
close() {
|
|
969
|
+
if (this.#shutdown.signal.aborted)
|
|
970
|
+
return;
|
|
971
|
+
this.#shutdown.abort(closed('Claustrum discovery hub is closed'));
|
|
972
|
+
if (this.#timer)
|
|
973
|
+
(this.#options.clearTimeoutImpl ?? clearTimeout)(this.#timer);
|
|
974
|
+
this.#timer = undefined;
|
|
975
|
+
const current = this.#current;
|
|
976
|
+
if (current)
|
|
977
|
+
this.#end(current);
|
|
978
|
+
this.#own = undefined;
|
|
979
|
+
this.#watch?.watch.close();
|
|
980
|
+
this.#watch = undefined;
|
|
981
|
+
}
|
|
982
|
+
/** Close immediately, then join background work for at most two seconds. */
|
|
983
|
+
async dispose() {
|
|
984
|
+
this.close();
|
|
985
|
+
this.#disposing ??= drainShutdown(this.#pending, this.#warnShutdown);
|
|
986
|
+
await this.#disposing;
|
|
987
|
+
}
|
|
988
|
+
}
|