@cotal-ai/core 0.48.2 → 0.50.0
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/agent-file.d.ts +34 -0
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +94 -2
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +25 -1
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-action.d.ts +1 -0
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +1 -1
- package/dist/endpoint-action.js.map +1 -1
- package/dist/endpoint-binding.d.ts +6 -0
- package/dist/endpoint-binding.d.ts.map +1 -1
- package/dist/endpoint-binding.js +17 -0
- package/dist/endpoint-binding.js.map +1 -1
- package/dist/endpoint-envelope.d.ts +1 -1
- package/dist/endpoint-envelope.d.ts.map +1 -1
- package/dist/endpoint-envelope.js +1 -1
- package/dist/endpoint-envelope.js.map +1 -1
- package/dist/endpoint-error.d.ts +10 -1
- package/dist/endpoint-error.d.ts.map +1 -1
- package/dist/endpoint-error.js +12 -0
- package/dist/endpoint-error.js.map +1 -1
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +34 -12
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +16 -51
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-publish-denial.d.ts +35 -0
- package/dist/endpoint-publish-denial.d.ts.map +1 -0
- package/dist/endpoint-publish-denial.js +56 -0
- package/dist/endpoint-publish-denial.js.map +1 -0
- package/dist/endpoint-serve-kv.d.ts +16 -0
- package/dist/endpoint-serve-kv.d.ts.map +1 -1
- package/dist/endpoint-serve-kv.js +29 -0
- package/dist/endpoint-serve-kv.js.map +1 -1
- package/dist/endpoint-serve.d.ts.map +1 -1
- package/dist/endpoint-serve.js +9 -4
- package/dist/endpoint-serve.js.map +1 -1
- package/dist/endpoint-service.d.ts +21 -0
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +150 -15
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint-subjects.d.ts +34 -0
- package/dist/endpoint-subjects.d.ts.map +1 -1
- package/dist/endpoint-subjects.js +87 -18
- package/dist/endpoint-subjects.js.map +1 -1
- package/dist/endpoint-verbs.d.ts +4 -2
- package/dist/endpoint-verbs.d.ts.map +1 -1
- package/dist/endpoint-verbs.js +23 -8
- package/dist/endpoint-verbs.js.map +1 -1
- package/dist/endpoint.d.ts +309 -8
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +888 -61
- package/dist/endpoint.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/issued-authority.d.ts +168 -0
- package/dist/issued-authority.d.ts.map +1 -0
- package/dist/issued-authority.js +497 -0
- package/dist/issued-authority.js.map +1 -0
- package/dist/issuer-session.d.ts +30 -0
- package/dist/issuer-session.d.ts.map +1 -0
- package/dist/issuer-session.js +51 -0
- package/dist/issuer-session.js.map +1 -0
- package/dist/lease.d.ts +22 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js +14 -1
- package/dist/lease.js.map +1 -1
- package/dist/lifecycle-state.d.ts +9 -2
- package/dist/lifecycle-state.d.ts.map +1 -1
- package/dist/lifecycle-state.js +11 -2
- package/dist/lifecycle-state.js.map +1 -1
- package/dist/membership-feed.d.ts +24 -1
- package/dist/membership-feed.d.ts.map +1 -1
- package/dist/membership-feed.js +61 -3
- package/dist/membership-feed.js.map +1 -1
- package/dist/provision.d.ts +24 -14
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +149 -15
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +126 -1
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +21 -0
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/run-admission.d.ts +88 -0
- package/dist/run-admission.d.ts.map +1 -0
- package/dist/run-admission.js +193 -0
- package/dist/run-admission.js.map +1 -0
- package/dist/run-driver-grants.d.ts +10 -0
- package/dist/run-driver-grants.d.ts.map +1 -1
- package/dist/run-driver-grants.js +21 -0
- package/dist/run-driver-grants.js.map +1 -1
- package/dist/run-host.d.ts +7 -1
- package/dist/run-host.d.ts.map +1 -1
- package/dist/run-host.js.map +1 -1
- package/dist/run-journal.d.ts +4 -0
- package/dist/run-journal.d.ts.map +1 -1
- package/dist/run-journal.js +80 -17
- package/dist/run-journal.js.map +1 -1
- package/dist/runtime.d.ts +31 -3
- package/dist/runtime.d.ts.map +1 -1
- package/dist/secret-fs.d.ts +25 -0
- package/dist/secret-fs.d.ts.map +1 -1
- package/dist/secret-fs.js +136 -3
- package/dist/secret-fs.js.map +1 -1
- package/dist/secret-store.d.ts +44 -0
- package/dist/secret-store.d.ts.map +1 -1
- package/dist/secret-store.js +60 -1
- package/dist/secret-store.js.map +1 -1
- package/dist/streams.d.ts +23 -75
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +65 -85
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +11 -1
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +11 -1
- package/dist/subjects.js.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -5,14 +5,16 @@ import { connect, credsAuthenticator, headers, tokenAuthenticator, nanos, Author
|
|
|
5
5
|
import { wsconnect } from "@nats-io/nats-core";
|
|
6
6
|
import { credsClaims, credsFingerprint, credsRenewalDelayMs, idFromCreds } from "./identity.js";
|
|
7
7
|
import { inspectCredHealth } from "./provision.js";
|
|
8
|
+
import { parseSecretStoreIdentity, } from "./secret-store.js";
|
|
8
9
|
import { resolveService, invokeCommand, submitAndFollowGoal } from "./endpoint-invoke.js";
|
|
9
10
|
import { EpEnvelopeError, respondedButUnbound, replyRefusedBeforeEffect, EP_BIND_REFUSED } from "./endpoint-envelope.js";
|
|
10
11
|
import { isRepeatSafeCommand } from "./endpoint-grants.js";
|
|
11
|
-
import { assertIdToken } from "./endpoint-subjects.js";
|
|
12
|
+
import { assertIdToken, assertGeneration } from "./endpoint-subjects.js";
|
|
13
|
+
import { readAcceptedRow } from "./issued-authority.js";
|
|
12
14
|
import { liveKvEntries } from "./kv-scan.js";
|
|
13
15
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
14
16
|
import { assertValidName } from "./resolve.js";
|
|
15
|
-
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
|
|
17
|
+
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS, TTL_RECONCILE_CANARY_KEY } from "./streams.js";
|
|
16
18
|
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
|
|
17
19
|
import {} from "@nats-io/jetstream";
|
|
18
20
|
import { Kvm } from "@nats-io/kv";
|
|
@@ -21,7 +23,7 @@ import { openMembersRegistry, commitMember, tombstoneMember, activateMember, rea
|
|
|
21
23
|
import { openAclRegistry, readAcl, readAclForAlias, AmbiguousAclAlias, commitAcl as writeAclRecord, reissueAcl as writeAclReissue } from "./acls.js";
|
|
22
24
|
import { openDeliveryRegistry } from "./lease.js";
|
|
23
25
|
import { openChannelRegistry, effectiveReplay, effectiveReplayWindowMs, effectiveDeliveryClass, readChannelConfig, readChannelDefaults, } from "./channels.js";
|
|
24
|
-
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
26
|
+
import { anycastSubject, CHANNEL_DEFAULTS_KEY, chatStream, chatHistDurable, chatSubject, controlServiceSubject, CONTROL_DELIVERY, CONTROL_DELIVERY_ADMIN, dmStream, dmDurable, dlvStream, dlvDurable, dlvSubject, dinboxSubject, inboxStream, parseDinboxPrincipal, FANOUT_DURABLE, INBOX_READER_DURABLE, leaseKey, managerBucket, MANAGER_LEASE_KEY, MANAGER_RENEWAL_LEASE_KEY, managerLeaseKey, chatWildcard, assertValidChannel, channelInAllow, isConcreteChannel, normalizeMentions, parseSubject, isPrincipalOwnerToken, assertInboxConnId, presenceBucket, membershipBucket, MEMBERSHIP_FEED_KEY, principalKey, parsePrincipalKey, assertLifecycleToken, mintLifecycleUid, lifecycleNameKey, DEV_OWNER, spacePrefix, spaceWildcard, subjectMatches, taskStream, taskDurable, token, unicastSubject, unicastRecvFilter, } from "./subjects.js";
|
|
25
27
|
export const DEFAULT_SERVER = "nats://127.0.0.1:4222";
|
|
26
28
|
const PLANE3_FRAME_HEADER = "Cotal-Delivery-Frame";
|
|
27
29
|
/** Space joined when none is given on the CLI (the `cotal-<space>` cmux tab, etc.). */
|
|
@@ -112,6 +114,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
112
114
|
/** The freshest bearer — what every (re)connect attempt presents. */
|
|
113
115
|
currentBearer;
|
|
114
116
|
bearerTimer;
|
|
117
|
+
/** Arms against the credential authenticated on the current wire. nats-core 3.4.0 discards the
|
|
118
|
+
* promise returned by its async transport-close continuation; if that continuation enters the
|
|
119
|
+
* reconnect dial loop with an expired JWT, its terminal auth error becomes an unhandled rejection.
|
|
120
|
+
* Cotal knows the JWT expiry, so it disables the library reconnect before the broker closes the
|
|
121
|
+
* transport. The endpoint's observed `closed()` supervisor remains responsible for rebuilding. */
|
|
122
|
+
authExpiryReconnectTimer;
|
|
115
123
|
sentinelCreds;
|
|
116
124
|
tls;
|
|
117
125
|
heartbeatMs;
|
|
@@ -139,6 +147,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
139
147
|
aclKv;
|
|
140
148
|
deliveryKv;
|
|
141
149
|
managerLeaseKv;
|
|
150
|
+
/** Our revision of the per-space daemon-credential renewal lease (#1634), or undefined when we do
|
|
151
|
+
* not hold it. Cleared with the bound KV handle: a revision from a dead connection is not a lease
|
|
152
|
+
* we can prove we still hold. */
|
|
153
|
+
daemonRenewalLeaseRevision;
|
|
142
154
|
membershipFeedKv;
|
|
143
155
|
/** Caller-owned membership watches survive a connection rebuild as INTENT. Their iterators are
|
|
144
156
|
* connection-scoped and are stopped/re-created around the epoch swap. */
|
|
@@ -150,6 +162,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
150
162
|
/** When set, this endpoint hosts the Plane-3 fan-out writer + trusted reader (the server-side delivery
|
|
151
163
|
* daemon). `aclFor` maps an owner id to its current read ACL (`allowSubscribe`) for the reader's
|
|
152
164
|
* re-authorization — read FRESH per entry from the durable ACL registry KV, hence async. */
|
|
165
|
+
/** True once {@link quiescePlane3} has stopped serving this shard pending an ownership answer.
|
|
166
|
+
* Guards {@link armPlane3} so a RECONNECT cannot silently resume serving mid-question. */
|
|
167
|
+
plane3Quiesced = false;
|
|
153
168
|
plane3;
|
|
154
169
|
/** Live local cache of the channel registry (key = channel token), kept by a KV watch. */
|
|
155
170
|
channelConfigs = new Map();
|
|
@@ -202,6 +217,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
202
217
|
firstConnect = true;
|
|
203
218
|
heartbeatTimer;
|
|
204
219
|
sweepTimer;
|
|
220
|
+
/** #1356: when the presence bucket started refusing writes; undefined once one succeeds. */
|
|
221
|
+
presenceWriteFailingSince;
|
|
222
|
+
/** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
|
|
223
|
+
lastPresenceWriteError;
|
|
205
224
|
roster = new Map();
|
|
206
225
|
/** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
|
|
207
226
|
presenceSnapshot = Promise.resolve();
|
|
@@ -216,6 +235,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
216
235
|
lastPresenceWatchAt = 0;
|
|
217
236
|
/** Last emitted presence-view state. Suppresses duplicate `presence-view` events. */
|
|
218
237
|
presenceViewState = "unpopulated";
|
|
238
|
+
/** A presence-watch rebind in flight (see {@link rebindStalePresenceWatch}); one at a time. */
|
|
239
|
+
presenceRebind;
|
|
240
|
+
/** Bumped by every connection-scoped teardown and by {@link stop}. A presence bind that was
|
|
241
|
+
* awaiting the broker when the epoch moved belongs to a retired epoch: it releases the
|
|
242
|
+
* iterator it got and installs nothing (see {@link startPresenceWatch}). */
|
|
243
|
+
presenceEpoch = 0;
|
|
244
|
+
/** The current watch was bound onto a bucket with NO keys (see {@link markPresenceBucketEmpty}).
|
|
245
|
+
* Such a watch cannot deliver until someone writes, so its silence is not staleness. */
|
|
246
|
+
presenceWatchEmpty = false;
|
|
247
|
+
/** Wall-clock of the last rebind attempt, so a bucket that is silent because it is EMPTY (or a
|
|
248
|
+
* broker that keeps refusing the consumer create) is retried once per TTL, not per sweep tick. */
|
|
249
|
+
presenceRebindAt = 0;
|
|
219
250
|
status = "idle";
|
|
220
251
|
activity;
|
|
221
252
|
/** Mirror of the connector's authoritative attention state, published in presence (advisory). The
|
|
@@ -256,6 +287,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
256
287
|
actorIsEphemeral;
|
|
257
288
|
/** This incarnation's lifecycle UID (opts.lifecycleUid) — see {@link EndpointOptions.lifecycleUid}. */
|
|
258
289
|
ownLifecycleUid;
|
|
290
|
+
acceptedToken;
|
|
291
|
+
/** The issuer-bound generation, learned once per connection from the accepted row. */
|
|
292
|
+
issuedGeneration;
|
|
259
293
|
/** Per-endpoint-name {@link resolveService} cache for {@link invokeService} — dropped on a
|
|
260
294
|
* `failed-precondition` currency refusal (the described incarnation was superseded). */
|
|
261
295
|
resolvedServices = new Map();
|
|
@@ -376,6 +410,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
376
410
|
: this.authed
|
|
377
411
|
? undefined
|
|
378
412
|
: mintLifecycleUid();
|
|
413
|
+
if (opts.acceptedToken !== undefined) {
|
|
414
|
+
if (!opts.creds)
|
|
415
|
+
throw new Error("EndpointOptions.acceptedToken names a static issuance and needs creds beside it (SPEC 13.15)");
|
|
416
|
+
this.acceptedToken = assertGeneration(opts.acceptedToken, "acceptedToken");
|
|
417
|
+
}
|
|
379
418
|
// `card.id` is the principal DOT-FORM `<owner>.<actor>` — the wire identity every `from.id` carries;
|
|
380
419
|
// principalKey validates both tokens.
|
|
381
420
|
const principal = principalKey(this.owner, this.actor);
|
|
@@ -444,8 +483,44 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
444
483
|
const claims = decodeBearerPrincipal(bearer);
|
|
445
484
|
if (claims.owner !== this.owner || claims.actor !== this.actor)
|
|
446
485
|
throw new Error(`bearer source returned principal ${claims.owner}.${claims.actor}, expected ${this.owner}.${this.actor}`);
|
|
486
|
+
// Two refusals, answering different questions, and both of them are about protecting what is
|
|
487
|
+
// ALREADY held. So both are scoped to there BEING something held, which is the same set as
|
|
488
|
+
// `!initial` but names the thing the rules actually depend on: on the first fetch there is no
|
|
489
|
+
// cache to lose, `start()` has to come up on whatever the source has, and the pre-dial guard
|
|
490
|
+
// in {@link bindConnection} is what speaks for a dead first token.
|
|
491
|
+
const expiryMs = bearerExpiryMs(bearer);
|
|
492
|
+
const held = this.currentBearer;
|
|
493
|
+
if (held !== undefined) {
|
|
494
|
+
// ALREADY DEAD, whatever else is true of it. Advancement is deliberately not part of this
|
|
495
|
+
// test: a candidate expiring at now-5s does advance a held one that died at now-60s, so an
|
|
496
|
+
// advance-only rule adopts it - overwriting the cache with material nothing can dial,
|
|
497
|
+
// skipping the recoverable warning because the fetch did not throw, and arming the next
|
|
498
|
+
// read off a non-positive delay. The creds path draws its line on expiry alone too, in
|
|
499
|
+
// {@link presentableCreds} (#1572).
|
|
500
|
+
if (expiryMs <= Date.now())
|
|
501
|
+
throw new Error("the bearer source returned a token that has already expired - nothing adopted");
|
|
502
|
+
// THE SAME BYTES BACK AGAIN from a source whose token cannot carry the next cycle: the
|
|
503
|
+
// delay it arms is non-positive, `armBearerRefresh` floors that to 5s, and the next read
|
|
504
|
+
// returns that same token - a 5s loop against the auth service for the rest of its life,
|
|
505
|
+
// with BEARER_RETRY_MS bypassed because the fetch did not FAIL. It succeeded and returned
|
|
506
|
+
// nothing new (#1561).
|
|
507
|
+
//
|
|
508
|
+
// The test is byte identity, not expiry, and not advancement. `exp` carries one second of
|
|
509
|
+
// resolution, so a key rotation re-signing the same claims under a new key - and a healthy
|
|
510
|
+
// short-TTL source read twice inside one wall-clock second - both hand back a token whose
|
|
511
|
+
// `exp` has not moved. That is genuinely re-issued material, which an advancement test
|
|
512
|
+
// refuses and this one adopts. It is the question the creds path asks with
|
|
513
|
+
// `credsFingerprint`: did the source re-issue ANYTHING (#1572).
|
|
514
|
+
if (bearer === held && expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS <= 0)
|
|
515
|
+
throw new Error("the bearer source re-served the token already held and it cannot carry another cycle (the auth service has not issued a fresh one) - nothing adopted");
|
|
516
|
+
}
|
|
447
517
|
this.currentBearer = bearer;
|
|
448
|
-
|
|
518
|
+
// A non-positive delay is NOT clamped to BEARER_RETRY_MS. A deployment whose whole token TTL
|
|
519
|
+
// sits inside the margin is legitimate - `expiry-renewal` mints 5s bearers against the 60s
|
|
520
|
+
// margin - and for it the 5s floor IS the renewal cadence. Backing that source off to 15s
|
|
521
|
+
// leaves a 5s token dead for two thirds of every cycle. What made #1561 a pointless loop was
|
|
522
|
+
// the source returning nothing new, which is refused above, not the cadence itself.
|
|
523
|
+
this.armBearerRefresh(expiryMs - Date.now() - CotalEndpoint.BEARER_REFRESH_MARGIN_MS);
|
|
449
524
|
}
|
|
450
525
|
catch (e) {
|
|
451
526
|
if (initial)
|
|
@@ -464,6 +539,77 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
464
539
|
/** How soon a FAILED creds refresh retries. Successful refreshes schedule by lifetime fraction
|
|
465
540
|
* (75% of iat→exp), not a fixed margin — standing creds span hours to days, bearers minutes. */
|
|
466
541
|
static CREDS_RETRY_MS = 60_000;
|
|
542
|
+
/** THE ONE PLACE a credential is cleared for presentation to a broker.
|
|
543
|
+
*
|
|
544
|
+
* The property is unconditional — this endpoint never presents a credential it has already
|
|
545
|
+
* decoded as expired — so it is a property of the SUPPLY, not of any one dial site. It used to
|
|
546
|
+
* live inside {@link bindConnection}, which only `start()` and `doRebuild` reach; the
|
|
547
|
+
* authenticator nats.js re-evaluates on ITS OWN reconnects read the cache directly and so
|
|
548
|
+
* presented whatever was last fetched, expired included. Two such reconnects exist and neither
|
|
549
|
+
* passes through bindConnection: the one the broker forces at JWT `exp`, and an ALREADY RUNNING
|
|
550
|
+
* dial loop from an earlier drop that crosses `exp` while it retries (the pre-expiry
|
|
551
|
+
* reconnect fence flips a policy flag nats-core only reads when it observes a NEW drop, so it
|
|
552
|
+
* cannot stop a loop already in flight).
|
|
553
|
+
*
|
|
554
|
+
* Putting the refusal here instead means a future caller cannot miss it: the only way to reach a
|
|
555
|
+
* dial is through {@link credsForWire}, and the one presentation that does not read the cache
|
|
556
|
+
* (the adoption preflight, which presents a fresh CANDIDATE) calls this same function on it.
|
|
557
|
+
*
|
|
558
|
+
* An unbounded credential (no numeric `exp`) is presentable: bounded lifetimes are the renewal
|
|
559
|
+
* seam's concern, and a cred with no expiry has none to be past. */
|
|
560
|
+
static presentableCreds(creds, opts) {
|
|
561
|
+
const { exp } = credsClaims(creds); // throws on a structurally-unusable file (fail-loud)
|
|
562
|
+
if (typeof exp === "number" && exp * 1000 <= Date.now())
|
|
563
|
+
throw new Error(opts.renewable
|
|
564
|
+
? "this endpoint's creds have expired and renewal is failing - not presenting the expired credential to the broker; retrying with backoff"
|
|
565
|
+
: "this endpoint's creds have expired and it holds no creds source to renew them - replace the credential and rebuild the endpoint (pass a creds FUNCTION for standing renewal)");
|
|
566
|
+
return creds;
|
|
567
|
+
}
|
|
568
|
+
/** The cached credential, checked. Handed to nats.js as the authenticator's source on EVERY auth
|
|
569
|
+
* mode (renewed or static), so each (re)connect attempt — ours or the library's — re-reads a
|
|
570
|
+
* CHECKED value. A refusal throws out of the authenticator, which nats-core turns into a closed
|
|
571
|
+
* connection rather than a CONNECT carrying dead material; the endpoint's own supervisor then
|
|
572
|
+
* rebuilds on capped backoff, and {@link bindConnection} re-fetches from the source on each of
|
|
573
|
+
* those attempts, so a renewal that starts working recovers the endpoint without presenting
|
|
574
|
+
* anything expired in the meantime. Deliberately side-effect free: kicking the renewal timer
|
|
575
|
+
* from here would retry the source once per dial attempt, which is the flat load on a dead
|
|
576
|
+
* broker that {@link RETRY_BACKOFF_CAP_MS} exists to prevent. */
|
|
577
|
+
credsForWire() {
|
|
578
|
+
if (!this.currentCreds)
|
|
579
|
+
throw new Error(this.credsSource
|
|
580
|
+
? "this endpoint has no credential to present yet (the creds source has not returned one) - not dialing without auth material"
|
|
581
|
+
: "this endpoint was constructed with an empty creds string - not dialing without auth material (an empty credential is not anonymous access)");
|
|
582
|
+
return CotalEndpoint.presentableCreds(this.currentCreds, { renewable: Boolean(this.credsSource) });
|
|
583
|
+
}
|
|
584
|
+
/** Refuses a fetched generation there is nothing to renew FROM, so the caller's failure posture
|
|
585
|
+
* ({@link CREDS_RETRY_MS}) applies instead of the renewal schedule (issue #1523).
|
|
586
|
+
*
|
|
587
|
+
* A source that hands back the SAME generation past its own renewal point has not re-signed yet:
|
|
588
|
+
* that is a missed remint, not a candidate. Adopting it re-arms from a non-positive delay, which
|
|
589
|
+
* {@link armCredsRefresh} floors to 1s, which fetches again, which is the same generation — a 1s
|
|
590
|
+
* read loop against a store that is already having a bad day, for the JWT's remaining 25% of life,
|
|
591
|
+
* with the 60s backoff that exists for exactly this bypassed because the fetch did not FAIL.
|
|
592
|
+
*
|
|
593
|
+
* The membership feed's rw-cred renewal already refuses it this way (`membership-feed.ts`,
|
|
594
|
+
* `adoptRwCreds`: "the rw source still holds the previous generation past its renewal point");
|
|
595
|
+
* this is the same rule on the endpoint's `delivery.creds` seam, so the two renewal paths answer
|
|
596
|
+
* a dead source identically.
|
|
597
|
+
*
|
|
598
|
+
* An ALREADY-EXPIRED generation is refused whatever it is: the delay is non-positive for it too,
|
|
599
|
+
* but it is dead rather than merely due, so it is named separately and refused even when the
|
|
600
|
+
* source keeps returning a different one. */
|
|
601
|
+
static assertRenewableGeneration(candidate, current, delayMs) {
|
|
602
|
+
const { exp } = credsClaims(candidate);
|
|
603
|
+
if (typeof exp === "number" && exp * 1000 <= Date.now())
|
|
604
|
+
throw new Error("the creds source returned an already-expired credential (its `exp` is in the past) - nothing adopted; the renewal owner has not re-signed it, or the store is serving a stale generation");
|
|
605
|
+
// Compared by GENERATION, not by envelope. `EndpointOptions.creds` takes opaque file content and
|
|
606
|
+
// promises no canonical whitespace: `jwtFromCreds` pads with `\s*` and trims, so a store, editor or
|
|
607
|
+
// filesystem round trip that adds a newline re-serves the SAME credential in bytes `===` calls
|
|
608
|
+
// different - and the refusal would be skipped for the one case it exists to catch. The envelope
|
|
609
|
+
// also carries the nkey seed, which is why `credsFingerprint` hashes the JWT instead.
|
|
610
|
+
if (current !== undefined && credsFingerprint(candidate) === credsFingerprint(current) && delayMs <= 0)
|
|
611
|
+
throw new Error("the creds source still holds the previous generation past its renewal point (the renewal owner has not re-signed it) - nothing adopted");
|
|
612
|
+
}
|
|
467
613
|
/** The disposable-preflight connect bound for the EXPLICIT reload proof (D5 class-2 adoption). A
|
|
468
614
|
* rogue or unreachable candidate must resolve well UNDER the manager's delivery-admin request
|
|
469
615
|
* bound, so this stays a few seconds and never blocks the responder. */
|
|
@@ -489,21 +635,74 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
489
635
|
const id = idFromCreds(creds);
|
|
490
636
|
if (id !== this.connId)
|
|
491
637
|
throw new Error(`creds source returned identity ${id}, expected ${this.connId} - renewal may not swap the connection's nkey`);
|
|
638
|
+
// Before the commit, so a refusal leaves the cache holding the last generation that was renewable
|
|
639
|
+
// from (and, on the initial fetch, fails loud rather than dialling with dead material).
|
|
640
|
+
const delay = credsRenewalDelayMs(creds);
|
|
641
|
+
CotalEndpoint.assertRenewableGeneration(creds, this.currentCreds, delay);
|
|
492
642
|
this.currentCreds = creds;
|
|
493
|
-
this.armCredsRefresh(
|
|
643
|
+
this.armCredsRefresh(delay);
|
|
494
644
|
const { iat, exp } = credsClaims(creds);
|
|
495
645
|
return { iat, exp };
|
|
496
646
|
}
|
|
497
647
|
/** Swap the live connection onto the freshest cached cred with a controlled `nc.reconnect()`
|
|
498
648
|
* (nats.js re-evaluates the creds getter per attempt). Swapping now, instead of waiting for the
|
|
499
649
|
* broker to close the connection at `exp`, means the wire never carries a near-dead JWT and the
|
|
500
|
-
* operator never sees a spurious "authentication expired"
|
|
501
|
-
*
|
|
502
|
-
* supervise loop's to own (its rebuild re-fetches); an already-disconnected client is a no-op
|
|
503
|
-
* (its own reconnect loop presents the fresh cred). */
|
|
650
|
+
* operator never sees a spurious "authentication expired". After the reconnect succeeds, re-arm
|
|
651
|
+
* the expiry fence for the credential now authenticated on the wire. */
|
|
504
652
|
async swapConnectionOntoFreshCreds() {
|
|
505
|
-
|
|
506
|
-
|
|
653
|
+
const nc = this.nc;
|
|
654
|
+
if (nc && !this.stopped) {
|
|
655
|
+
await nc.reconnect().catch(() => { });
|
|
656
|
+
if (this.nc === nc && !this.stopped)
|
|
657
|
+
this.armAuthExpiryReconnectFence(nc);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
/** nats-core keeps its reconnect switch on the protocol handler. This pinned internal shape is the
|
|
661
|
+
* same last-resort surface used by {@link closeFailedBind}; there is no public API for changing the
|
|
662
|
+
* reconnect policy of an existing connection. Disabling it does not disable Cotal self-heal: the
|
|
663
|
+
* `nc.closed()` supervisor below rebuilds the endpoint with freshly checked auth material. */
|
|
664
|
+
disableLibraryReconnect(nc) {
|
|
665
|
+
const protocol = nc.protocol;
|
|
666
|
+
if (protocol?.options)
|
|
667
|
+
protocol.options.reconnect = false;
|
|
668
|
+
}
|
|
669
|
+
/** Close a connection whose library reconnect has already been disabled. `drain()` flushes with a
|
|
670
|
+
* PING and waits for the matching PONG; nats-core only rejects that waiter inside reconnect
|
|
671
|
+
* `prepare()`, so a half-open socket with reconnect=false leaves drain pending until the 2-minute
|
|
672
|
+
* ping interval times out. `close()` tears the transport down without that round-trip, which is
|
|
673
|
+
* the same public path {@link closeFailedBind} already uses when there is no graceful delivery
|
|
674
|
+
* contract left. */
|
|
675
|
+
async closeWithoutLibraryReconnect(nc) {
|
|
676
|
+
if (!nc)
|
|
677
|
+
return;
|
|
678
|
+
this.disableLibraryReconnect(nc);
|
|
679
|
+
try {
|
|
680
|
+
await nc.close();
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
/* already closing */
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
/** Disable nats-core reconnect shortly before the JWT authenticated on this wire expires. The small
|
|
687
|
+
* lead makes the policy change precede the broker's expiry close even when both timers wake in the
|
|
688
|
+
* same event-loop turn. A credential adoption does not move this fence until the resident reconnect
|
|
689
|
+
* is requested, so the old wire remains protected during the prove-then-adopt window. */
|
|
690
|
+
armAuthExpiryReconnectFence(nc) {
|
|
691
|
+
clearTimeout(this.authExpiryReconnectTimer);
|
|
692
|
+
this.authExpiryReconnectTimer = undefined;
|
|
693
|
+
const credsExp = this.currentCreds && credsClaims(this.currentCreds).exp;
|
|
694
|
+
const expiryMs = this.userMode && this.currentBearer
|
|
695
|
+
? bearerExpiryMs(this.currentBearer)
|
|
696
|
+
: typeof credsExp === "number"
|
|
697
|
+
? credsExp * 1000
|
|
698
|
+
: undefined;
|
|
699
|
+
if (expiryMs === undefined || this.stopped)
|
|
700
|
+
return;
|
|
701
|
+
this.authExpiryReconnectTimer = setTimeout(() => {
|
|
702
|
+
if (this.nc === nc)
|
|
703
|
+
this.disableLibraryReconnect(nc);
|
|
704
|
+
}, Math.max(0, expiryMs - Date.now() - 500));
|
|
705
|
+
this.authExpiryReconnectTimer.unref?.();
|
|
507
706
|
}
|
|
508
707
|
/** The connectAndBind PRE-CONNECT fetch: pull the freshest source cred and pin it into
|
|
509
708
|
* {@link currentCreds} so the connect() that immediately follows presents it — that connect IS the
|
|
@@ -573,7 +772,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
573
772
|
throw new Error("reloadCreds: re-read credential generation did not match the expected re-signed generation (a different store, or a torn/stale read); nothing adopted");
|
|
574
773
|
// PREFLIGHT = the proof. A disposable connection presenting exactly the candidate BEFORE the live
|
|
575
774
|
// cache is touched; a refused cred throws here, leaving the resident connection untouched.
|
|
576
|
-
|
|
775
|
+
// The candidate goes through the SAME checkpoint the resident getter uses: this is the one
|
|
776
|
+
// presentation that does not read the cache, so routing it here is what makes the refusal a
|
|
777
|
+
// property of every path rather than of the cached one. An already-dead re-signed generation is
|
|
778
|
+
// refused locally instead of spending a round trip to be told so, and — because this throws
|
|
779
|
+
// BEFORE the commit below — it can never become the resident connection's next-presented cred.
|
|
780
|
+
const proven = CotalEndpoint.presentableCreds(candidate, { renewable: true });
|
|
781
|
+
const probe = await probeConnect(this.servers, { creds: proven, tls: this.tls, timeoutMs: Math.max(500, Math.min(CotalEndpoint.PREFLIGHT_MS, deadline - Date.now())) });
|
|
577
782
|
if (!probe.ok)
|
|
578
783
|
throw new Error(`reloadCreds: the broker did not accept the re-signed credential (${probe.reason}); nothing adopted`);
|
|
579
784
|
if (Date.now() > deadline)
|
|
@@ -582,6 +787,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
582
787
|
// lacking a numeric `exp`, and that throw must not leave currentCreds flipped to a candidate the
|
|
583
788
|
// authenticator would present on the next reconnect (a post-preflight validation failure is a no-op).
|
|
584
789
|
const delay = credsRenewalDelayMs(candidate);
|
|
790
|
+
CotalEndpoint.assertRenewableGeneration(candidate, this.currentCreds, delay);
|
|
585
791
|
this.currentCreds = candidate;
|
|
586
792
|
this.armCredsRefresh(delay);
|
|
587
793
|
return credsClaims(candidate);
|
|
@@ -694,11 +900,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
694
900
|
throw new Error(this.bearerSource
|
|
695
901
|
? "this endpoint's user bearer has expired and renewal through the auth exchange is failing - not presenting the expired token to the broker; retrying with backoff"
|
|
696
902
|
: "this endpoint's user bearer has expired and it holds no bearer source to renew it - re-authenticate and rebuild the endpoint (construct it with a bearer FUNCTION for standing renewal)");
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
903
|
+
// The CREDS refusal is NOT repeated here. It lives on the supply itself ({@link credsForWire}),
|
|
904
|
+
// which the authenticator below re-reads per attempt, so it covers this dial AND the reconnects
|
|
905
|
+
// nats.js runs on its own. Raising it early here too would only duplicate it on the one path
|
|
906
|
+
// that was already covered, and a fourth dial site added later would silently miss the copy.
|
|
907
|
+
// The refusal still surfaces on this path: the authenticator throws during the CONNECT, the
|
|
908
|
+
// library closes that attempt, and the reestablish loop's capped backoff re-enters here — where
|
|
909
|
+
// the source re-fetch above is what recovers a renewable endpoint.
|
|
702
910
|
this.nc = await dialerFor(this.servers)({
|
|
703
911
|
servers: this.servers,
|
|
704
912
|
// In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
|
|
@@ -714,12 +922,32 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
714
922
|
inboxPrefix: `_INBOX_${this.connId}`,
|
|
715
923
|
// The bearer rides a GETTER: nats.js re-evaluates the token authenticator per (re)connect
|
|
716
924
|
// attempt, so internal reconnects present whatever refreshBearer last fetched.
|
|
717
|
-
// Creds
|
|
718
|
-
// one the broker forces at JWT `exp
|
|
719
|
-
|
|
925
|
+
// Creds ALWAYS ride the CHECKED getter, renewed or static, so every attempt — including the
|
|
926
|
+
// reconnects nats.js performs on its own (the one the broker forces at JWT `exp`, and a dial
|
|
927
|
+
// loop from an earlier drop that crosses `exp` mid-retry) — re-reads a credential that has
|
|
928
|
+
// just been proven unexpired rather than whatever the cache happens to hold.
|
|
929
|
+
// The gate is `!== undefined`, NOT truthiness. An EMPTY creds string is a caller that meant to
|
|
930
|
+
// authenticate and supplied nothing; on a truthiness gate it fell through to `creds: undefined`
|
|
931
|
+
// and dialed ANONYMOUSLY, so the broker answered `Authorization Violation` and the real fault
|
|
932
|
+
// (an empty credential) was never named. Routing it into the checked getter fails it loud
|
|
933
|
+
// instead. Anonymous access stays reachable the only way it should be: by passing no creds.
|
|
934
|
+
...authOpts({ token: this.token, user: this.user, pass: this.pass, creds: this.currentCreds !== undefined || this.credsSource ? () => this.credsForWire() : undefined, bearer: this.userMode ? () => this.currentBearer : undefined, sentinelCreds: this.sentinelCreds, tls: this.tls }),
|
|
720
935
|
});
|
|
936
|
+
this.armAuthExpiryReconnectFence(this.nc);
|
|
721
937
|
this.watchStatus();
|
|
722
938
|
this.js = jetstream(this.nc);
|
|
939
|
+
// Discovery of the issued generation (SPEC 13.15): the ISSUER wrote the accepted reference
|
|
940
|
+
// under the token this launch was handed; the broker admits the read only under this
|
|
941
|
+
// connection's own per-key grant. What comes back must name this incarnation, and a fresh
|
|
942
|
+
// connection re-reads it: the generation is bound to the credential the transport presented,
|
|
943
|
+
// never to a file that may have been replaced under it.
|
|
944
|
+
if (this.acceptedToken !== undefined) {
|
|
945
|
+
const ref = await readAcceptedRow(this.nc, this.space, this.acceptedToken);
|
|
946
|
+
const uid = this.requireLifecycleUid("an issued endpoint");
|
|
947
|
+
if (ref.owner !== this.owner || ref.actor !== this.actor || ref.uid !== uid)
|
|
948
|
+
throw new Error(`the accepted row names ${ref.owner}.${ref.actor} (uid ${ref.uid}), not this endpoint ${this.owner}.${this.actor} (uid ${uid}); refusing to ride a foreign issuance (SPEC 13.15)`);
|
|
949
|
+
this.issuedGeneration = ref.generation;
|
|
950
|
+
}
|
|
723
951
|
if (this.doWatch || this.doRegister) {
|
|
724
952
|
const kvm = new Kvm(this.nc);
|
|
725
953
|
// The presence bucket is a JetStream stream. Open mode lazily creates it; auth mode
|
|
@@ -783,7 +1011,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
783
1011
|
}
|
|
784
1012
|
}
|
|
785
1013
|
if (this.doRegister) {
|
|
786
|
-
await
|
|
1014
|
+
// #1356: this await is bounded already — a refused presence write returns no response at all,
|
|
1015
|
+
// so it surfaces as the JetStream request TIMEOUT (~5s), once, with no retry here. What it did
|
|
1016
|
+
// NOT do was say what failed: the bare `timeout` names no bucket, no space and no subsystem,
|
|
1017
|
+
// and a launcher that catches it has nothing to act on. The lifecycle-proof failure a few lines
|
|
1018
|
+
// above throws a fully explanatory message; this one is held to the same standard. Still fails
|
|
1019
|
+
// rather than degrading — SPEC 13.1 fail-before-presence means a registering agent that cannot
|
|
1020
|
+
// publish presence must not come up as though it had.
|
|
1021
|
+
try {
|
|
1022
|
+
await this.publishPresence();
|
|
1023
|
+
}
|
|
1024
|
+
catch (e) {
|
|
1025
|
+
const detail = e?.message ?? String(e);
|
|
1026
|
+
throw new Error(`presence registration failed for ${this.card.id}: the space's presence KV bucket "${presenceBucket(this.space)}" did not accept this endpoint's first write (${detail}). The bucket can be OPENED and WATCHED while refusing every write, so a healthy-looking connection does not rule this out; a broker whose store has latched refuses until it is restarted, and no client can clear it (#1356).`);
|
|
1027
|
+
}
|
|
787
1028
|
this.heartbeatTimer = setInterval(() => {
|
|
788
1029
|
this.publishPresence().catch((e) => this.emitRecoverable(e));
|
|
789
1030
|
}, this.heartbeatMs);
|
|
@@ -817,6 +1058,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
817
1058
|
* second heartbeat, double-pump a consumer, or keep stale roster ghosts. Caller-owned
|
|
818
1059
|
* subs (tap/serve) are left alone — they aren't rebuilt here. */
|
|
819
1060
|
clearConnectionScoped() {
|
|
1061
|
+
if (this.authExpiryReconnectTimer) {
|
|
1062
|
+
clearTimeout(this.authExpiryReconnectTimer);
|
|
1063
|
+
this.authExpiryReconnectTimer = undefined;
|
|
1064
|
+
}
|
|
820
1065
|
if (this.heartbeatTimer) {
|
|
821
1066
|
clearInterval(this.heartbeatTimer);
|
|
822
1067
|
this.heartbeatTimer = undefined;
|
|
@@ -834,6 +1079,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
834
1079
|
}
|
|
835
1080
|
}
|
|
836
1081
|
this.streamMsgs.length = 0;
|
|
1082
|
+
this.presenceEpoch++;
|
|
1083
|
+
this.presenceRebind = undefined;
|
|
837
1084
|
try {
|
|
838
1085
|
this.presenceWatchIter?.stop();
|
|
839
1086
|
}
|
|
@@ -860,7 +1107,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
860
1107
|
this.chatSubDenied.clear();
|
|
861
1108
|
this.confirmingChatSubs.clear();
|
|
862
1109
|
this.roster.clear();
|
|
1110
|
+
// #1356: the presence-refusal record is connection-scoped like everything else torn down here.
|
|
1111
|
+
// It says "the broker on THIS connection refuses writes to this bucket", so it cannot outlive the
|
|
1112
|
+
// connection that observed it. Left behind, a later failure on a different connection inherits it
|
|
1113
|
+
// and is reported as a bucket refusal — measured: a failed bind against an unreachable server
|
|
1114
|
+
// still printed the refusal sentence while `connectionIssue` already said "connection refused".
|
|
1115
|
+
this.clearPresenceWriteFailure();
|
|
863
1116
|
this.lastPresenceWatchAt = 0;
|
|
1117
|
+
this.presenceRebindAt = 0;
|
|
1118
|
+
this.presenceWatchEmpty = false;
|
|
864
1119
|
this.presenceSnapshotPopulated = false;
|
|
865
1120
|
this.emitPresenceViewIfChanged();
|
|
866
1121
|
this.joinSeq.clear();
|
|
@@ -894,6 +1149,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
894
1149
|
* retry starts with the same empty state as a first attempt. */
|
|
895
1150
|
async closeFailedBind() {
|
|
896
1151
|
const failedNc = this.nc;
|
|
1152
|
+
if (failedNc)
|
|
1153
|
+
this.disableLibraryReconnect(failedNc);
|
|
897
1154
|
this.clearConnectionScoped();
|
|
898
1155
|
this.nc = undefined;
|
|
899
1156
|
this.js = undefined;
|
|
@@ -905,6 +1162,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
905
1162
|
this.membershipFeedKv = undefined;
|
|
906
1163
|
this.deliveryKv = undefined;
|
|
907
1164
|
this.managerLeaseKv = undefined;
|
|
1165
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
908
1166
|
// Handles that armDeliveryControl created on a previous connection: null them so a rebind
|
|
909
1167
|
// does not carry a dead protocol's subs forward. Best-effort unsubscribe first (the sub is
|
|
910
1168
|
// dead with its connection either way; unsubscribing an already-dead sub is a noop).
|
|
@@ -980,13 +1238,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
980
1238
|
if (!this.stopped)
|
|
981
1239
|
return false;
|
|
982
1240
|
const nc = this.nc;
|
|
1241
|
+
if (nc)
|
|
1242
|
+
this.disableLibraryReconnect(nc);
|
|
983
1243
|
this.clearConnectionScoped();
|
|
984
|
-
|
|
985
|
-
await nc?.drain();
|
|
986
|
-
}
|
|
987
|
-
catch {
|
|
988
|
-
/* already closing */
|
|
989
|
-
}
|
|
1244
|
+
await this.closeWithoutLibraryReconnect(nc);
|
|
990
1245
|
this.nc = undefined;
|
|
991
1246
|
return true;
|
|
992
1247
|
}
|
|
@@ -1028,15 +1283,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1028
1283
|
return p;
|
|
1029
1284
|
}
|
|
1030
1285
|
/** The transition: stop the connection-scoped timers FIRST (so nothing live touches
|
|
1031
|
-
* this.nc during the null window), drop the connection refs,
|
|
1286
|
+
* this.nc during the null window), drop the connection refs, close the old nc, then
|
|
1032
1287
|
* rebind + re-arm the supervisor on the fresh connection. clearConnectionScoped is
|
|
1033
1288
|
* idempotent, so connectAndBind's own call here is a noop. */
|
|
1034
1289
|
async doRebuild() {
|
|
1035
1290
|
const oldNc = this.nc;
|
|
1291
|
+
if (oldNc)
|
|
1292
|
+
this.disableLibraryReconnect(oldNc);
|
|
1036
1293
|
this.reconnecting = true;
|
|
1037
1294
|
try {
|
|
1038
1295
|
this.clearConnectionScoped();
|
|
1039
|
-
// Manual reconnect still has a live old epoch: complete broker-consumer cleanup before
|
|
1296
|
+
// Manual reconnect still has a live old epoch: complete broker-consumer cleanup before close.
|
|
1040
1297
|
// Terminal self-heal has an already-closed epoch: disarm retains stream/name for fresh cleanup.
|
|
1041
1298
|
if (oldNc && !oldNc.isClosed())
|
|
1042
1299
|
await Promise.all([...this.membershipFeedWatches].map((watch) => watch.arm));
|
|
@@ -1055,17 +1312,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1055
1312
|
// The manager's liveness-lease handle too: left bound to the old connection, every renew and
|
|
1056
1313
|
// re-read after a reconnect times out, and the manager reports its lease unknown for good.
|
|
1057
1314
|
this.managerLeaseKv = undefined;
|
|
1315
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
1058
1316
|
// This is an application-requested epoch teardown, not a transient nats.js blip. The old
|
|
1059
1317
|
// status iterator is now stale by construction and its close is epoch-dropped, so this line is
|
|
1060
1318
|
// the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
|
|
1061
1319
|
this.emit("transport", { connected: false });
|
|
1062
1320
|
this.emit("connection", { connected: false });
|
|
1063
|
-
|
|
1064
|
-
await oldNc?.drain();
|
|
1065
|
-
}
|
|
1066
|
-
catch {
|
|
1067
|
-
/* already closing */
|
|
1068
|
-
}
|
|
1321
|
+
await this.closeWithoutLibraryReconnect(oldNc);
|
|
1069
1322
|
await this.connectAndBind();
|
|
1070
1323
|
// stop() may have run during the await — don't leave a live connection + heartbeat +
|
|
1071
1324
|
// supervisor on a stopped endpoint. (Reads this.nc in its own scope — a bare `this.nc`
|
|
@@ -1149,10 +1402,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1149
1402
|
throw e;
|
|
1150
1403
|
}
|
|
1151
1404
|
}
|
|
1405
|
+
/** The presence epoch moves first: a bind still awaiting the broker must find it moved
|
|
1406
|
+
* before any await below gives it a window to install a watch on a stopped endpoint
|
|
1407
|
+
* (see {@link startPresenceWatch}). */
|
|
1152
1408
|
async stop() {
|
|
1153
1409
|
if (this.stopped)
|
|
1154
1410
|
return;
|
|
1411
|
+
if (this.nc)
|
|
1412
|
+
this.disableLibraryReconnect(this.nc);
|
|
1155
1413
|
this.stopped = true;
|
|
1414
|
+
this.presenceEpoch++;
|
|
1415
|
+
this.presenceRebind = undefined;
|
|
1156
1416
|
// Wake a reestablishLoop sitting in backoff so it sees `stopped` and exits instead of
|
|
1157
1417
|
// sleeping out retryMs; also clears the timer so it can't fire later.
|
|
1158
1418
|
this.kickBackoff();
|
|
@@ -1164,6 +1424,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1164
1424
|
clearTimeout(this.bearerTimer);
|
|
1165
1425
|
if (this.credsTimer)
|
|
1166
1426
|
clearTimeout(this.credsTimer);
|
|
1427
|
+
if (this.authExpiryReconnectTimer)
|
|
1428
|
+
clearTimeout(this.authExpiryReconnectTimer);
|
|
1167
1429
|
for (const watch of this.membershipFeedWatches) {
|
|
1168
1430
|
watch.stopped = true;
|
|
1169
1431
|
watch.arm = watch.arm.catch(() => { }).then(async () => {
|
|
@@ -1215,11 +1477,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1215
1477
|
/* best-effort graceful leave */
|
|
1216
1478
|
}
|
|
1217
1479
|
try {
|
|
1218
|
-
await this.nc
|
|
1480
|
+
await this.closeWithoutLibraryReconnect(this.nc);
|
|
1219
1481
|
}
|
|
1220
1482
|
catch {
|
|
1221
1483
|
/* ignore */
|
|
1222
1484
|
}
|
|
1485
|
+
// #1356: stop() does its own teardown and never runs clearConnectionScoped, so clear here too —
|
|
1486
|
+
// AFTER the best-effort offline publish above, which would otherwise re-record a refusal on its
|
|
1487
|
+
// way out. A stopped endpoint has no live basis for "this bucket is refusing writes", and the
|
|
1488
|
+
// duration is computed from `Date.now()`, so a retained record does not merely go stale: it keeps
|
|
1489
|
+
// COUNTING UP for as long as the dead object is held.
|
|
1490
|
+
this.clearPresenceWriteFailure();
|
|
1223
1491
|
}
|
|
1224
1492
|
// ---- messaging -----------------------------------------------------------
|
|
1225
1493
|
/** Multicast: broadcast to everyone on a channel. */
|
|
@@ -1550,7 +1818,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1550
1818
|
* launcher-supplied incarnation the rows are keyed on (ledger-consistent: the §13.1 presence
|
|
1551
1819
|
* lifecycle-proof refuses a divergent uid before any publish). */
|
|
1552
1820
|
serviceCaller() {
|
|
1553
|
-
|
|
1821
|
+
const triple = { owner: this.owner, actor: this.actor, uid: this.requireLifecycleUid("invokeService") };
|
|
1822
|
+
if (this.acceptedToken === undefined)
|
|
1823
|
+
return triple;
|
|
1824
|
+
if (this.issuedGeneration === undefined)
|
|
1825
|
+
throw new Error("invokeService: the issued generation is not yet discovered on this connection");
|
|
1826
|
+
return { ...triple, generation: this.issuedGeneration };
|
|
1554
1827
|
}
|
|
1555
1828
|
/** GENERIC v0.4 service invoke over this endpoint's own connection (P2 item 1, 1c.2b): resolve
|
|
1556
1829
|
* the named endpoint's registered surface — describe, §13.7 store fetch, digest-verified
|
|
@@ -1753,6 +2026,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1753
2026
|
return { state: "current", fresh: true };
|
|
1754
2027
|
if (!this.presenceSnapshotPopulated)
|
|
1755
2028
|
return { state: "unpopulated", fresh: false };
|
|
2029
|
+
if (this.presenceWatchEmpty)
|
|
2030
|
+
return { state: "current", fresh: true };
|
|
1756
2031
|
const staleSince = this.lastPresenceWatchAt + this.ttlMs;
|
|
1757
2032
|
if (Date.now() < staleSince)
|
|
1758
2033
|
return { state: "current", fresh: true };
|
|
@@ -2641,20 +2916,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2641
2916
|
delivered++;
|
|
2642
2917
|
if (m.seq >= ceiling) { // reached the page's upper bound
|
|
2643
2918
|
if (m.seq === ceiling) {
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
catch { /* skip undecodable */ }
|
|
2919
|
+
const msg = historyMessageFromDelivery(m);
|
|
2920
|
+
if (msg)
|
|
2921
|
+
out.push({ seq: m.seq, subject: m.subject, msg });
|
|
2648
2922
|
}
|
|
2649
2923
|
complete = true;
|
|
2650
2924
|
break;
|
|
2651
2925
|
}
|
|
2652
|
-
|
|
2653
|
-
|
|
2926
|
+
const msg = historyMessageFromDelivery(m);
|
|
2927
|
+
if (msg) {
|
|
2928
|
+
out.push({ seq: m.seq, subject: m.subject, msg });
|
|
2654
2929
|
if (out.length > limit)
|
|
2655
2930
|
out.shift();
|
|
2656
2931
|
}
|
|
2657
|
-
catch { /* skip undecodable */ }
|
|
2658
2932
|
if (delivered >= pending) {
|
|
2659
2933
|
complete = true;
|
|
2660
2934
|
break;
|
|
@@ -2709,6 +2983,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2709
2983
|
continue;
|
|
2710
2984
|
}
|
|
2711
2985
|
if (s.type === "reconnect") {
|
|
2986
|
+
this.armAuthExpiryReconnectFence(nc);
|
|
2712
2987
|
this.emit("transport", { connected: true, server: s.server });
|
|
2713
2988
|
continue;
|
|
2714
2989
|
}
|
|
@@ -2919,8 +3194,25 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2919
3194
|
this.deliveryKv ??= await openDeliveryRegistry(this.nc, this.space);
|
|
2920
3195
|
return this.deliveryKv;
|
|
2921
3196
|
}
|
|
3197
|
+
/** This endpoint instance's LEASE INCARNATION: which run of this principal a lease row was written
|
|
3198
|
+
* by. Minted per construction and never re-derived, because the credential cannot supply it, the
|
|
3199
|
+
* daemon's cred is a file on disk that every restart re-reads, so `card.id` is stable across
|
|
3200
|
+
* processes by design. See {@link DeliveryLeaseInfo.incarnation}. */
|
|
3201
|
+
leaseIncarnation = randomUUID();
|
|
2922
3202
|
encodeLease(ready) {
|
|
2923
|
-
return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, since: Date.now(), ready }));
|
|
3203
|
+
return new TextEncoder().encode(JSON.stringify({ holder: this.card.id, incarnation: this.leaseIncarnation, since: Date.now(), ready }));
|
|
3204
|
+
}
|
|
3205
|
+
/** Is this shard's lease row one THIS ENDPOINT INSTANCE wrote? The question a daemon whose renew
|
|
3206
|
+
* just failed has to answer before it decides whether it still owns the shard.
|
|
3207
|
+
*
|
|
3208
|
+
* Both halves are required. `holder` alone is not sufficient (a successor daemon re-reading the
|
|
3209
|
+
* same creds file presents the same principal, so its row would read as ours), and `incarnation`
|
|
3210
|
+
* alone is not sufficient either, it is a bare uuid with no claim to the principal, so a row
|
|
3211
|
+
* bearing ours but a foreign holder is not something we should ever adopt. A row with NO
|
|
3212
|
+
* incarnation predates the field and cannot be proven ours, which is the safe reading: it leads
|
|
3213
|
+
* to the takeover path rather than to serving on someone else's claim. */
|
|
3214
|
+
ownsDeliveryLease(info) {
|
|
3215
|
+
return info.holder === this.card.id && info.incarnation !== undefined && info.incarnation === this.leaseIncarnation;
|
|
2924
3216
|
}
|
|
2925
3217
|
/** Acquire the single-flight delivery lease for a shard via an ATOMIC CAS create, marked NOT-ready.
|
|
2926
3218
|
* THROWS if a live lease exists — a loud refusal-to-bind (the daemon exits), never a retry, so two
|
|
@@ -2936,6 +3228,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2936
3228
|
async markDeliveryLeaseReady(shardIndex, revision) {
|
|
2937
3229
|
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
2938
3230
|
}
|
|
3231
|
+
/** Flip the held lease back to NOT-ready, the counterpart to {@link markDeliveryLeaseReady}, for a
|
|
3232
|
+
* holder that has UNBOUND its loops and control responder but has not given up the shard.
|
|
3233
|
+
*
|
|
3234
|
+
* `ready` is a claim about the RESPONDER, not about the row's existence: `ensureDelivery` waits on
|
|
3235
|
+
* it and the channel-health surface reports it. A daemon that goes quiet to re-check its ownership
|
|
3236
|
+
* still holds the key, so without this the space would be told a responder is up while nothing is
|
|
3237
|
+
* bound, a readiness lie of exactly the kind #1318 is about, just pointed the other way. Keeping
|
|
3238
|
+
* the row (rather than deleting it) is deliberate: the shard is still claimed, so no third daemon
|
|
3239
|
+
* should be invited in; what is being withdrawn is only the claim to be answering. */
|
|
3240
|
+
async markDeliveryLeaseNotReady(shardIndex, revision) {
|
|
3241
|
+
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(false), revision);
|
|
3242
|
+
}
|
|
2939
3243
|
/** Renew the held lease (CAS `kv.update` against `revision`, keeping `ready:true`) to refresh it before
|
|
2940
3244
|
* the bucket TTL expires it. Returns the new revision. Throws if the revision moved (lost the lease —
|
|
2941
3245
|
* the daemon should exit). */
|
|
@@ -2943,25 +3247,54 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2943
3247
|
return (await this.deliveryRegistry()).update(leaseKey(shardIndex), this.encodeLease(true), revision);
|
|
2944
3248
|
}
|
|
2945
3249
|
/** Release the held lease on clean shutdown so a replacement daemon re-acquires immediately (best
|
|
2946
|
-
* effort
|
|
2947
|
-
|
|
3250
|
+
* effort, a crash just lets the bucket TTL expire it).
|
|
3251
|
+
*
|
|
3252
|
+
* THE REVISION IS WHAT MAKES THIS A RELEASE RATHER THAN A DELETE. An unconditional delete removes
|
|
3253
|
+
* whatever row is there, and by shutdown time the row is not necessarily still ours: the exit
|
|
3254
|
+
* paths that matter most are precisely the ones where another daemon has taken the shard, so the
|
|
3255
|
+
* departing process would delete the REPLACEMENT's lease on its way out and leave the shard with
|
|
3256
|
+
* no holder at all. Passing the revision this endpoint last owned makes the delete a compare-and-
|
|
3257
|
+
* swap (`previousSeq` becomes JetStream's `ExpectedLastSubjectSequence`), so a row that has moved
|
|
3258
|
+
* on is left alone.
|
|
3259
|
+
*
|
|
3260
|
+
* THE ARGUMENT IS REQUIRED, AND EXPLICITLY NULLABLE RATHER THAN OPTIONAL. `undefined` means "this
|
|
3261
|
+
* process no longer holds a revision it can argue for", which is the takeover paths' honest
|
|
3262
|
+
* answer and correctly releases nothing, the bucket TTL is the crash-safe authority and expires
|
|
3263
|
+
* a genuinely stale row. But if that were the DEFAULT, every existing `releaseDeliveryLease(0)`
|
|
3264
|
+
* call site would keep compiling and silently stop releasing: the same omission hole
|
|
3265
|
+
* `standaloneConnectOpts` closed by deleting its `= {}`. Measured, not theorised, this landed as
|
|
3266
|
+
* a red in `smoke:delivery-lease`, where a caller that genuinely held the lease released nothing
|
|
3267
|
+
* and the next acquire was refused. A caller must now say which it means. */
|
|
3268
|
+
async releaseDeliveryLease(shardIndex, revision) {
|
|
3269
|
+
if (revision === undefined)
|
|
3270
|
+
return;
|
|
2948
3271
|
try {
|
|
2949
|
-
await (await this.deliveryRegistry()).delete(leaseKey(shardIndex));
|
|
3272
|
+
await (await this.deliveryRegistry()).delete(leaseKey(shardIndex), { previousSeq: revision });
|
|
2950
3273
|
}
|
|
2951
3274
|
catch {
|
|
2952
3275
|
// Intentionally best-effort for EVERY failure: the lease TTL is the crash-safe release authority,
|
|
2953
|
-
// and clean shutdown must continue even when the broker is already gone or draining.
|
|
3276
|
+
// and clean shutdown must continue even when the broker is already gone or draining. A refused
|
|
3277
|
+
// CAS lands here too, which is the correct outcome, someone else owns the row.
|
|
2954
3278
|
}
|
|
2955
3279
|
}
|
|
2956
3280
|
/** Read a shard's delivery lease (the daemon-availability signal), or `undefined` if none is live.
|
|
2957
3281
|
* READ-ONLY surface — drives Component 6's `cotal_channels` delivery-health field (an agent reads it
|
|
2958
3282
|
* under its own cred, which holds lease-bucket read but no write). */
|
|
2959
3283
|
async readDeliveryLease(shardIndex) {
|
|
3284
|
+
return (await this.readDeliveryLeaseEntry(shardIndex))?.info;
|
|
3285
|
+
}
|
|
3286
|
+
/** The lease row AND the KV revision it is at. The revision is the CAS token every renew and the
|
|
3287
|
+
* CAS release are argued against, so a caller re-establishing ownership after a failed renew
|
|
3288
|
+
* needs the BROKER's sequence, not the one it last cached: a renew can fail with its write
|
|
3289
|
+
* already applied (a lost reply, a reconnect mid-request), which leaves the cached revision one
|
|
3290
|
+
* behind forever and every subsequent CAS refused over a sequence this process itself moved ,
|
|
3291
|
+
* read as somebody else's takeover, which is the #1318 misreading in a second costume. */
|
|
3292
|
+
async readDeliveryLeaseEntry(shardIndex) {
|
|
2960
3293
|
const e = await (await this.deliveryRegistry()).get(leaseKey(shardIndex));
|
|
2961
3294
|
if (!e || e.operation === "DEL" || e.operation === "PURGE")
|
|
2962
3295
|
return undefined;
|
|
2963
3296
|
try {
|
|
2964
|
-
return e.json();
|
|
3297
|
+
return { info: e.json(), revision: e.revision };
|
|
2965
3298
|
}
|
|
2966
3299
|
catch {
|
|
2967
3300
|
return undefined;
|
|
@@ -3002,6 +3335,53 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3002
3335
|
}
|
|
3003
3336
|
return this.managerLeaseKv;
|
|
3004
3337
|
}
|
|
3338
|
+
/** Take or keep the per-SPACE daemon-credential renewal lease (#1634), returning whether THIS
|
|
3339
|
+
* instance now holds it. One atomic CAS `create` per pass: it succeeds for whoever arrives first
|
|
3340
|
+
* and throws for everyone else, so exactly one manager remints even when several share the
|
|
3341
|
+
* daemon's store. The holder re-`update`s its own key by revision, which both keeps it and proves
|
|
3342
|
+
* it never lost it. Losing the CAS is an ordinary outcome (a peer holds it) and returns false; it
|
|
3343
|
+
* never fails a start. A crashed holder's key TTL-expires with the bucket, so the next pass hands
|
|
3344
|
+
* the lease to a survivor with no operator step. */
|
|
3345
|
+
async holdDaemonRenewalLease(instanceId) {
|
|
3346
|
+
const kv = await this.managerLeaseRegistry();
|
|
3347
|
+
const held = this.daemonRenewalLeaseRevision;
|
|
3348
|
+
if (held !== undefined) {
|
|
3349
|
+
try {
|
|
3350
|
+
this.daemonRenewalLeaseRevision = await kv.update(MANAGER_RENEWAL_LEASE_KEY, this.encodeDaemonRenewalLease(instanceId), held);
|
|
3351
|
+
return true;
|
|
3352
|
+
}
|
|
3353
|
+
catch {
|
|
3354
|
+
// The revision moved (our key TTL-expired and a peer took it). Re-contend below rather than
|
|
3355
|
+
// keep reminting on a lease we no longer hold.
|
|
3356
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
try {
|
|
3360
|
+
this.daemonRenewalLeaseRevision = await kv.create(MANAGER_RENEWAL_LEASE_KEY, this.encodeDaemonRenewalLease(instanceId));
|
|
3361
|
+
return true;
|
|
3362
|
+
}
|
|
3363
|
+
catch {
|
|
3364
|
+
return false; // another manager holds it: exactly one owner is the point
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
/** Release the renewal lease on a clean stop so a peer takes over at once rather than at the TTL.
|
|
3368
|
+
* CAS-guarded, so a lease we already lost is never deleted out from under its new holder. */
|
|
3369
|
+
async releaseDaemonRenewalLease() {
|
|
3370
|
+
const held = this.daemonRenewalLeaseRevision;
|
|
3371
|
+
this.daemonRenewalLeaseRevision = undefined;
|
|
3372
|
+
if (held === undefined)
|
|
3373
|
+
return;
|
|
3374
|
+
try {
|
|
3375
|
+
await (await this.managerLeaseRegistry()).delete(MANAGER_RENEWAL_LEASE_KEY, { previousSeq: held });
|
|
3376
|
+
}
|
|
3377
|
+
catch {
|
|
3378
|
+
// Best-effort, like releaseManagerLease: a moved revision means it is not ours, and a broker
|
|
3379
|
+
// failure is recovered by the bucket TTL. Shutdown must not claim deletion.
|
|
3380
|
+
}
|
|
3381
|
+
}
|
|
3382
|
+
encodeDaemonRenewalLease(instanceId) {
|
|
3383
|
+
return new TextEncoder().encode(JSON.stringify({ instanceId, since: Date.now() }));
|
|
3384
|
+
}
|
|
3005
3385
|
encodeManagerLease(info) {
|
|
3006
3386
|
return new TextEncoder().encode(JSON.stringify(info));
|
|
3007
3387
|
}
|
|
@@ -3326,14 +3706,35 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3326
3706
|
async startPlane3(aclFor, opts = {}) {
|
|
3327
3707
|
if (!this.js)
|
|
3328
3708
|
throw new Error("endpoint not started");
|
|
3329
|
-
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness };
|
|
3709
|
+
this.plane3 = { aclFor, reloadMembershipCreds: opts.reloadMembershipCreds, evictPrincipal: opts.evictPrincipal, planeConnLiveness: opts.planeConnLiveness, principalLiveness: opts.principalLiveness, reloadStoreIdentity: opts.reloadStoreIdentity };
|
|
3330
3710
|
await this.armPlane3();
|
|
3331
3711
|
}
|
|
3332
3712
|
/** Serve one runtime durable-membership control request (the server-side delivery daemon). The caller
|
|
3333
3713
|
* id is the authenticated subject sender ({@link serveControl} fail-closes on a mismatch). Validation
|
|
3334
3714
|
* is against the durable ACL registry — the SAME KV the reader re-auths against (single source of
|
|
3335
3715
|
* truth, no in-memory ledger to drift). */
|
|
3716
|
+
/** Whether an ALREADY-DISPATCHED unit of Plane-3 work may still take effect.
|
|
3717
|
+
*
|
|
3718
|
+
* Unsubscribing stops NEW work; it cannot recall work already in flight. A handler that entered
|
|
3719
|
+
* before {@link quiescePlane3} and awaited broker I/O inside it resumes AFTER the freeze, and a
|
|
3720
|
+
* reviewer traced the ordering that makes that a split rather than a latency blip: the loser
|
|
3721
|
+
* accepts a unit and awaits, the loser is descheduled, the successor acquires the shard and flips
|
|
3722
|
+
* its lease READY, then the loser resumes and answers or acks for a shard it no longer holds.
|
|
3723
|
+
* Subscription counts and parked-pull readings cannot see it, because the effect is the reply and
|
|
3724
|
+
* the ack rather than the binding.
|
|
3725
|
+
*
|
|
3726
|
+
* So each work path re-asks HERE, at the point of effect, after its awaits and before it acts.
|
|
3727
|
+
* The lease keeps one ROW, not one SERVER; this is the half that keeps one server. An unacked
|
|
3728
|
+
* message is not lost by refusing: a stopped consumer redelivers it to whoever holds the shard
|
|
3729
|
+
* next, which is the same property quiescing already relies on. */
|
|
3730
|
+
plane3MayAct() {
|
|
3731
|
+
return !this.plane3Quiesced;
|
|
3732
|
+
}
|
|
3336
3733
|
async handleDeliveryControl(req) {
|
|
3734
|
+
// FENCE: entered before a quiesce, resuming after it. Answering now would put a second server on
|
|
3735
|
+
// this shard's control rail while the winner is already READY.
|
|
3736
|
+
if (!this.plane3MayAct())
|
|
3737
|
+
return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
|
|
3337
3738
|
const caller = req.from.id;
|
|
3338
3739
|
const args = req.args ?? {};
|
|
3339
3740
|
if (req.op === "durableJoin")
|
|
@@ -3352,11 +3753,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3352
3753
|
catch (e) {
|
|
3353
3754
|
return { ok: false, error: e.message };
|
|
3354
3755
|
}
|
|
3355
|
-
|
|
3756
|
+
const memberships = await this.ownerMemberships(caller, uid);
|
|
3757
|
+
// FENCE AFTER THE AWAIT, which is the one that matters. The entry check above cannot catch the
|
|
3758
|
+
// case this exists for: a request admitted WHILE SERVING, then descheduled inside its broker
|
|
3759
|
+
// read, resuming after a successor has taken the shard and reached READY. Re-ask at the point
|
|
3760
|
+
// of effect, so the answer is never served by a daemon that has stood down.
|
|
3761
|
+
if (!this.plane3MayAct())
|
|
3762
|
+
return { ok: false, error: "delivery: this daemon stopped serving this shard while the request was in flight; retry" };
|
|
3763
|
+
return { ok: true, data: { memberships } };
|
|
3356
3764
|
}
|
|
3357
3765
|
return { ok: false, error: `op "${req.op}" not supported on the delivery control service` };
|
|
3358
3766
|
}
|
|
3359
|
-
/** Validate the channel ARG shape only
|
|
3767
|
+
/** Validate the channel ARG shape only: non-blank, valid, concrete (NO ACL check, that is op-specific).
|
|
3360
3768
|
* Returns the channel on success or a ControlReply error to short-circuit. */
|
|
3361
3769
|
checkDurableChannelArg(args, op) {
|
|
3362
3770
|
const channel = typeof args.channel === "string" ? args.channel.trim() : "";
|
|
@@ -3539,6 +3947,85 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3539
3947
|
const max = this.nc?.info?.max_payload ?? 1_048_576;
|
|
3540
3948
|
return Math.max(1, Math.floor(max * 0.9));
|
|
3541
3949
|
}
|
|
3950
|
+
/** Stop serving Plane-3 WITHOUT tearing down the connection, so a daemon that has just learned its
|
|
3951
|
+
* lease may no longer be its own can stop acting on the shard while it finds out for certain.
|
|
3952
|
+
*
|
|
3953
|
+
* A COMPARE-AND-SWAP KEEPS ONE LEASE ROW; IT DOES NOT KEEP ONE SERVER. That distinction is the
|
|
3954
|
+
* reason this exists, and it was a review finding. When a renew fails, the daemon re-reads the
|
|
3955
|
+
* key and may then re-acquire it, and across that read-then-create it was still consuming the
|
|
3956
|
+
* fan-out durable, still running the inbox reader, and still answering ctl.delivery. If a
|
|
3957
|
+
* replacement acquired the shard in that window, both processes served the same durables until
|
|
3958
|
+
* the loser's create was refused and its teardown finished. The old code did not have this
|
|
3959
|
+
* window, because it began shutting down on the first renew failure; treating that failure as a
|
|
3960
|
+
* question instead of a verdict is right, but asking the question while still serving is not.
|
|
3961
|
+
*
|
|
3962
|
+
* So the daemon goes quiet FIRST and re-arms only once it has proof: `held` on a re-read, or a
|
|
3963
|
+
* won atomic create. `unknown` stays quiet, the whole point is that not being able to ask is not
|
|
3964
|
+
* permission to keep acting. Quiescing costs delivery latency for a few seconds; the alternative
|
|
3965
|
+
* costs a SPLIT durable, which is a correctness failure rather than an availability one.
|
|
3966
|
+
*
|
|
3967
|
+
* Deliberately not `stop()`: the connection, the lease KV handles and the control rails must stay
|
|
3968
|
+
* up, because the daemon still has to ask the broker who owns the shard. */
|
|
3969
|
+
async quiescePlane3() {
|
|
3970
|
+
this.plane3Quiesced = true;
|
|
3971
|
+
if (this.deliveryServeSub) {
|
|
3972
|
+
try {
|
|
3973
|
+
this.deliveryServeSub.unsubscribe();
|
|
3974
|
+
}
|
|
3975
|
+
catch { /* already dead */ }
|
|
3976
|
+
this.deliveryServeSub = undefined;
|
|
3977
|
+
}
|
|
3978
|
+
if (this.deliveryAdminServeSub) {
|
|
3979
|
+
try {
|
|
3980
|
+
this.deliveryAdminServeSub.unsubscribe();
|
|
3981
|
+
}
|
|
3982
|
+
catch { /* already dead */ }
|
|
3983
|
+
this.deliveryAdminServeSub = undefined;
|
|
3984
|
+
}
|
|
3985
|
+
// Stopping the consumers ends the `for await` loops that drive fan-out and the reader. In-flight
|
|
3986
|
+
// messages are NOT acked by a stopped consumer, so they redeliver to whoever holds the shard
|
|
3987
|
+
// next: quiescing loses no message, it only stops this process from claiming them.
|
|
3988
|
+
for (const msgs of this.streamMsgs.splice(0)) {
|
|
3989
|
+
try {
|
|
3990
|
+
msgs.stop();
|
|
3991
|
+
}
|
|
3992
|
+
catch { /* already draining */ }
|
|
3993
|
+
}
|
|
3994
|
+
}
|
|
3995
|
+
/** Whether Plane-3 is currently quiesced: unbound, and refusing to re-bind until ownership is proven.
|
|
3996
|
+
* Read-only. The flag is the daemon's own answer to "am I serving this shard", so a cell that asserts
|
|
3997
|
+
* a recovery worked can check the endpoint's state rather than inferring it from a log line. */
|
|
3998
|
+
plane3IsQuiesced() {
|
|
3999
|
+
return this.plane3Quiesced;
|
|
4000
|
+
}
|
|
4001
|
+
/** Resume serving Plane-3 after {@link quiescePlane3}, once ownership has been re-established.
|
|
4002
|
+
* Idempotent, and a no-op when the daemon was never quiesced.
|
|
4003
|
+
*
|
|
4004
|
+
* The flag is cleared ONLY after every binding is up, and restored if any of them throws. Clearing
|
|
4005
|
+
* it first looks equivalent and is not: `armPlane3` binds in four stages (`manager`, the two control
|
|
4006
|
+
* responders, the fan-out consumer, the reader), so a failure at any one leaves the endpoint recorded
|
|
4007
|
+
* as un-quiesced while some of those are missing. From there every later `rearmPlane3` returns at the
|
|
4008
|
+
* `!plane3Quiesced` guard WITHOUT attempting to bind, and the caller goes on to flip the lease READY.
|
|
4009
|
+
* That is a readiness lie surviving a transient broker error, the daemon claims a responder it does
|
|
4010
|
+
* not have, which is the #1318 outage wearing the readiness flag instead of the exit path. */
|
|
4011
|
+
async rearmPlane3() {
|
|
4012
|
+
if (!this.plane3Quiesced)
|
|
4013
|
+
return;
|
|
4014
|
+
// `armPlane3` refuses while quiesced (a reconnect must not resume mid-question), so the flag is
|
|
4015
|
+
// lowered for the attempt and RAISED AGAIN on failure. A partial bind leaves the endpoint quiesced,
|
|
4016
|
+
// which is the honest state: not serving, and a later retry will attempt the whole sequence again.
|
|
4017
|
+
this.plane3Quiesced = false;
|
|
4018
|
+
try {
|
|
4019
|
+
await this.armPlane3();
|
|
4020
|
+
}
|
|
4021
|
+
catch (e) {
|
|
4022
|
+
this.plane3Quiesced = true;
|
|
4023
|
+
// Drop whatever DID bind. Leaving half-bound responders up would answer control requests for a
|
|
4024
|
+
// shard this process has just declared itself not to be serving.
|
|
4025
|
+
this.quiescePlane3();
|
|
4026
|
+
throw e;
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
3542
4029
|
/** (Re)bind the Plane-3 fan-out writer + trusted reader. Idempotent — the durables resume from their
|
|
3543
4030
|
* cursor. Called by {@link startPlane3} once AND by {@link connectAndBind} on every (re)connect, so
|
|
3544
4031
|
* the delivery daemon's reconnect RE-ARMS the backstop + the ctl.delivery responder. Without this, a broker blip would silently kill
|
|
@@ -3547,6 +4034,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3547
4034
|
async armPlane3() {
|
|
3548
4035
|
if (!this.plane3 || !this.js)
|
|
3549
4036
|
return;
|
|
4037
|
+
// A quiesced endpoint must not be re-armed by a RECONNECT: the reconnect path calls this too,
|
|
4038
|
+
// and silently resuming there would restore exactly the double-serving this guards against
|
|
4039
|
+
// while the ownership question is still open.
|
|
4040
|
+
if (this.plane3Quiesced)
|
|
4041
|
+
return;
|
|
3550
4042
|
await this.manager(); // the manager runs consume:false, so this.jsm is lazy — ensure it
|
|
3551
4043
|
this.armDeliveryControl();
|
|
3552
4044
|
await this.runFanout();
|
|
@@ -3587,6 +4079,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3587
4079
|
* feed's rw connection, and reply with proof (identities + the adopted JWT windows) — or a
|
|
3588
4080
|
* structured failure (e.g. the file was never re-signed), never a silent partial. */
|
|
3589
4081
|
async handleDeliveryAdmin(req) {
|
|
4082
|
+
// FENCE: same reason as the runtime rail above.
|
|
4083
|
+
if (!this.plane3MayAct())
|
|
4084
|
+
return { ok: false, error: "delivery: this daemon is not serving this shard (it is re-checking ownership); retry" };
|
|
3590
4085
|
if (req.op === "reloadCreds") {
|
|
3591
4086
|
// The renewal owner's EXPECTED-generation tokens (SHA-256 of each JWT it re-signed), per
|
|
3592
4087
|
// component. A missing entry means "no expectation" (the passive backstop still adopts).
|
|
@@ -3666,6 +4161,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3666
4161
|
return { ok: false, error: e.message };
|
|
3667
4162
|
}
|
|
3668
4163
|
}
|
|
4164
|
+
if (req.op === "reloadStoreIdentity") {
|
|
4165
|
+
// Proof that this daemon reloads standing creds from ONE named store. The manager
|
|
4166
|
+
// compares it to its remint store before every renewal pass. Absent hook
|
|
4167
|
+
// is a daemon that cannot name its store, which is itself a divergent composition.
|
|
4168
|
+
if (!this.plane3?.reloadStoreIdentity)
|
|
4169
|
+
return { ok: false, error: "reloadStoreIdentity: this daemon did not name the SecretStore it reloads from" };
|
|
4170
|
+
try {
|
|
4171
|
+
const identity = this.plane3.reloadStoreIdentity();
|
|
4172
|
+
// Round-trip through the closed parser so a hook cannot smuggle extra fields onto the rail.
|
|
4173
|
+
return { ok: true, data: parseSecretStoreIdentity(identity) };
|
|
4174
|
+
}
|
|
4175
|
+
catch (e) {
|
|
4176
|
+
return { ok: false, error: e.message };
|
|
4177
|
+
}
|
|
4178
|
+
}
|
|
3669
4179
|
return { ok: false, error: `op "${req.op}" not supported on the delivery admin service` };
|
|
3670
4180
|
}
|
|
3671
4181
|
/** Fan-out loop: bind the privileged `fanout` durable on CHAT and route each message (routing only —
|
|
@@ -3700,6 +4210,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3700
4210
|
* members within interval; `live` channel → `@mention` targets authorized to read it (ACL only).
|
|
3701
4211
|
* Members KV is scanned FRESH per message (no cache — red-team BLOCKER-1 catch-up correctness). */
|
|
3702
4212
|
async fanOutMessage(m) {
|
|
4213
|
+
// FENCE, and NOT followed by an ack: this unit was dispatched before the quiesce and is resuming
|
|
4214
|
+
// after it, so writing the fan-out now would put entries in member inboxes on behalf of a shard
|
|
4215
|
+
// this daemon no longer serves. Returning WITHOUT acking is the whole point - a stopped consumer
|
|
4216
|
+
// redelivers to whoever holds the shard next, so refusing here loses no message, while acking it
|
|
4217
|
+
// would consume the successor's work.
|
|
4218
|
+
if (!this.plane3MayAct())
|
|
4219
|
+
return;
|
|
3703
4220
|
const parsed = parseSubject(m.subject);
|
|
3704
4221
|
if (!parsed || parsed.kind !== "chat") {
|
|
3705
4222
|
m.ack();
|
|
@@ -3720,6 +4237,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3720
4237
|
} // authenticity (owner must be a real principal, not an old-shape alias)
|
|
3721
4238
|
const seq = m.seq;
|
|
3722
4239
|
const normalizedMsg = authenticatedChannelMessage(msg, channel);
|
|
4240
|
+
// SECOND FENCE, AT THE COMMIT POINT. The entry check above cannot cover this unit's own awaits:
|
|
4241
|
+
// the class read and the member scan below both hit the broker, and a daemon descheduled inside
|
|
4242
|
+
// them can resume after a successor owns the shard. Re-ask immediately before the first WRITE,
|
|
4243
|
+
// and again return without acking so the entry redelivers rather than being consumed here.
|
|
4244
|
+
if (!this.plane3MayAct())
|
|
4245
|
+
return;
|
|
3723
4246
|
if ((await this.deliveryClassFresh(channel)) === "durable") {
|
|
3724
4247
|
for (const rec of await listMembers(await this.membersRegistry(), { channel })) {
|
|
3725
4248
|
if (rec.owner === msg.from.id)
|
|
@@ -3730,6 +4253,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3730
4253
|
// retired lifecycle's inbox, never the alias's new occupant (SPEC 13.1 cross-plane scoping).
|
|
3731
4254
|
// Store the AUTHENTICATED-channel copy (main's normalization): the durable frame validates
|
|
3732
4255
|
// msg.channel === frame.channel, and payload to/toService are stripped.
|
|
4256
|
+
// Per-copy, because the scan above and every publish below is a broker round trip: a fan-out
|
|
4257
|
+
// to fifty members can straddle a quiesce in its middle. Stopping part-way is safe where
|
|
4258
|
+
// stopping late is not - the entry is never acked here, so the successor redelivers it and
|
|
4259
|
+
// re-sends the whole set; publishDinbox is idempotent per (owner, lifecycle, msgID).
|
|
4260
|
+
if (!this.plane3MayAct())
|
|
4261
|
+
return;
|
|
3733
4262
|
await this.publishDinbox(rec.owner, rec.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "durable-channel", generation: rec.generation });
|
|
3734
4263
|
}
|
|
3735
4264
|
}
|
|
@@ -3751,9 +4280,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3751
4280
|
}
|
|
3752
4281
|
if (!row || !channelInAllow(row.allowSubscribe, channel))
|
|
3753
4282
|
continue; // @mention can't bypass the read ACL
|
|
4283
|
+
if (!this.plane3MayAct())
|
|
4284
|
+
return; // same rule on the live-mention path
|
|
3754
4285
|
await this.publishDinbox(owner, row.lifecycleUid, { msg: normalizedMsg, channel, seq, reason: "live-mention", generation: 0 });
|
|
3755
4286
|
}
|
|
3756
4287
|
}
|
|
4288
|
+
// FINAL FENCE, BEFORE THE ACK ITSELF, which is the real commit: acking tells the stream this
|
|
4289
|
+
// shard's work is done. A daemon that stopped serving mid-fan-out must not make that claim, or
|
|
4290
|
+
// the successor never sees the entry and the message is lost outright rather than duplicated.
|
|
4291
|
+
if (!this.plane3MayAct())
|
|
4292
|
+
return;
|
|
3757
4293
|
m.ack();
|
|
3758
4294
|
}
|
|
3759
4295
|
/** Trusted-reader loop: bind the single privileged `reader` durable over `dinbox.>` and re-authorize
|
|
@@ -3787,6 +4323,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3787
4323
|
* revoked/narrowed ACL or out-of-interval seq; on transfer success, ack the mixed entry (durability
|
|
3788
4324
|
* has moved to DLV — an §8 equivalent per-member at-least-once mechanism). The agent acks DLV. */
|
|
3789
4325
|
async readerHandle(m) {
|
|
4326
|
+
// FENCE, same rule as the fan-out writer: no effect and no ack once this daemon has stopped
|
|
4327
|
+
// serving the shard. The entry redelivers to the holder rather than being consumed here.
|
|
4328
|
+
if (!this.plane3MayAct())
|
|
4329
|
+
return;
|
|
3790
4330
|
const pr = parseDinboxPrincipal(m.subject);
|
|
3791
4331
|
if (!pr) {
|
|
3792
4332
|
m.ack();
|
|
@@ -3836,6 +4376,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3836
4376
|
return;
|
|
3837
4377
|
}
|
|
3838
4378
|
}
|
|
4379
|
+
// COMMIT-POINT FENCE. Everything above this line is broker I/O - the ACL read and the member
|
|
4380
|
+
// re-read - so the entry check at the top of the handler is stale by now if this unit was
|
|
4381
|
+
// descheduled inside either. The transfer below is the effect that must not happen on behalf of
|
|
4382
|
+
// a shard we no longer serve; returning without acking leaves it for the holder.
|
|
4383
|
+
if (!this.plane3MayAct())
|
|
4384
|
+
return;
|
|
3839
4385
|
try {
|
|
3840
4386
|
// DLV has no original chat subject, so preserve the channel the trusted fan-out reader derived
|
|
3841
4387
|
// from CHAT. Never let the publisher-controlled payload label choose connector attention.
|
|
@@ -3866,6 +4412,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3866
4412
|
m.nak(2000);
|
|
3867
4413
|
return;
|
|
3868
4414
|
}
|
|
4415
|
+
// The ack is the last commit: the transfer above already happened, so the only question left is
|
|
4416
|
+
// whether THIS daemon may retire the source entry. If it stopped serving mid-transfer, leave the
|
|
4417
|
+
// entry pending - the successor re-transfers and stream-wide dedupe on the msgID collapses the
|
|
4418
|
+
// duplicate, which is the recoverable direction. Acking here is the unrecoverable one.
|
|
4419
|
+
if (!this.plane3MayAct())
|
|
4420
|
+
return;
|
|
3869
4421
|
m.ack();
|
|
3870
4422
|
}
|
|
3871
4423
|
/** Agent-side: bind + pump our pre-created Plane-3 DELIVER durable (`dlv_<id>`). Every message here is
|
|
@@ -4605,18 +5157,99 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4605
5157
|
// the publisher — this covers stop(), setStatus("offline"), and any future offline publish site, so
|
|
4606
5158
|
// the raw KV record is compliant, not only the observer-side roster materialization.
|
|
4607
5159
|
const record = this.status === "offline" ? this.toOffline(p) : p;
|
|
4608
|
-
|
|
5160
|
+
// #1356: this put can still be in flight when a teardown runs, because a rebind reaches
|
|
5161
|
+
// publishPresence through onPresenceBucketEmpty and no teardown awaits that flight. Take the same
|
|
5162
|
+
// epoch fence {@link startPresenceWatch} takes, so a put that outlives its epoch still throws to
|
|
5163
|
+
// its caller but no longer writes presence-refusal state belonging to a later connection. Without
|
|
5164
|
+
// it, a heartbeat put (default 2s) whose ~5s JetStream timeout elapses after a rebuild plants a
|
|
5165
|
+
// refusal on the connection that just published successfully.
|
|
5166
|
+
const epoch = this.presenceEpoch;
|
|
5167
|
+
try {
|
|
5168
|
+
await this.kv.put(this.card.id, JSON.stringify(record));
|
|
5169
|
+
}
|
|
5170
|
+
catch (e) {
|
|
5171
|
+
// #1356: a broker can put this bucket into a state where it refuses every write and never
|
|
5172
|
+
// recovers, and the write is the ONLY thing that fails — open and watch both still succeed, so
|
|
5173
|
+
// nothing else here notices. Record WHEN the refusals started, at the one site that knows the
|
|
5174
|
+
// failing write was a presence write; a caller cannot infer that from the generic `warning`
|
|
5175
|
+
// stream, which carries any recoverable error.
|
|
5176
|
+
if (epoch === this.presenceEpoch && !this.stopped) {
|
|
5177
|
+
this.presenceWriteFailingSince ??= Date.now();
|
|
5178
|
+
this.lastPresenceWriteError = e?.message ?? String(e);
|
|
5179
|
+
}
|
|
5180
|
+
throw e;
|
|
5181
|
+
}
|
|
5182
|
+
// A late SUCCESS is the same hazard mirrored, and this fence answers only the cross-epoch half
|
|
5183
|
+
// of it: a success belonging to a retired epoch cannot erase a refusal the current one
|
|
5184
|
+
// established from its own evidence. It does NOT order puts within a single epoch, because it
|
|
5185
|
+
// compares epoch identity rather than which put is the latest evidence, so an earlier put that
|
|
5186
|
+
// succeeds late still clears a later put's refusal. Heartbeats run at 2s against a ~5s put
|
|
5187
|
+
// timeout, so that overlap is routine rather than a corner, and the next failing put re-plants
|
|
5188
|
+
// the record with a fresh `since`. Tracked in #1461, not repaired here.
|
|
5189
|
+
if (epoch !== this.presenceEpoch || this.stopped)
|
|
5190
|
+
return;
|
|
5191
|
+
this.clearPresenceWriteFailure();
|
|
4609
5192
|
}
|
|
5193
|
+
/** #1356: drop the presence-refusal record when the connection that OBSERVED those refusals goes
|
|
5194
|
+
* away. "This bucket is refusing writes" is a claim about a specific broker connection; once that
|
|
5195
|
+
* connection is torn down or rebuilt the claim has no remaining basis, and a later failure on a
|
|
5196
|
+
* fresh connection must establish it again from its own evidence.
|
|
5197
|
+
*
|
|
5198
|
+
* Cleared at the SOURCE rather than guarded at each reader, because a guard protects one consumer
|
|
5199
|
+
* and clearing protects every consumer, including ones not yet written. Measured: without this, a
|
|
5200
|
+
* failed bind against an unreachable server still reported the presence-refusal sentence while the
|
|
5201
|
+
* endpoint's own `connectionIssue` already said "connection refused". */
|
|
5202
|
+
clearPresenceWriteFailure() {
|
|
5203
|
+
this.presenceWriteFailingSince = undefined;
|
|
5204
|
+
this.lastPresenceWriteError = undefined;
|
|
5205
|
+
}
|
|
5206
|
+
/** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
|
|
5207
|
+
* last publish succeeded. Cleared by the first successful write, so a survived blip reads as
|
|
5208
|
+
* healthy and only a SUSTAINED failure carries a duration.
|
|
5209
|
+
*
|
|
5210
|
+
* Presence writes are the CANARY, not the scope: the broker can disable JetStream account-wide
|
|
5211
|
+
* while the NATS connection stays up, so a caller must not read this as "only presence is
|
|
5212
|
+
* affected". It reports what was observed, not how far the fault extends. */
|
|
5213
|
+
presenceWriteFailure() {
|
|
5214
|
+
if (this.presenceWriteFailingSince === undefined)
|
|
5215
|
+
return undefined;
|
|
5216
|
+
return {
|
|
5217
|
+
since: this.presenceWriteFailingSince,
|
|
5218
|
+
forMs: Date.now() - this.presenceWriteFailingSince,
|
|
5219
|
+
error: this.lastPresenceWriteError,
|
|
5220
|
+
bucket: presenceBucket(this.space),
|
|
5221
|
+
};
|
|
5222
|
+
}
|
|
5223
|
+
/** Bind a presence watch on the current connection. Resolves true when the watch was
|
|
5224
|
+
* installed, false when the endpoint stopped or rebuilt while the bind was in flight: that
|
|
5225
|
+
* bind's iterator is released here and nothing is installed, because the epoch that asked
|
|
5226
|
+
* for it is gone and the epoch that replaced it binds its own watch through
|
|
5227
|
+
* {@link connectAndBind}. Without this fence a bind that completes after {@link stop} would
|
|
5228
|
+
* resurrect a watch on a stopped endpoint, and one that completes after a rebuild would
|
|
5229
|
+
* overwrite the fresh epoch's watch with a dead-connection iterator. */
|
|
4610
5230
|
async startPresenceWatch() {
|
|
4611
5231
|
if (!this.kv)
|
|
4612
|
-
return;
|
|
5232
|
+
return false;
|
|
5233
|
+
const epoch = this.presenceEpoch;
|
|
4613
5234
|
let hydrated;
|
|
4614
5235
|
this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
|
|
4615
5236
|
const iter = await this.kv.watch();
|
|
5237
|
+
if (epoch !== this.presenceEpoch) {
|
|
5238
|
+
try {
|
|
5239
|
+
iter.stop();
|
|
5240
|
+
}
|
|
5241
|
+
catch { /* its connection may already be gone */ }
|
|
5242
|
+
hydrated();
|
|
5243
|
+
return false;
|
|
5244
|
+
}
|
|
4616
5245
|
this.presenceWatchIter = iter;
|
|
4617
5246
|
void (async () => {
|
|
4618
5247
|
let ready = false;
|
|
4619
5248
|
for await (const e of iter) {
|
|
5249
|
+
// A rebind bound a newer watch: this one's remaining entries belong to a retired epoch.
|
|
5250
|
+
// Stop consuming them (the rebind stops the iterator too; this ends the loop first).
|
|
5251
|
+
if (this.presenceWatchIter !== iter)
|
|
5252
|
+
break;
|
|
4620
5253
|
this.handleKvEntry(e);
|
|
4621
5254
|
// @nats-io/kv marks the final initial replay entry isUpdate=true. Later updates stay true.
|
|
4622
5255
|
if (!ready && e.isUpdate) {
|
|
@@ -4628,6 +5261,68 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4628
5261
|
}
|
|
4629
5262
|
hydrated();
|
|
4630
5263
|
})().catch((e) => this.emit("error", e));
|
|
5264
|
+
return true;
|
|
5265
|
+
}
|
|
5266
|
+
/**
|
|
5267
|
+
* Replace a presence watch that has gone silent past TTL while the connection is up. The new
|
|
5268
|
+
* ordered consumer starts from the bucket's current last-per-subject state, so a peer that is
|
|
5269
|
+
* heartbeating is re-observed within one replay and a peer that is gone is aged out by the
|
|
5270
|
+
* next sweep exactly as if the watch had never stalled. Rate-limited to one attempt per TTL
|
|
5271
|
+
* per observer, never overlapping, never on a stopped or rebuilding endpoint (those own their
|
|
5272
|
+
* watch through {@link connectAndBind}). A stop or rebuild that lands while the bind is in
|
|
5273
|
+
* flight retires it: {@link startPresenceWatch} releases the late iterator and reports
|
|
5274
|
+
* nothing, since the epoch that was silent no longer exists. A failed bind is reported and
|
|
5275
|
+
* the view stays stale.
|
|
5276
|
+
*/
|
|
5277
|
+
rebindStalePresenceWatch(now) {
|
|
5278
|
+
if (this.stopped || this.reconnecting || !this.kv || !this.nc || this.nc.isClosed())
|
|
5279
|
+
return;
|
|
5280
|
+
if (this.presenceRebind || now - this.presenceRebindAt < this.ttlMs)
|
|
5281
|
+
return;
|
|
5282
|
+
this.presenceRebindAt = now;
|
|
5283
|
+
const old = this.presenceWatchIter;
|
|
5284
|
+
const epoch = this.presenceEpoch;
|
|
5285
|
+
this.presenceRebind = (async () => {
|
|
5286
|
+
try {
|
|
5287
|
+
const silentMs = now - this.lastPresenceWatchAt;
|
|
5288
|
+
// Bind the replacement FIRST. A rebind the broker refuses (stream gone, create denied) or
|
|
5289
|
+
// one a held link never answers must leave the old watch in place: on a plain stall that
|
|
5290
|
+
// watch is the one that recovers by itself, and its replay is still guarded against
|
|
5291
|
+
// expired PUTs. Only a successfully bound watch retires its predecessor.
|
|
5292
|
+
const installed = await this.startPresenceWatch();
|
|
5293
|
+
// Retired mid-bind (stop or rebuild moved the epoch): the late iterator is already
|
|
5294
|
+
// released and the old watch was torn down by whoever moved the epoch. Nothing to
|
|
5295
|
+
// retire, nothing to report.
|
|
5296
|
+
if (!installed)
|
|
5297
|
+
return;
|
|
5298
|
+
if (old && old !== this.presenceWatchIter) {
|
|
5299
|
+
try {
|
|
5300
|
+
old.stop();
|
|
5301
|
+
}
|
|
5302
|
+
catch { /* already closed with its consumer */ }
|
|
5303
|
+
}
|
|
5304
|
+
// A bucket with no keys replays nothing, so the new watch cannot refresh
|
|
5305
|
+
// `lastPresenceWatchAt` by delivering. It IS current knowledge: nobody is present. Read
|
|
5306
|
+
// the consumer's initial pending count for that one fact; nats.js's KV watch computed it
|
|
5307
|
+
// from the same `info(true)` it used to place the isUpdate marker.
|
|
5308
|
+
const pending = this.presenceWatchIter?._data?._info?.num_pending;
|
|
5309
|
+
if (pending === 0)
|
|
5310
|
+
await this.onPresenceBucketEmpty();
|
|
5311
|
+
this.emit("warning", new Error(`presence watch silent for ${silentMs}ms with the connection up; rebound it from the bucket's current state`));
|
|
5312
|
+
}
|
|
5313
|
+
catch (e) {
|
|
5314
|
+
// A bind the epoch swap itself rejected (connection drained under it) is not a fault of
|
|
5315
|
+
// the epoch that replaced it; only a refusal on a still-current epoch is reported.
|
|
5316
|
+
if (epoch === this.presenceEpoch)
|
|
5317
|
+
this.emit("error", e);
|
|
5318
|
+
}
|
|
5319
|
+
finally {
|
|
5320
|
+
// An epoch swap already disowned this flight (and may own a successor's by now); only
|
|
5321
|
+
// a flight still in its own epoch clears the slot. Within one epoch there is one flight.
|
|
5322
|
+
if (epoch === this.presenceEpoch)
|
|
5323
|
+
this.presenceRebind = undefined;
|
|
5324
|
+
}
|
|
5325
|
+
})();
|
|
4631
5326
|
}
|
|
4632
5327
|
/** Watch the channel registry: replay existing keys, then stream updates, into the local
|
|
4633
5328
|
* cache. Best-effort — a registry the endpoint can't read leaves the cache empty (effective
|
|
@@ -4667,8 +5362,65 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4667
5362
|
/* keep last good */
|
|
4668
5363
|
}
|
|
4669
5364
|
}
|
|
5365
|
+
/** The watch was just bound onto a bucket with no keys.
|
|
5366
|
+
*
|
|
5367
|
+
* A NON-REGISTERING observer (a `cotal status` probe, a lease checker) has real knowledge:
|
|
5368
|
+
* nobody is present. Every peer still in its roster is known gone (its key is not there to
|
|
5369
|
+
* replay), so it is marked offline now rather than aged out against a delivery that cannot
|
|
5370
|
+
* come; the silence gate is disarmed and the view reads current until the first write lands.
|
|
5371
|
+
* Without that the empty-bucket view relapsed to stale one window later and rebound again on
|
|
5372
|
+
* every window, one consumer create and one warning per TTL for as long as the mesh was empty.
|
|
5373
|
+
*
|
|
5374
|
+
* A REGISTERING observer (the manager) is itself one of the keys that should be there. An
|
|
5375
|
+
* empty bucket under it means the bucket was wiped since its last heartbeat (the stream
|
|
5376
|
+
* recreation), and the same wipe took every peer's record: their absence says the bucket is
|
|
5377
|
+
* new, not that they left. a reviewer reproduced the previous behaviour at default timing:
|
|
5378
|
+
* the rebind landed ~0.9s after the recreation, the observer marked every peer AND ITSELF
|
|
5379
|
+
* offline, and held the view current for up to one heartbeat, a false verdict `cotal ps`
|
|
5380
|
+
* would print as `mesh offline`. So a registering observer re-publishes its own record NOW,
|
|
5381
|
+
* which the new watch delivers, and lets the ordinary per-peer age-out run from that delivery:
|
|
5382
|
+
* a peer that is still heartbeating rewrites its key within its own heartbeat interval and is
|
|
5383
|
+
* re-observed live; one that is gone ages out exactly as after a plain rebind. The roster is
|
|
5384
|
+
* not touched here and the view is not held; the delivery is what makes it current.
|
|
5385
|
+
*
|
|
5386
|
+
* What neither branch covers: a consumer that dies again while the bucket is still empty is
|
|
5387
|
+
* not detectable by silence, so the first write after that is missed until the observer
|
|
5388
|
+
* restarts. */
|
|
5389
|
+
async onPresenceBucketEmpty() {
|
|
5390
|
+
if (this.doRegister) {
|
|
5391
|
+
// Our own key is missing from a bucket we write to: put it back. publishPresence throws on
|
|
5392
|
+
// a refused write; that surfaces through the rebind flight's error path with the epoch
|
|
5393
|
+
// fence intact, and the view stays stale, which is the honest state for an observer that
|
|
5394
|
+
// cannot even record itself.
|
|
5395
|
+
await this.publishPresence();
|
|
5396
|
+
return;
|
|
5397
|
+
}
|
|
5398
|
+
this.markPresenceBucketEmpty();
|
|
5399
|
+
}
|
|
5400
|
+
/** See {@link onPresenceBucketEmpty}: the non-registering branch. */
|
|
5401
|
+
markPresenceBucketEmpty() {
|
|
5402
|
+
this.presenceWatchEmpty = true;
|
|
5403
|
+
this.lastPresenceWatchAt = 0;
|
|
5404
|
+
let changed = false;
|
|
5405
|
+
for (const [id, p] of this.roster) {
|
|
5406
|
+
if (p.status === "offline")
|
|
5407
|
+
continue;
|
|
5408
|
+
const offline = this.toOffline(p);
|
|
5409
|
+
this.roster.set(id, offline);
|
|
5410
|
+
this.emit("presence", { type: "offline", presence: offline });
|
|
5411
|
+
changed = true;
|
|
5412
|
+
}
|
|
5413
|
+
if (changed)
|
|
5414
|
+
this.emit("roster", this.getRoster());
|
|
5415
|
+
this.emitPresenceViewIfChanged();
|
|
5416
|
+
}
|
|
4670
5417
|
handleKvEntry(e) {
|
|
5418
|
+
// Provisioning uses this one reserved key to prove the bucket's max_age is enforced (#404).
|
|
5419
|
+
// It is maintenance traffic, never a peer identity, an offline transition, or view freshness.
|
|
5420
|
+
if (e.key === TTL_RECONCILE_CANARY_KEY)
|
|
5421
|
+
return;
|
|
4671
5422
|
this.lastPresenceWatchAt = Date.now();
|
|
5423
|
+
this.presenceWatchEmpty = false;
|
|
4672
5424
|
if (e.operation === "DEL" || e.operation === "PURGE") {
|
|
4673
5425
|
this.markOffline(e.key);
|
|
4674
5426
|
return;
|
|
@@ -4774,11 +5526,29 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4774
5526
|
// watch freshness; surface the view as stale instead.
|
|
4775
5527
|
if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
|
|
4776
5528
|
this.emitPresenceViewIfChanged();
|
|
5529
|
+
// Staying stale is the right verdict for a held link (#1045), and the wrong END STATE when
|
|
5530
|
+
// the transport is up and the watch's own consumer is what died. Measured on a live deployment
|
|
5531
|
+
// 2026-09-09: the presence stream was deleted and recreated, its sequence restarted, and
|
|
5532
|
+
// every observer's ORDERED consumer re-created itself at the OLD start sequence (nats.js
|
|
5533
|
+
// 3.4.0 resets from its cursor). The broker kept sending idle heartbeats, so the client
|
|
5534
|
+
// never reset again, the iterator never closed, and the manager's roster stayed frozen at
|
|
5535
|
+
// the pre-recreation snapshot for hours: `cotal ps` read every older seat "mesh offline"
|
|
5536
|
+
// and every newer seat "not in roster" while all of them were heartbeating. The same
|
|
5537
|
+
// end state follows a plain consumer delete (an operator, or the 5-minute inactive
|
|
5538
|
+
// threshold after a long stall). Rebind the watch from the bucket's CURRENT state; a held
|
|
5539
|
+
// link's rebind fails or stays silent and the view simply stays stale, as before.
|
|
5540
|
+
this.rebindStalePresenceWatch(now);
|
|
4777
5541
|
return;
|
|
4778
5542
|
}
|
|
4779
5543
|
let changed = false;
|
|
4780
5544
|
for (const [id, p] of this.roster) {
|
|
4781
|
-
|
|
5545
|
+
// A peer's own `ts` always trails the observer's last delivery, so "older than TTL by the
|
|
5546
|
+
// wall clock" alone ages peers out on the tick just before the whole-bucket gate above
|
|
5547
|
+
// trips (#1311's flap; measured as 3 offline verdicts per silence in the rebind suite).
|
|
5548
|
+
// Require that the watch itself delivered for a full TTL after this peer's last heartbeat:
|
|
5549
|
+
// then other peers were heard and this one was not, which is the only silence that is the
|
|
5550
|
+
// peer's rather than the observer's.
|
|
5551
|
+
if (p.status !== "offline" && now - p.ts > this.ttlMs && this.lastPresenceWatchAt - p.ts > this.ttlMs) {
|
|
4782
5552
|
const offline = this.toOffline(p);
|
|
4783
5553
|
this.roster.set(id, offline);
|
|
4784
5554
|
this.emit("presence", { type: "offline", presence: offline });
|
|
@@ -4817,10 +5587,18 @@ function kindFromParsed(kind) {
|
|
|
4817
5587
|
throw new Error(`cannot derive a message kind from subject kind "${kind}"`);
|
|
4818
5588
|
}
|
|
4819
5589
|
}
|
|
4820
|
-
/** Routing fields in the envelope are advisory.
|
|
4821
|
-
*
|
|
5590
|
+
/** Routing fields in the envelope are advisory. The broker forge-locks sender (and for DMs,
|
|
5591
|
+
* recipient) into the subject. Callers MUST already have rejected a missing `from`, an
|
|
5592
|
+
* unparseable subject, or `from.id !== parsed.sender` (SPEC §5). This derives the remaining
|
|
5593
|
+
* routing tokens from the subject for rows that survived — it does not rewrite a mismatched
|
|
5594
|
+
* `from.id`. Live tails, channel backfill, and channel recall skip the mismatch; history
|
|
5595
|
+
* does the same (#388). */
|
|
4822
5596
|
function authenticatedMessage(msg, parsed) {
|
|
4823
|
-
|
|
5597
|
+
if (parsed.kind === "chat")
|
|
5598
|
+
return authenticatedChannelMessage(msg, parsed.rest);
|
|
5599
|
+
if (parsed.kind === "inst")
|
|
5600
|
+
return authenticatedDmMessage(msg, parsed.rest);
|
|
5601
|
+
return msg;
|
|
4824
5602
|
}
|
|
4825
5603
|
function authenticatedChannelMessage(msg, channel) {
|
|
4826
5604
|
if (msg.channel === channel && msg.to === undefined && msg.toService === undefined)
|
|
@@ -4828,6 +5606,49 @@ function authenticatedChannelMessage(msg, channel) {
|
|
|
4828
5606
|
const { to: _to, toService: _toService, ...base } = msg;
|
|
4829
5607
|
return { ...base, channel };
|
|
4830
5608
|
}
|
|
5609
|
+
function authenticatedDmMessage(msg, to) {
|
|
5610
|
+
if (msg.to === to && msg.channel === undefined && msg.toService === undefined)
|
|
5611
|
+
return msg;
|
|
5612
|
+
const { channel: _channel, toService: _toService, ...base } = msg;
|
|
5613
|
+
return { ...base, to };
|
|
5614
|
+
}
|
|
5615
|
+
/** History drain keeps `m.json()` and used to throw the subject away. SPEC §5: on receive, verify
|
|
5616
|
+
* `from.id` equals the subject sender; on mismatch, a missing `from`, or an unparseable delivery
|
|
5617
|
+
* subject, reject and never surface. Fail closed on shape too: a stored JSON `null` or a truthy
|
|
5618
|
+
* non-object `from` must not throw mid-array. Do not echo-drop `from.id === this.card.id`:
|
|
5619
|
+
* god-view history must include the viewer's own sends. */
|
|
5620
|
+
function historyMessageFromDelivery(m) {
|
|
5621
|
+
let raw;
|
|
5622
|
+
try {
|
|
5623
|
+
raw = m.json();
|
|
5624
|
+
}
|
|
5625
|
+
catch {
|
|
5626
|
+
return undefined;
|
|
5627
|
+
}
|
|
5628
|
+
if (!isHistoryDrainEnvelope(raw))
|
|
5629
|
+
return undefined;
|
|
5630
|
+
const parsed = parseSubject(m.subject);
|
|
5631
|
+
if (!parsed || !isPrincipalOwnerToken(parsed.owner))
|
|
5632
|
+
return undefined;
|
|
5633
|
+
if (raw.from.id !== parsed.sender)
|
|
5634
|
+
return undefined;
|
|
5635
|
+
return authenticatedMessage(raw, parsed);
|
|
5636
|
+
}
|
|
5637
|
+
/**
|
|
5638
|
+
* Narrow enough for the type checker and for fail-closed history: object envelope, usable `id`,
|
|
5639
|
+
* object `from`. That is what lets `from.id !== parsed.sender` run without throwing, and what
|
|
5640
|
+
* lets `authenticatedMessage` take the row without a cast.
|
|
5641
|
+
*
|
|
5642
|
+
* Does NOT verify SPEC §5 message shape. It does not require a string `from.id` (the SPEC §5
|
|
5643
|
+
* comparison still rejects a mismatch), exactly one route key, a finite `ts`, a string
|
|
5644
|
+
* `space`, a full EndpointRef `from` (`name`/`role`), or well-formed `parts`. Those belong
|
|
5645
|
+
* to `isCotalMessage` (Plane-3). History must not use that guard: a public
|
|
5646
|
+
* `unicast(..., { parts: [{ kind: "data", data: undefined }] })` serializes to `{kind:"data"}`
|
|
5647
|
+
* and must still surface.
|
|
5648
|
+
*/
|
|
5649
|
+
function isHistoryDrainEnvelope(value) {
|
|
5650
|
+
return isRecord(value) && isUsableMessageId(value.id) && isRecord(value.from);
|
|
5651
|
+
}
|
|
4831
5652
|
function isPlane3DeliveryFrame(value) {
|
|
4832
5653
|
if (!isRecord(value))
|
|
4833
5654
|
return false;
|
|
@@ -5028,8 +5849,14 @@ export function wsServers(servers) {
|
|
|
5028
5849
|
* probe dials; a ws(s) broker is by definition published through an HTTPS edge (CDN tunnel,
|
|
5029
5850
|
* reverse proxy), where TLS + upgrade + INFO + the auth round-trip routinely exceeds 1s cold —
|
|
5030
5851
|
* measured ~60% spurious "not reachable" against a Cloudflare-fronted broker. Callers passing an
|
|
5031
|
-
* explicit `timeoutMs` are untouched.
|
|
5032
|
-
|
|
5852
|
+
* explicit `timeoutMs` are untouched.
|
|
5853
|
+
*
|
|
5854
|
+
* EXPORTED because a caller that judges a probe by WHEN it answered has to compare against the
|
|
5855
|
+
* deadline this function actually handed the probe. The delivery watchdog does exactly that, and
|
|
5856
|
+
* hardcoding 1000 there silently misread every ws broker: honest refusals arrive at 2-5s, past a
|
|
5857
|
+
* budget that was never theirs, and would be classified as this process's starvation rather than
|
|
5858
|
+
* the server's refusal. The budget and the judgment must come from one place. */
|
|
5859
|
+
export function defaultProbeTimeoutMs(servers) {
|
|
5033
5860
|
return wsServers(servers) ? 5000 : 1000;
|
|
5034
5861
|
}
|
|
5035
5862
|
/** Pick the dial function by SCHEME: `ws://`/`wss://` servers go through nats-core's
|