@cotal-ai/core 0.35.0 → 0.37.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 +18 -0
- package/dist/agent-file.d.ts.map +1 -1
- package/dist/agent-file.js +37 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/connector-setup.d.ts +30 -0
- package/dist/connector-setup.d.ts.map +1 -0
- package/dist/connector-setup.js +2 -0
- package/dist/connector-setup.js.map +1 -0
- package/dist/connector.d.ts +31 -3
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js +12 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-action.d.ts +6 -1
- package/dist/endpoint-action.d.ts.map +1 -1
- package/dist/endpoint-action.js +6 -2
- package/dist/endpoint-action.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 +26 -0
- package/dist/endpoint-error.d.ts.map +1 -1
- package/dist/endpoint-error.js +41 -0
- package/dist/endpoint-error.js.map +1 -1
- package/dist/endpoint-grants.d.ts +8 -7
- package/dist/endpoint-grants.d.ts.map +1 -1
- package/dist/endpoint-grants.js +9 -8
- package/dist/endpoint-grants.js.map +1 -1
- package/dist/endpoint-invoke.d.ts.map +1 -1
- package/dist/endpoint-invoke.js +58 -7
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint-serve-kv.d.ts +17 -0
- package/dist/endpoint-serve-kv.d.ts.map +1 -1
- package/dist/endpoint-serve-kv.js +59 -1
- package/dist/endpoint-serve-kv.js.map +1 -1
- package/dist/endpoint-service.d.ts +6 -0
- package/dist/endpoint-service.d.ts.map +1 -1
- package/dist/endpoint-service.js +12 -3
- package/dist/endpoint-service.js.map +1 -1
- package/dist/endpoint.d.ts +90 -15
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +470 -120
- package/dist/endpoint.js.map +1 -1
- package/dist/identity.d.ts +27 -1
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +5 -1
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lifecycle-saga.d.ts.map +1 -1
- package/dist/lifecycle-saga.js +24 -5
- package/dist/lifecycle-saga.js.map +1 -1
- package/dist/lifecycle-state.d.ts +15 -0
- package/dist/lifecycle-state.d.ts.map +1 -1
- package/dist/lifecycle-state.js +60 -0
- package/dist/lifecycle-state.js.map +1 -1
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +19 -7
- package/dist/provision.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/endpoint.js
CHANGED
|
@@ -13,7 +13,7 @@ import { liveKvEntries } from "./kv-scan.js";
|
|
|
13
13
|
import { ARTIFACT_PART_KIND, isArtifactPart } from "./artifact.js";
|
|
14
14
|
import { assertValidName } from "./resolve.js";
|
|
15
15
|
import { createSpaceStreams, dmDurableConfig, dlvDurableConfig, taskDurableConfig, fanoutDurableConfig, inboxReaderConfig, MAX_MSGS_PER_SUBJECT, MANAGER_LEASE_TTL_MS, MANAGER_LEASE_ATTEMPT_MS } from "./streams.js";
|
|
16
|
-
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, } from "@nats-io/jetstream";
|
|
16
|
+
import { jetstream, jetstreamManager, AckPolicy, DeliverPolicy, JetStreamApiCodes, JetStreamApiError, } from "@nats-io/jetstream";
|
|
17
17
|
import {} from "@nats-io/jetstream";
|
|
18
18
|
import { Kvm } from "@nats-io/kv";
|
|
19
19
|
import { Bucket, KvWatchInclude } from "@nats-io/kv/internal";
|
|
@@ -31,7 +31,9 @@ export const DEFAULT_SPACE = "main";
|
|
|
31
31
|
* "connection" ({ connected: boolean }) — true on every successful (re)bind (initial start, manual
|
|
32
32
|
* reconnect, AND background self-heal), false the moment the connection drops (rebuild null window /
|
|
33
33
|
* terminal close). Lets an in-process agent track connectedness off the endpoint's own (re)binds
|
|
34
|
-
* instead of an imperative flag the self-heal path can't reach
|
|
34
|
+
* instead of an imperative flag the self-heal path can't reach; "transport" ({ connected, server? })
|
|
35
|
+
* is the lower-level NATS socket edge, true before the full bind finishes and false during an internal
|
|
36
|
+
* nats.js reconnect without changing `connection` readiness.
|
|
35
37
|
*
|
|
36
38
|
* Callers MUST attach an "error" listener before `start()`: async faults (incl. NATS
|
|
37
39
|
* permission denials, surfaced via `watchStatus`) are emitted as "error", and Node throws
|
|
@@ -105,6 +107,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
105
107
|
jsm;
|
|
106
108
|
kv;
|
|
107
109
|
channelKv;
|
|
110
|
+
/** The presence/channel-registry watches' own handles. Each is an ORDERED push consumer: its idle
|
|
111
|
+
* heartbeat monitor lives on a JS timer independent of the connection, so a drain that doesn't
|
|
112
|
+
* `.stop()` it first leaves the monitor to fire into a closing connection every 30s, throwing
|
|
113
|
+
* `DrainingConnectionError` out of `reset()` on a timer nothing awaits. */
|
|
114
|
+
presenceWatchIter;
|
|
115
|
+
channelWatchIter;
|
|
108
116
|
/** Plane-3 durable-membership registry KV — lazily opened by the privileged delivery daemon (or a
|
|
109
117
|
* short-lived provisioner). */
|
|
110
118
|
membersKv;
|
|
@@ -177,6 +185,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
177
185
|
roster = new Map();
|
|
178
186
|
/** Resolves when the current presence watch has consumed its complete initial KV snapshot. */
|
|
179
187
|
presenceSnapshot = Promise.resolve();
|
|
188
|
+
/**
|
|
189
|
+
* Observer-local age of the last presence-KV delivery (any key, including DEL/PURGE). Distinct
|
|
190
|
+
* from each peer's `ts`: that is the publisher's heartbeat. Whole-bucket silence past TTL is
|
|
191
|
+
* the observer going deaf, not N simultaneous deaths, and sweep must not treat it as the
|
|
192
|
+
* latter (#1045).
|
|
193
|
+
*/
|
|
194
|
+
lastPresenceWatchAt = 0;
|
|
195
|
+
/** Last emitted presence-view freshness. Suppresses duplicate `presence-view` events. */
|
|
196
|
+
presenceViewFresh = true;
|
|
180
197
|
status = "idle";
|
|
181
198
|
activity;
|
|
182
199
|
/** Mirror of the connector's authoritative attention state, published in presence (advisory). The
|
|
@@ -198,6 +215,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
198
215
|
backoffResolve;
|
|
199
216
|
backoffTimer;
|
|
200
217
|
retryMs = 3000;
|
|
218
|
+
/** Consecutive failed rebuilds, driving {@link nextRetryDelayMs}. Reset on every success. */
|
|
219
|
+
retryAttempt = 0;
|
|
201
220
|
/** The connection's authenticated nkey — dev: the creds' identity; user mode: the per-connection
|
|
202
221
|
* ephemeral. Distinct from the {@link owner}+{@link actor} principal: it names the CONNECTION (the
|
|
203
222
|
* broker-authenticated user), and scopes the private reply inbox (`_INBOX_<connId>`) + the credId
|
|
@@ -369,10 +388,15 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
369
388
|
}
|
|
370
389
|
async start() {
|
|
371
390
|
await this.connectAndBind();
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
391
|
+
// stop() can finish while the INITIAL connectAndBind is still awaiting its broker work. The
|
|
392
|
+
// rebuild path already closes that race; initial start needs the same fence or the late bind
|
|
393
|
+
// leaves a fresh nc, heartbeat, consumers, and presence live on an endpoint already stopped.
|
|
394
|
+
// superviseConnection below: nats.js auto-reconnects transient drops, and when it exhausts its
|
|
395
|
+
// attempts and the connection closes for good we rebuild from scratch, so an in-process agent
|
|
396
|
+
// (e.g. the OpenCode plugin) recovers without a host respawn. Armed only after a successful
|
|
397
|
+
// first connect; a first-connect failure throws to the caller's connect-retry loop instead.
|
|
398
|
+
if (await this.tearDownIfStopped())
|
|
399
|
+
return;
|
|
376
400
|
this.superviseConnection();
|
|
377
401
|
}
|
|
378
402
|
/** How far ahead of the current bearer's `exp` a refresh fires, and how soon a FAILED refresh
|
|
@@ -380,9 +404,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
380
404
|
* so whatever `currentBearer` holds is what every attempt presents). */
|
|
381
405
|
static BEARER_REFRESH_MARGIN_MS = 60_000;
|
|
382
406
|
static BEARER_RETRY_MS = 15_000;
|
|
407
|
+
/**
|
|
408
|
+
* A condition the endpoint is already surviving. Node rethrows `error` when no listener is
|
|
409
|
+
* attached, so emitting retry notices on `error` killed hosts that the endpoint intended to
|
|
410
|
+
* keep running (#891). `warning` is observable and never fatal without a listener.
|
|
411
|
+
*/
|
|
412
|
+
emitRecoverable(err) {
|
|
413
|
+
this.emit("warning", err);
|
|
414
|
+
}
|
|
383
415
|
/** Fetch a fresh bearer from the source, pin its principal to ours, arm the next refresh. On a
|
|
384
416
|
* fetch/principal failure: THROWS when `initial` (start() must fail loud before first connect);
|
|
385
|
-
* otherwise emits "
|
|
417
|
+
* otherwise emits "warning" and retries — the live connection keeps working until its current JWT
|
|
386
418
|
* expiry, so a dead auth service is loud without instantly dropping the mesh. */
|
|
387
419
|
async refreshBearer(initial = false) {
|
|
388
420
|
try {
|
|
@@ -396,7 +428,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
396
428
|
catch (e) {
|
|
397
429
|
if (initial)
|
|
398
430
|
throw e;
|
|
399
|
-
this.
|
|
431
|
+
this.emitRecoverable(new Error(`bearer refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current token's expiry if the auth service stays down`));
|
|
400
432
|
this.armBearerRefresh(CotalEndpoint.BEARER_RETRY_MS);
|
|
401
433
|
}
|
|
402
434
|
}
|
|
@@ -464,7 +496,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
464
496
|
catch (e) {
|
|
465
497
|
if (initial)
|
|
466
498
|
throw e;
|
|
467
|
-
this.
|
|
499
|
+
this.emitRecoverable(new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
|
|
468
500
|
this.armCredsRefresh(CotalEndpoint.CREDS_RETRY_MS);
|
|
469
501
|
}
|
|
470
502
|
}
|
|
@@ -544,7 +576,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
544
576
|
await this.swapConnectionOntoFreshCreds();
|
|
545
577
|
}
|
|
546
578
|
catch (e) {
|
|
547
|
-
this.
|
|
579
|
+
this.emitRecoverable(new Error(`creds refresh failed (${e instanceof Error ? e.message : String(e)}) - retrying; this connection dies at its current JWT's expiry if renewal keeps failing`));
|
|
548
580
|
this.armCredsRefresh(CotalEndpoint.CREDS_RETRY_MS);
|
|
549
581
|
}
|
|
550
582
|
}
|
|
@@ -619,6 +651,23 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
619
651
|
if (stale)
|
|
620
652
|
await this.refreshCreds(!this.currentCreds);
|
|
621
653
|
}
|
|
654
|
+
// Never dial with material we can already prove is dead. The refresh above is best-effort (a
|
|
655
|
+
// failed renewal emits and retries rather than throwing, so a dead auth service does not
|
|
656
|
+
// instantly drop a still-live mesh), which used to leave the connect below presenting an
|
|
657
|
+
// EXPIRED bearer: a guaranteed denial that still costs a full auth-callout round trip, repeated
|
|
658
|
+
// by the reestablish loop at a flat rate for as long as the process lives. Refuse here instead.
|
|
659
|
+
// The loop's capped backoff paces the retries and re-enters this method, so a renewal that
|
|
660
|
+
// starts working reconnects on its own; an endpoint with no bearer source cannot renew at all,
|
|
661
|
+
// and says so.
|
|
662
|
+
if (this.userMode && this.currentBearer && bearerExpiryMs(this.currentBearer) <= Date.now())
|
|
663
|
+
throw new Error(this.bearerSource
|
|
664
|
+
? "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"
|
|
665
|
+
: "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)");
|
|
666
|
+
const credsExp = this.currentCreds && credsClaims(this.currentCreds).exp;
|
|
667
|
+
if (typeof credsExp === "number" && credsExp * 1000 <= Date.now())
|
|
668
|
+
throw new Error(this.credsSource
|
|
669
|
+
? "this endpoint's creds have expired and renewal is failing - not presenting the expired credential to the broker; retrying with backoff"
|
|
670
|
+
: "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)");
|
|
622
671
|
this.nc = await dialerFor(this.servers)({
|
|
623
672
|
servers: this.servers,
|
|
624
673
|
// In USER MODE the connection `name` carries the client-chosen inbox nonce (= connId) the callout
|
|
@@ -705,7 +754,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
705
754
|
if (this.doRegister) {
|
|
706
755
|
await this.publishPresence();
|
|
707
756
|
this.heartbeatTimer = setInterval(() => {
|
|
708
|
-
this.publishPresence().catch((e) => this.
|
|
757
|
+
this.publishPresence().catch((e) => this.emitRecoverable(e));
|
|
709
758
|
}, this.heartbeatMs);
|
|
710
759
|
}
|
|
711
760
|
// Caller-owned membership watches are INTENT rather than one-connection iterators. Re-open them
|
|
@@ -717,6 +766,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
717
766
|
await this.armPlane3();
|
|
718
767
|
// Bound and live — covers initial start, manual reconnect, AND background self-heal (every
|
|
719
768
|
// path lands here). The single signal an in-process agent's connected flag tracks.
|
|
769
|
+
//
|
|
770
|
+
// The stopped guard: stop() can land in any await above. Both callers tear the fresh
|
|
771
|
+
// connection back down (tearDownIfStopped), but an event has no undo, so a late
|
|
772
|
+
// `connection: true` would be the last edge a listener ever sees on a stopped endpoint and
|
|
773
|
+
// nothing follows it to correct the record. It belongs here rather than in a consumer because
|
|
774
|
+
// every listener reads the same edge; MeshAgent carries its own `stopping` guard and so was
|
|
775
|
+
// never the one exposed, which is the point.
|
|
776
|
+
//
|
|
777
|
+
// Measured for the start() caller by the broker suite's mid-bind cell. doRebuild is
|
|
778
|
+
// also measured, by a separate cell that holds the rebuild's connectAndBind at
|
|
779
|
+
// armPlane3 after a successful first bind and lands stop() in that window (#1028).
|
|
780
|
+
// Shared-line reasoning is no longer the rebuild proof.
|
|
781
|
+
if (this.stopped)
|
|
782
|
+
return;
|
|
720
783
|
this.emit("connection", { connected: true });
|
|
721
784
|
}
|
|
722
785
|
/** Tear down everything {@link connectAndBind} (re)creates, so a rebind can't leak a
|
|
@@ -740,6 +803,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
740
803
|
}
|
|
741
804
|
}
|
|
742
805
|
this.streamMsgs.length = 0;
|
|
806
|
+
try {
|
|
807
|
+
this.presenceWatchIter?.stop();
|
|
808
|
+
}
|
|
809
|
+
catch {
|
|
810
|
+
/* already closed with the connection */
|
|
811
|
+
}
|
|
812
|
+
this.presenceWatchIter = undefined;
|
|
813
|
+
try {
|
|
814
|
+
this.channelWatchIter?.stop();
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
/* already closed with the connection */
|
|
818
|
+
}
|
|
819
|
+
this.channelWatchIter = undefined;
|
|
743
820
|
for (const sub of this.chatSubs.values()) {
|
|
744
821
|
try {
|
|
745
822
|
sub.unsubscribe();
|
|
@@ -752,11 +829,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
752
829
|
this.chatSubDenied.clear();
|
|
753
830
|
this.confirmingChatSubs.clear();
|
|
754
831
|
this.roster.clear();
|
|
832
|
+
this.lastPresenceWatchAt = 0;
|
|
833
|
+
this.presenceViewFresh = true;
|
|
755
834
|
this.joinSeq.clear();
|
|
756
835
|
this.channelConfigs.clear();
|
|
757
836
|
this.channelDefaults = {};
|
|
758
837
|
for (const watch of this.membershipFeedWatches)
|
|
759
|
-
watch.arm = watch.arm.catch(() => { }).then(() => this.disarmMembershipWatch(watch));
|
|
838
|
+
watch.arm = watch.arm.catch(() => { }).then(() => this.disarmMembershipWatch(watch)).catch((err) => { this.emit("error", err); });
|
|
760
839
|
}
|
|
761
840
|
/** If stop() ran during a rebuild's `await connectAndBind`, the just-bound connection +
|
|
762
841
|
* heartbeat + supervisor would be left live on a stopped endpoint. Tear that fresh
|
|
@@ -792,7 +871,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
792
871
|
return;
|
|
793
872
|
if (this.nc !== nc)
|
|
794
873
|
return; // epoch-stale — a rebuild already swapped this connection
|
|
795
|
-
|
|
874
|
+
// ORDER IS PART OF THE DIAGNOSTIC CONTRACT. MeshAgent retains endpoint errors only while it is
|
|
875
|
+
// not bound, so readiness must turn false before the matching terminal-close error is emitted.
|
|
876
|
+
// Reversing these two lines silently loses the only post-drop reason an agent can report.
|
|
877
|
+
this.emit("connection", { connected: false });
|
|
796
878
|
this.emit("error", new Error(`mesh connection closed${err ? `: ${err.message}` : ""} - re-establishing`));
|
|
797
879
|
void this.reestablishLoop();
|
|
798
880
|
});
|
|
@@ -839,7 +921,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
839
921
|
// The manager's liveness-lease handle too: left bound to the old connection, every renew and
|
|
840
922
|
// re-read after a reconnect times out, and the manager reports its lease unknown for good.
|
|
841
923
|
this.managerLeaseKv = undefined;
|
|
842
|
-
|
|
924
|
+
// This is an application-requested epoch teardown, not a transient nats.js blip. The old
|
|
925
|
+
// status iterator is now stale by construction and its close is epoch-dropped, so this line is
|
|
926
|
+
// the authoritative raw-liveness edge for the no-nc window until the new watcher seeds true.
|
|
927
|
+
this.emit("transport", { connected: false });
|
|
928
|
+
this.emit("connection", { connected: false });
|
|
843
929
|
try {
|
|
844
930
|
await oldNc?.drain();
|
|
845
931
|
}
|
|
@@ -850,17 +936,31 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
850
936
|
// stop() may have run during the await — don't leave a live connection + heartbeat +
|
|
851
937
|
// supervisor on a stopped endpoint. (Reads this.nc in its own scope — a bare `this.nc`
|
|
852
938
|
// here in doRebuild narrows to `never` via TS inlining connectAndBind's assignment.)
|
|
939
|
+
// Re-arm on the fresh nc only after this stopped fence accepts it.
|
|
853
940
|
if (await this.tearDownIfStopped())
|
|
854
941
|
return;
|
|
855
|
-
this.superviseConnection();
|
|
942
|
+
this.superviseConnection();
|
|
856
943
|
}
|
|
857
944
|
finally {
|
|
858
945
|
this.reconnecting = false;
|
|
859
946
|
}
|
|
860
947
|
}
|
|
948
|
+
/** The ceiling {@link nextRetryDelayMs} grows to. A failure that outlives a couple of retries is
|
|
949
|
+
* a standing one (a down broker, a credential nothing can renew), and retrying it every few
|
|
950
|
+
* seconds forever is what turns one stuck client into a permanent flat load on the broker and its
|
|
951
|
+
* auth callout. */
|
|
952
|
+
static RETRY_BACKOFF_CAP_MS = 60_000;
|
|
953
|
+
/** The wait before the next rebuild attempt: {@link retryMs}, doubling per consecutive failure up
|
|
954
|
+
* to {@link RETRY_BACKOFF_CAP_MS}. The FIRST retry still waits exactly retryMs, so a transient
|
|
955
|
+
* drop recovers as fast as it always did; only a failure that repeats gets paced. */
|
|
956
|
+
nextRetryDelayMs() {
|
|
957
|
+
const delay = Math.min(this.retryMs * 2 ** this.retryAttempt, CotalEndpoint.RETRY_BACKOFF_CAP_MS);
|
|
958
|
+
this.retryAttempt++;
|
|
959
|
+
return delay;
|
|
960
|
+
}
|
|
861
961
|
/** Rebuild with backoff until it sticks or we're stopped. Interruptible: a manual
|
|
862
962
|
* {@link reconnect} kicks the backoff so the next attempt runs immediately instead of
|
|
863
|
-
* awaiting the full
|
|
963
|
+
* awaiting the full delay. One loop at a time ({@link reestablishing}); concurrent
|
|
864
964
|
* triggers coalesce via {@link rebuild}. */
|
|
865
965
|
async reestablishLoop() {
|
|
866
966
|
if (this.reestablishing)
|
|
@@ -870,14 +970,16 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
870
970
|
while (!this.stopped) {
|
|
871
971
|
try {
|
|
872
972
|
await this.rebuild();
|
|
973
|
+
this.retryAttempt = 0; // reconnected — the next drop starts from retryMs again
|
|
873
974
|
return; // success — re-armed; the supervisor re-triggers on the next terminal close
|
|
874
975
|
}
|
|
875
976
|
catch (e) {
|
|
876
977
|
if (!this.stopped)
|
|
877
|
-
this.
|
|
978
|
+
this.emitRecoverable(e);
|
|
979
|
+
const delay = this.nextRetryDelayMs();
|
|
878
980
|
await new Promise((resolve) => {
|
|
879
981
|
this.backoffResolve = resolve;
|
|
880
|
-
this.backoffTimer = setTimeout(resolve,
|
|
982
|
+
this.backoffTimer = setTimeout(resolve, delay);
|
|
881
983
|
});
|
|
882
984
|
}
|
|
883
985
|
}
|
|
@@ -955,6 +1057,20 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
955
1057
|
/* already closed */
|
|
956
1058
|
}
|
|
957
1059
|
}
|
|
1060
|
+
try {
|
|
1061
|
+
this.presenceWatchIter?.stop();
|
|
1062
|
+
}
|
|
1063
|
+
catch {
|
|
1064
|
+
/* already closed */
|
|
1065
|
+
}
|
|
1066
|
+
this.presenceWatchIter = undefined;
|
|
1067
|
+
try {
|
|
1068
|
+
this.channelWatchIter?.stop();
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
/* already closed */
|
|
1072
|
+
}
|
|
1073
|
+
this.channelWatchIter = undefined;
|
|
958
1074
|
try {
|
|
959
1075
|
if (this.doRegister) {
|
|
960
1076
|
this.status = "offline";
|
|
@@ -1491,6 +1607,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1491
1607
|
getRoster() {
|
|
1492
1608
|
return [...this.roster.values()].sort((a, b) => a.card.name.localeCompare(b.card.name));
|
|
1493
1609
|
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Freshness of THIS observer's presence watch, not of any peer. `fresh: false` means the
|
|
1612
|
+
* whole bucket has been silent past the liveness window — the view is stale as of
|
|
1613
|
+
* `staleSince`, and {@link getRoster} is last-known rather than a current offline verdict.
|
|
1614
|
+
* A watch that has not yet delivered anything is not stale (there is no T to name).
|
|
1615
|
+
*/
|
|
1616
|
+
presenceView() {
|
|
1617
|
+
if (!this.doWatch)
|
|
1618
|
+
return { fresh: true };
|
|
1619
|
+
if (this.lastPresenceWatchAt === 0)
|
|
1620
|
+
return { fresh: true };
|
|
1621
|
+
const staleSince = this.lastPresenceWatchAt + this.ttlMs;
|
|
1622
|
+
if (Date.now() < staleSince)
|
|
1623
|
+
return { fresh: true };
|
|
1624
|
+
return { fresh: false, staleSince };
|
|
1625
|
+
}
|
|
1494
1626
|
/** Wait until the current presence watch has consumed its initial KV snapshot. An empty bucket
|
|
1495
1627
|
* emits no watch entry, so the timeout keeps a genuinely empty mesh bounded. */
|
|
1496
1628
|
async waitForPresenceSnapshot(timeoutMs = 1_000) {
|
|
@@ -1694,8 +1826,12 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1694
1826
|
}
|
|
1695
1827
|
}
|
|
1696
1828
|
}
|
|
1697
|
-
catch {
|
|
1698
|
-
|
|
1829
|
+
catch (e) {
|
|
1830
|
+
// A genuinely absent CHAT stream means there are no retained message counts yet. Every other
|
|
1831
|
+
// failure, especially a STREAM.INFO permission denial, means the count view could not be read
|
|
1832
|
+
// and must stay loud rather than returning a valid-looking registry-only subset.
|
|
1833
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.StreamNotFound))
|
|
1834
|
+
throw e;
|
|
1699
1835
|
}
|
|
1700
1836
|
const channels = new Set([...counts.keys(), ...this.channelConfigs.keys()]);
|
|
1701
1837
|
return [...channels]
|
|
@@ -1921,11 +2057,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1921
2057
|
watch.consumerName = undefined;
|
|
1922
2058
|
}
|
|
1923
2059
|
else {
|
|
1924
|
-
// A timeout is deferred only for an epoch that is actually closing/rebuilding; live timeouts stay loud.
|
|
1925
2060
|
const closedEpoch = err.name === "ClosedConnectionError" || /^closed connection$/i.test(err.message);
|
|
1926
|
-
const
|
|
1927
|
-
|
|
2061
|
+
const timeout = err.name === "TimeoutError" || /timeout/i.test(err.message);
|
|
2062
|
+
const dyingEpochTimeout = timeout && (this.reconnecting || !this.nc || this.nc.isClosed());
|
|
2063
|
+
// Cleanup of an ordered consumer: a delete timeout means the broker did not answer in time,
|
|
2064
|
+
// not that the endpoint is unusable. The broker reaps an idle/ephemeral consumer anyway.
|
|
2065
|
+
// Throwing here killed a live observer over a slow VPN (#1047). Catch, surface, continue.
|
|
2066
|
+
if (timeout || closedEpoch || dyingEpochTimeout) {
|
|
2067
|
+
this.emit("error", err);
|
|
2068
|
+
}
|
|
2069
|
+
else {
|
|
1928
2070
|
throw err;
|
|
2071
|
+
}
|
|
1929
2072
|
}
|
|
1930
2073
|
// A terminal close leaves stream/name intact. The endpoint-owned stopped intent is retried
|
|
1931
2074
|
// through the fresh JetStream manager before its public stop promise may resolve.
|
|
@@ -1952,10 +2095,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1952
2095
|
}
|
|
1953
2096
|
}));
|
|
1954
2097
|
}
|
|
1955
|
-
/** Fetch recent messages from a channel's JetStream backlog.
|
|
2098
|
+
/** Fetch recent messages from a channel's JetStream backlog. `signal` cancels the active pull and
|
|
2099
|
+
* reclaims its ephemeral consumer before the promise rejects. */
|
|
1956
2100
|
async channelHistory(channel, opts) {
|
|
1957
2101
|
// history from any sender
|
|
1958
|
-
return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100);
|
|
2102
|
+
return this.streamHistory(chatStream(this.space), chatSubject(this.space, "*", "*", channel), opts?.limit ?? 100, undefined, opts?.signal);
|
|
1959
2103
|
}
|
|
1960
2104
|
/** Read a channel's recent history THROUGH THE DELIVERY DAEMON instead of through a consumer this
|
|
1961
2105
|
* connection creates itself — the mediated read of SPEC's "Mediated reads (normative)" rule (no raw
|
|
@@ -1996,12 +2140,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
1996
2140
|
throw new Error("readHistory: the delivery daemon returned a malformed page (expected { items, complete })");
|
|
1997
2141
|
return { items: data.items, complete: data.complete };
|
|
1998
2142
|
}
|
|
1999
|
-
/** Fetch recent DMs (any sender→any recipient) from the space's DM backlog.
|
|
2143
|
+
/** Fetch recent DMs (any sender→any recipient) from the space's DM backlog. `signal` cancels the
|
|
2144
|
+
* active pull and reclaims its ephemeral consumer. God-view only:
|
|
2000
2145
|
* a normal agent/observer's ACL denies CONSUMER.CREATE on DM_<space>, so this throws-and-
|
|
2001
2146
|
* skips for them — only an `admin`-profile cred can read it. */
|
|
2002
2147
|
async dmHistory(opts) {
|
|
2003
2148
|
// every inst.<recipOwner>.<recipActor>.<sndOwner>.<sndActor> DM — the whole DM subtree (god-view)
|
|
2004
|
-
return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100);
|
|
2149
|
+
return this.streamHistory(dmStream(this.space), `${spacePrefix(this.space)}.inst.>`, opts?.limit ?? 100, undefined, opts?.signal);
|
|
2005
2150
|
}
|
|
2006
2151
|
/**
|
|
2007
2152
|
* The `limit` MOST RECENT messages matching `subject`, oldest-first within the page.
|
|
@@ -2019,14 +2164,17 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2019
2164
|
* A filtered subject's sequences are non-contiguous (other channels interleave in the same
|
|
2020
2165
|
* stream), so the window cannot be computed arithmetically. A FAILED attempt holds fewer than a
|
|
2021
2166
|
* page by definition, so wasted transfer stays page-sized and geometric growth keeps the number of
|
|
2022
|
-
* attempts logarithmic.
|
|
2023
|
-
*
|
|
2167
|
+
* attempts logarithmic. A channel with fewer than `limit` matches used to keep widening until
|
|
2168
|
+
* sequence 1 and drain the stream's whole retained set (#840). The walk now stops at the
|
|
2169
|
+
* subject's FIRST matching sequence (the mirror of the last-matching ceiling), probed on the
|
|
2170
|
+
* same CREATE surface, and only after a short drain so a dense page never pays for the floor.
|
|
2024
2171
|
*
|
|
2025
2172
|
* `before` pages toward the past: pass the `seq` of the oldest message you already have.
|
|
2026
2173
|
*/
|
|
2027
|
-
async streamHistory(stream, subject, limit, before) {
|
|
2174
|
+
async streamHistory(stream, subject, limit, before, signal) {
|
|
2028
2175
|
if (!this.nc)
|
|
2029
2176
|
throw new Error("endpoint not started");
|
|
2177
|
+
signal?.throwIfAborted();
|
|
2030
2178
|
// A LIMIT THAT IS NOT A FINITE NUMBER HAS NO ANSWER, AND THE SEARCH BELOW CANNOT REFUSE IT.
|
|
2031
2179
|
// Every comparison against NaN is false, so `limit <= 0` does not fire for one, and neither of
|
|
2032
2180
|
// the widening loop's exits can ever be true either: `page.length >= NaN` is false forever and
|
|
@@ -2062,7 +2210,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2062
2210
|
// Deliberately NOT `getMessage({ last_by_subj })`, which would be the obvious way to ask: it
|
|
2063
2211
|
// needs `$JS.API.STREAM.MSG.GET`, which read credentials do not hold. That grant hole already
|
|
2064
2212
|
// shipped once from this function and turned every non-admin history read into an empty list.
|
|
2065
|
-
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subject);
|
|
2213
|
+
const ceiling = before !== undefined ? before - 1 : await this.lastMatchingSeq(js, stream, subject, signal);
|
|
2066
2214
|
if (ceiling < 1)
|
|
2067
2215
|
return [];
|
|
2068
2216
|
// Widen from the exact ceiling until a window holds a full page, or until the window IS the
|
|
@@ -2071,17 +2219,28 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2071
2219
|
// Geometric growth keeps the number of attempts logarithmic, so total wasted transfer is a
|
|
2072
2220
|
// small multiple of a page.
|
|
2073
2221
|
//
|
|
2074
|
-
//
|
|
2075
|
-
//
|
|
2076
|
-
//
|
|
2077
|
-
//
|
|
2078
|
-
//
|
|
2079
|
-
//
|
|
2222
|
+
// A SHORT PAGE is either "the channel is exhausted" or "the window is still above the
|
|
2223
|
+
// first match". Sequence 1 is the wrong floor for the first of those: three matches at
|
|
2224
|
+
// the high end of a busy stream are exhausted as soon as the window's lower edge passes
|
|
2225
|
+
// the subject's first matching sequence, and walking on to 1 re-reads everyone else's
|
|
2226
|
+
// retained set (#840). Probe that floor only after a short drain so a dense page (one
|
|
2227
|
+
// drain, full) never pays for it. The remaining unbounded-looking case is a subject
|
|
2228
|
+
// whose FIRST match really is near sequence 1; that span is the channel's own, not the
|
|
2229
|
+
// stream's.
|
|
2080
2230
|
let span = Math.max(limit * 4, 64);
|
|
2231
|
+
let floor = 1;
|
|
2232
|
+
let floorKnown = false;
|
|
2081
2233
|
for (;;) {
|
|
2082
|
-
|
|
2083
|
-
const
|
|
2084
|
-
|
|
2234
|
+
signal?.throwIfAborted();
|
|
2235
|
+
const start = Math.max(floor, ceiling - span + 1);
|
|
2236
|
+
const page = await this.drainWindow(js, stream, subject, start, ceiling, limit, signal);
|
|
2237
|
+
if (page.length >= limit)
|
|
2238
|
+
return page.slice(-limit);
|
|
2239
|
+
if (!floorKnown) {
|
|
2240
|
+
floor = Math.max(1, await this.firstMatchingSeq(js, stream, subject, signal));
|
|
2241
|
+
floorKnown = true;
|
|
2242
|
+
}
|
|
2243
|
+
if (start <= floor)
|
|
2085
2244
|
return page.slice(-limit);
|
|
2086
2245
|
span *= 4;
|
|
2087
2246
|
}
|
|
@@ -2115,7 +2274,51 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2115
2274
|
* (available from the create, before anything is delivered) is 0 for an empty subject, and
|
|
2116
2275
|
* otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
|
|
2117
2276
|
* already uses, so no broker authority is added. */
|
|
2118
|
-
|
|
2277
|
+
/** The oldest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
2278
|
+
* Mirror of {@link lastMatchingSeq}: same CREATE/INFO/NEXT/DELETE surface, `DeliverPolicy.All`
|
|
2279
|
+
* instead of `Last`, first delivered seq instead of last. Read credentials already hold this. */
|
|
2280
|
+
async firstMatchingSeq(js, stream, subject, signal) {
|
|
2281
|
+
signal?.throwIfAborted();
|
|
2282
|
+
const consumer = await js.consumers.get(stream, {
|
|
2283
|
+
filter_subjects: [subject],
|
|
2284
|
+
deliver_policy: DeliverPolicy.All,
|
|
2285
|
+
});
|
|
2286
|
+
try {
|
|
2287
|
+
if ((await consumer.info(true)).num_pending === 0)
|
|
2288
|
+
return 0;
|
|
2289
|
+
signal?.throwIfAborted();
|
|
2290
|
+
const iter = await consumer.fetch({ max_messages: 1 });
|
|
2291
|
+
const stop = () => iter.stop(abortReason(signal));
|
|
2292
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
2293
|
+
try {
|
|
2294
|
+
signal?.throwIfAborted();
|
|
2295
|
+
for await (const m of iter)
|
|
2296
|
+
return m.seq;
|
|
2297
|
+
}
|
|
2298
|
+
finally {
|
|
2299
|
+
signal?.removeEventListener("abort", stop);
|
|
2300
|
+
iter.stop();
|
|
2301
|
+
}
|
|
2302
|
+
throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
|
|
2303
|
+
}
|
|
2304
|
+
finally {
|
|
2305
|
+
try {
|
|
2306
|
+
await consumer.delete();
|
|
2307
|
+
}
|
|
2308
|
+
catch (e) {
|
|
2309
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
2310
|
+
throw e;
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
/** The newest stream sequence matching `subject`, or 0 when the subject has no messages.
|
|
2315
|
+
*
|
|
2316
|
+
* One ordered consumer at `DeliverPolicy.Last` with this subject's filter: its `num_pending`
|
|
2317
|
+
* (available from the create, before anything is delivered) is 0 for an empty subject, and
|
|
2318
|
+
* otherwise one message carries the sequence. Same CREATE/INFO/NEXT/DELETE surface `drainWindow`
|
|
2319
|
+
* already uses, so no broker authority is added. */
|
|
2320
|
+
async lastMatchingSeq(js, stream, subject, signal) {
|
|
2321
|
+
signal?.throwIfAborted();
|
|
2119
2322
|
const consumer = await js.consumers.get(stream, {
|
|
2120
2323
|
filter_subjects: [subject],
|
|
2121
2324
|
deliver_policy: DeliverPolicy.Last,
|
|
@@ -2124,9 +2327,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2124
2327
|
// Bind-time zero is the ONLY thing that means "this subject has no messages".
|
|
2125
2328
|
if ((await consumer.info(true)).num_pending === 0)
|
|
2126
2329
|
return 0;
|
|
2330
|
+
signal?.throwIfAborted();
|
|
2127
2331
|
const iter = await consumer.fetch({ max_messages: 1 });
|
|
2128
|
-
|
|
2129
|
-
|
|
2332
|
+
const stop = () => iter.stop(abortReason(signal));
|
|
2333
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
2334
|
+
try {
|
|
2335
|
+
signal?.throwIfAborted();
|
|
2336
|
+
for await (const m of iter)
|
|
2337
|
+
return m.seq;
|
|
2338
|
+
}
|
|
2339
|
+
finally {
|
|
2340
|
+
signal?.removeEventListener("abort", stop);
|
|
2341
|
+
iter.stop();
|
|
2342
|
+
}
|
|
2130
2343
|
// Bind said a message was pending and none arrived. The pinned client's pull iterator ends
|
|
2131
2344
|
// CLEANLY when the connection closes ("we don't propagate the error here"), so this is what a
|
|
2132
2345
|
// dropped link looks like from here. Returning 0 would make the caller report an empty
|
|
@@ -2135,14 +2348,21 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2135
2348
|
throw new Error(`history: the broker reported messages on ${subject} but delivered none - the read was cut short, not empty`);
|
|
2136
2349
|
}
|
|
2137
2350
|
finally {
|
|
2138
|
-
|
|
2351
|
+
try {
|
|
2352
|
+
await consumer.delete();
|
|
2353
|
+
}
|
|
2354
|
+
catch (e) {
|
|
2355
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
2356
|
+
throw e;
|
|
2357
|
+
}
|
|
2139
2358
|
}
|
|
2140
2359
|
}
|
|
2141
2360
|
/** Drain every message matching `subject` with sequence in `[start, ceiling]`, oldest-first.
|
|
2142
2361
|
* One ephemeral ordered consumer, one batched pull — `AckPolicy.None`, so no per-message ack
|
|
2143
2362
|
* round trip. Fetches exactly the pending count so it returns as soon as the window is
|
|
2144
2363
|
* delivered rather than blocking for the pull's full expiry. */
|
|
2145
|
-
async drainWindow(js, stream, subject, start, ceiling) {
|
|
2364
|
+
async drainWindow(js, stream, subject, start, ceiling, limit, signal) {
|
|
2365
|
+
signal?.throwIfAborted();
|
|
2146
2366
|
const out = [];
|
|
2147
2367
|
const consumer = await js.consumers.get(stream, { filter_subjects: [subject], opt_start_seq: start });
|
|
2148
2368
|
try {
|
|
@@ -2151,36 +2371,51 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2151
2371
|
const pending = (await consumer.info(true)).num_pending;
|
|
2152
2372
|
if (pending === 0)
|
|
2153
2373
|
return out;
|
|
2154
|
-
|
|
2374
|
+
signal?.throwIfAborted();
|
|
2375
|
+
// Keep only a small rolling buffer in flight. Stopping a client iterator cannot unsend bytes the
|
|
2376
|
+
// broker already committed to that pull request, so fetching the whole page let an aborted history
|
|
2377
|
+
// read keep filling a constrained shared connection and starve the next dashboard poll. `consume`
|
|
2378
|
+
// replenishes this bounded buffer as it is read, so large pages still complete without committing
|
|
2379
|
+
// all of their bytes to the connection up front.
|
|
2380
|
+
const iter = await consumer.consume({ max_messages: Math.min(pending, 32) });
|
|
2381
|
+
const stop = () => iter.stop(abortReason(signal));
|
|
2382
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
2155
2383
|
// PROVE THE WINDOW COMPLETED. The pull iterator ends cleanly on a dropped connection, so a
|
|
2156
2384
|
// close after three of ten deliveries would otherwise return a convincing three-message page.
|
|
2157
2385
|
// The window is done when we have reached its upper bound or consumed everything bind said
|
|
2158
2386
|
// was pending; anything else is a cut-short read and must say so.
|
|
2159
2387
|
let delivered = 0;
|
|
2160
2388
|
let complete = false;
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2389
|
+
try {
|
|
2390
|
+
signal?.throwIfAborted();
|
|
2391
|
+
for await (const m of iter) {
|
|
2392
|
+
delivered++;
|
|
2393
|
+
if (m.seq >= ceiling) { // reached the page's upper bound
|
|
2394
|
+
if (m.seq === ceiling) {
|
|
2395
|
+
try {
|
|
2396
|
+
out.push(m.json());
|
|
2397
|
+
}
|
|
2398
|
+
catch { /* skip undecodable */ }
|
|
2167
2399
|
}
|
|
2168
|
-
|
|
2400
|
+
complete = true;
|
|
2401
|
+
break;
|
|
2402
|
+
}
|
|
2403
|
+
try {
|
|
2404
|
+
out.push(m.json());
|
|
2405
|
+
if (out.length > limit)
|
|
2406
|
+
out.shift();
|
|
2407
|
+
}
|
|
2408
|
+
catch { /* skip undecodable */ }
|
|
2409
|
+
if (delivered >= pending) {
|
|
2410
|
+
complete = true;
|
|
2411
|
+
break;
|
|
2169
2412
|
}
|
|
2170
|
-
complete = true;
|
|
2171
|
-
break;
|
|
2172
|
-
}
|
|
2173
|
-
try {
|
|
2174
|
-
out.push(m.json());
|
|
2175
|
-
}
|
|
2176
|
-
catch {
|
|
2177
|
-
/* skip undecodable */
|
|
2178
|
-
}
|
|
2179
|
-
if (delivered >= pending) {
|
|
2180
|
-
complete = true;
|
|
2181
|
-
break;
|
|
2182
2413
|
}
|
|
2183
2414
|
}
|
|
2415
|
+
finally {
|
|
2416
|
+
signal?.removeEventListener("abort", stop);
|
|
2417
|
+
iter.stop();
|
|
2418
|
+
}
|
|
2184
2419
|
if (!complete)
|
|
2185
2420
|
throw new Error(`history: read ${delivered} of ${pending} messages on ${subject} before the stream ended early - the window was cut short, not empty`);
|
|
2186
2421
|
return out;
|
|
@@ -2191,7 +2426,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2191
2426
|
// up to eight per call, the dashboard makes one call per channel, and a reload repeats it.
|
|
2192
2427
|
// Left alone that accumulates consumers on the broker until the thresholds expire, and the
|
|
2193
2428
|
// resulting resource exhaustion would land in streamHistory's catch and read as empty history.
|
|
2194
|
-
|
|
2429
|
+
try {
|
|
2430
|
+
await consumer.delete();
|
|
2431
|
+
}
|
|
2432
|
+
catch (e) {
|
|
2433
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
2434
|
+
throw e;
|
|
2435
|
+
}
|
|
2195
2436
|
}
|
|
2196
2437
|
}
|
|
2197
2438
|
// ---- internals -----------------------------------------------------------
|
|
@@ -2203,10 +2444,27 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2203
2444
|
* denial is never mistaken for absence (which already has a benign cause: MCP reconnect).
|
|
2204
2445
|
*/
|
|
2205
2446
|
watchStatus() {
|
|
2206
|
-
|
|
2447
|
+
const nc = this.nc;
|
|
2448
|
+
if (!nc)
|
|
2207
2449
|
return;
|
|
2208
2450
|
void (async () => {
|
|
2209
|
-
for await (const s of
|
|
2451
|
+
for await (const s of nc.status()) {
|
|
2452
|
+
// A rebuild can replace `this.nc` before the old iterator finishes. Late disconnect/close
|
|
2453
|
+
// from that old epoch says nothing about the replacement and must not flip its liveness.
|
|
2454
|
+
if (this.nc !== nc)
|
|
2455
|
+
continue;
|
|
2456
|
+
if (s.type === "disconnect") {
|
|
2457
|
+
this.emit("transport", { connected: false, server: s.server });
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
if (s.type === "reconnect") {
|
|
2461
|
+
this.emit("transport", { connected: true, server: s.server });
|
|
2462
|
+
continue;
|
|
2463
|
+
}
|
|
2464
|
+
if (s.type === "close") {
|
|
2465
|
+
this.emit("transport", { connected: false });
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2210
2468
|
if (s.type !== "error")
|
|
2211
2469
|
continue;
|
|
2212
2470
|
// Suppress the EXPECTED permission violation from a manager-free join we're confirming: an
|
|
@@ -2217,9 +2475,24 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2217
2475
|
this.emit("error", describeStatusError(s.error));
|
|
2218
2476
|
}
|
|
2219
2477
|
})().catch((e) => {
|
|
2220
|
-
|
|
2478
|
+
// Defensive symmetry with the reachable in-loop epoch guard above. Measured against five real
|
|
2479
|
+
// broker loss/reconnect/terminal-close cycles on pinned nats.js 3.4.0: status iterators ended
|
|
2480
|
+
// normally and this catch never fired. Keep an old epoch from surfacing an error if a runtime or
|
|
2481
|
+
// future client version can reject here, but do not treat this as a currently reachable edge.
|
|
2482
|
+
if (!this.stopped && this.nc === nc)
|
|
2221
2483
|
this.emit("error", e);
|
|
2222
2484
|
});
|
|
2485
|
+
// The transport is already live when connect() returns, while the Cotal bind below is still in
|
|
2486
|
+
// progress. Seed this contract explicitly rather than requiring consumers to combine it with the
|
|
2487
|
+
// later, differently-scoped `connection:true` event.
|
|
2488
|
+
//
|
|
2489
|
+
// The same stopped race as the readiness emit at the end of connectAndBind, and it reaches here
|
|
2490
|
+
// FIRST: connectAndBind calls watchStatus right after the dial, so a stop() landing while the
|
|
2491
|
+
// dial is still pending has this seed fire on an endpoint that is already stopped. Measured
|
|
2492
|
+
// through a real pending dial, a stopped endpoint announced a live transport it never had.
|
|
2493
|
+
if (this.stopped)
|
|
2494
|
+
return;
|
|
2495
|
+
this.emit("transport", { connected: true, server: nc.getServer() });
|
|
2223
2496
|
}
|
|
2224
2497
|
/** The error message for a guard that finds the endpoint unbound: "reconnecting" during a
|
|
2225
2498
|
* rebuild's null window OR an inter-retry backoff (so a concurrent op reports the real
|
|
@@ -2280,7 +2553,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2280
2553
|
await jsm.consumers.info(stream, name);
|
|
2281
2554
|
return; // this lifecycle's durable exists — keep its original frontier
|
|
2282
2555
|
}
|
|
2283
|
-
catch {
|
|
2556
|
+
catch (e) {
|
|
2557
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
2558
|
+
throw e;
|
|
2559
|
+
// Genuinely absent consumer: create it below. A denial is not absence.
|
|
2560
|
+
}
|
|
2284
2561
|
const frontier = (await jsm.streams.info(stream)).state.last_seq;
|
|
2285
2562
|
try {
|
|
2286
2563
|
await jsm.consumers.add(stream, dmDurableConfig(this.space, owner, actor, lifecycleUid, { ...opts, activationFrontier: frontier }));
|
|
@@ -2292,7 +2569,13 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2292
2569
|
await jsm.consumers.info(stream, name);
|
|
2293
2570
|
return;
|
|
2294
2571
|
}
|
|
2295
|
-
catch {
|
|
2572
|
+
catch (probeError) {
|
|
2573
|
+
// The probe never creates a successful result: it either proves the concurrent winner above,
|
|
2574
|
+
// or the original create failure stays authoritative. Preserve a denial from the probe because
|
|
2575
|
+
// it names the missing capability more accurately than an unrelated create conflict.
|
|
2576
|
+
if (isPermissionDenied(probeError))
|
|
2577
|
+
throw probeError;
|
|
2578
|
+
}
|
|
2296
2579
|
throw e;
|
|
2297
2580
|
}
|
|
2298
2581
|
}
|
|
@@ -2414,7 +2697,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2414
2697
|
try {
|
|
2415
2698
|
await (await this.deliveryRegistry()).delete(leaseKey(shardIndex));
|
|
2416
2699
|
}
|
|
2417
|
-
catch {
|
|
2700
|
+
catch {
|
|
2701
|
+
// Intentionally best-effort for EVERY failure: the lease TTL is the crash-safe release authority,
|
|
2702
|
+
// and clean shutdown must continue even when the broker is already gone or draining.
|
|
2703
|
+
}
|
|
2418
2704
|
}
|
|
2419
2705
|
/** Read a shard's delivery lease (the daemon-availability signal), or `undefined` if none is live.
|
|
2420
2706
|
* READ-ONLY surface — drives Component 6's `cotal_channels` delivery-health field (an agent reads it
|
|
@@ -2457,6 +2743,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2457
2743
|
this.managerLeaseKv = await kvm.create(managerBucket(this.space), { ttl: MANAGER_LEASE_TTL_MS });
|
|
2458
2744
|
}
|
|
2459
2745
|
catch {
|
|
2746
|
+
// OPEN mode has no broker ACLs, so a permission denial is impossible here. `create` is only
|
|
2747
|
+
// the ensure-exists attempt; `open` below is the authority after either a pre-existing bucket
|
|
2748
|
+
// or a create race, and it still throws if the bucket cannot actually be used.
|
|
2460
2749
|
this.managerLeaseKv = await kvm.open(managerBucket(this.space));
|
|
2461
2750
|
}
|
|
2462
2751
|
}
|
|
@@ -2502,7 +2791,10 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2502
2791
|
else
|
|
2503
2792
|
await kv.delete(managerLeaseKey(instanceId), { previousSeq: revision });
|
|
2504
2793
|
}
|
|
2505
|
-
catch {
|
|
2794
|
+
catch {
|
|
2795
|
+
// Intentionally best-effort for EVERY failure: a revision mismatch means the lease is no longer
|
|
2796
|
+
// ours, while a broker failure is recovered by the bucket TTL. Shutdown must not claim deletion.
|
|
2797
|
+
}
|
|
2506
2798
|
}
|
|
2507
2799
|
/** Read a live manager liveness lease, or undefined if NONE (no manager instance holds the space). A
|
|
2508
2800
|
* presence/existence check for the CLI's `spawn -f` reuse and `waitLeaseGone`, which only need "is any
|
|
@@ -2538,9 +2830,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2538
2830
|
// it returns that instance's latest state, so a DEL here retires only its own key. The
|
|
2539
2831
|
// defect was asking one wildcard for the newest message in the whole subtree, where a
|
|
2540
2832
|
// stopping peer's tombstone outranks a live sibling's older PUT.
|
|
2541
|
-
|
|
2833
|
+
let m;
|
|
2834
|
+
try {
|
|
2835
|
+
m = await jsm.streams.getMessage(stream, { last_by_subj: subject });
|
|
2836
|
+
}
|
|
2837
|
+
catch (e) {
|
|
2838
|
+
if (e.code === JetStreamApiCodes.NoMessageFound)
|
|
2839
|
+
continue;
|
|
2840
|
+
throw e;
|
|
2841
|
+
}
|
|
2542
2842
|
if (m === null)
|
|
2543
2843
|
continue; // key vanished between INFO and GET: it is not a live holder
|
|
2844
|
+
// A 10037 above means the key vanished between INFO and GET: it is not a live holder. A
|
|
2845
|
+
// denial or failed read is not absence and is rethrown.
|
|
2544
2846
|
const op = m.header?.get("KV-Operation");
|
|
2545
2847
|
if (op === "DEL" || op === "PURGE" || m.data.length === 0)
|
|
2546
2848
|
continue;
|
|
@@ -2723,10 +3025,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2723
3025
|
// delete/recreate a predecessor's in-flight catch-up consumer — the uid disambiguates.
|
|
2724
3026
|
const cuP = parsePrincipalKey(owner);
|
|
2725
3027
|
const name = `cu_${cuP ? lifecycleNameKey(cuP.owner, cuP.actor, lifecycleUid) : `${token(owner)}-${lifecycleUid}`}_${generation}`;
|
|
2726
|
-
|
|
2727
|
-
await this.jsm.consumers.delete(chatStream(this.space), name);
|
|
2728
|
-
}
|
|
2729
|
-
catch { /* none */ }
|
|
3028
|
+
await this.deleteConsumerIfPresent(chatStream(this.space), name);
|
|
2730
3029
|
await this.jsm.consumers.add(chatStream(this.space), {
|
|
2731
3030
|
name, filter_subject: subject, ack_policy: AckPolicy.None, mem_storage: true,
|
|
2732
3031
|
inactive_threshold: nanos(30_000), deliver_policy: DeliverPolicy.StartSequence, opt_start_seq: fromSeqExcl + 1,
|
|
@@ -2762,10 +3061,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
2762
3061
|
}
|
|
2763
3062
|
}
|
|
2764
3063
|
finally {
|
|
2765
|
-
|
|
2766
|
-
await this.jsm.consumers.delete(chatStream(this.space), name);
|
|
2767
|
-
}
|
|
2768
|
-
catch { /* gone */ }
|
|
3064
|
+
await this.deleteConsumerIfPresent(chatStream(this.space), name);
|
|
2769
3065
|
}
|
|
2770
3066
|
return { copied, evicted };
|
|
2771
3067
|
}
|
|
@@ -3126,10 +3422,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3126
3422
|
async runFanout() {
|
|
3127
3423
|
if (!this.js || !this.jsm)
|
|
3128
3424
|
return;
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
}
|
|
3132
|
-
catch { /* exists */ }
|
|
3425
|
+
// Named consumer creation is idempotent for the same config. A denial must not be mistaken for
|
|
3426
|
+
// "already exists"; let the broker response decide and propagate every failure.
|
|
3427
|
+
await this.jsm.consumers.add(chatStream(this.space), fanoutDurableConfig(this.space, { ackWaitMs: this.ackWaitMs }));
|
|
3133
3428
|
const consumer = await this.js.consumers.get(chatStream(this.space), FANOUT_DURABLE);
|
|
3134
3429
|
const msgs = await consumer.consume();
|
|
3135
3430
|
this.streamMsgs.push(msgs);
|
|
@@ -3215,10 +3510,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3215
3510
|
async runReader() {
|
|
3216
3511
|
if (!this.js || !this.jsm)
|
|
3217
3512
|
return;
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
}
|
|
3221
|
-
catch { /* exists */ }
|
|
3513
|
+
// Same fail-loud rule as fan-out: idempotent success is success, denial is not existence.
|
|
3514
|
+
await this.jsm.consumers.add(inboxStream(this.space), inboxReaderConfig(this.space, { ackWaitMs: this.ackWaitMs }));
|
|
3222
3515
|
const consumer = await this.js.consumers.get(inboxStream(this.space), INBOX_READER_DURABLE);
|
|
3223
3516
|
const msgs = await consumer.consume();
|
|
3224
3517
|
this.streamMsgs.push(msgs);
|
|
@@ -3311,7 +3604,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3311
3604
|
});
|
|
3312
3605
|
}
|
|
3313
3606
|
catch {
|
|
3314
|
-
//
|
|
3607
|
+
// EVERY transfer failure is deliberately a retry, never a successful result. Keep the entry
|
|
3608
|
+
// pending (redeliver), bounded by the same ceiling so a poison
|
|
3315
3609
|
// entry can't head-of-line the shared reader forever.
|
|
3316
3610
|
if (redeliveries >= READER_MAX_REDELIVERIES) {
|
|
3317
3611
|
m.term();
|
|
@@ -3337,9 +3631,11 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3337
3631
|
try {
|
|
3338
3632
|
consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor, this.ownLifecycleUid));
|
|
3339
3633
|
}
|
|
3340
|
-
catch {
|
|
3341
|
-
|
|
3342
|
-
|
|
3634
|
+
catch (e) {
|
|
3635
|
+
if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
3636
|
+
return;
|
|
3637
|
+
throw e; // a denied bind is not proof Plane-3 is absent
|
|
3638
|
+
}
|
|
3343
3639
|
const msgs = await consumer.consume();
|
|
3344
3640
|
this.streamMsgs.push(msgs);
|
|
3345
3641
|
void (async () => {
|
|
@@ -3471,13 +3767,9 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3471
3767
|
for (const channel of this.channels) {
|
|
3472
3768
|
if (!isConcreteChannel(channel) || this.plane3Channels.has(channel))
|
|
3473
3769
|
continue;
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
}
|
|
3478
|
-
catch {
|
|
3479
|
-
continue;
|
|
3480
|
-
}
|
|
3770
|
+
// A missing channel row legitimately resolves through the defaults. A failed/denied registry
|
|
3771
|
+
// read does not prove the channel is live-only, so let it fail startup loudly.
|
|
3772
|
+
const cls = await this.deliveryClassFresh(channel);
|
|
3481
3773
|
if (cls !== "durable")
|
|
3482
3774
|
continue;
|
|
3483
3775
|
try {
|
|
@@ -3489,7 +3781,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3489
3781
|
}
|
|
3490
3782
|
catch (e) {
|
|
3491
3783
|
if (!this.isNoResponders(e))
|
|
3492
|
-
this.
|
|
3784
|
+
this.emitRecoverable(e); // no daemon ⇒ retry until it recovers
|
|
3493
3785
|
void this.reconcileBootJoin(channel);
|
|
3494
3786
|
}
|
|
3495
3787
|
}
|
|
@@ -3522,7 +3814,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3522
3814
|
}
|
|
3523
3815
|
catch (e) {
|
|
3524
3816
|
if (attempt === 0 && !this.isNoResponders(e))
|
|
3525
|
-
this.
|
|
3817
|
+
this.emitRecoverable(new Error(`channel "${channel}": boot durable self-join not yet established - retrying until the delivery daemon is reachable (${e.message})`));
|
|
3526
3818
|
}
|
|
3527
3819
|
}
|
|
3528
3820
|
}
|
|
@@ -3778,8 +4070,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3778
4070
|
try {
|
|
3779
4071
|
return await this.jsm.consumers.info(stream, durable);
|
|
3780
4072
|
}
|
|
3781
|
-
catch {
|
|
3782
|
-
|
|
4073
|
+
catch (e) {
|
|
4074
|
+
if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
|
|
4075
|
+
return null; // structured absence — fresh durable
|
|
4076
|
+
throw e;
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4079
|
+
/** Delete one named consumer, swallowing ONLY structured consumer/stream absence. */
|
|
4080
|
+
async deleteConsumerIfPresent(stream, durable) {
|
|
4081
|
+
if (!this.jsm)
|
|
4082
|
+
throw new Error("endpoint not started");
|
|
4083
|
+
try {
|
|
4084
|
+
await this.jsm.consumers.delete(stream, durable);
|
|
4085
|
+
}
|
|
4086
|
+
catch (e) {
|
|
4087
|
+
if (!isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound, JetStreamApiCodes.StreamNotFound))
|
|
4088
|
+
throw e;
|
|
3783
4089
|
}
|
|
3784
4090
|
}
|
|
3785
4091
|
/** Current frontier (last sequence) of the chat stream — a channel's join watermark, and the
|
|
@@ -3855,10 +4161,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3855
4161
|
const out = [];
|
|
3856
4162
|
// Clear any consumer leaked by a crashed prior read before re-creating it with THIS read's
|
|
3857
4163
|
// single filter (the read ACL is enforced at create — see the doc above).
|
|
3858
|
-
|
|
3859
|
-
await this.jsm.consumers.delete(stream, name);
|
|
3860
|
-
}
|
|
3861
|
-
catch { /* none; fine */ }
|
|
4164
|
+
await this.deleteConsumerIfPresent(stream, name);
|
|
3862
4165
|
await this.jsm.consumers.add(stream, {
|
|
3863
4166
|
name,
|
|
3864
4167
|
filter_subject: subject,
|
|
@@ -3894,10 +4197,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3894
4197
|
}
|
|
3895
4198
|
}
|
|
3896
4199
|
finally {
|
|
3897
|
-
|
|
3898
|
-
await this.jsm.consumers.delete(stream, name);
|
|
3899
|
-
}
|
|
3900
|
-
catch { /* already gone */ }
|
|
4200
|
+
await this.deleteConsumerIfPresent(stream, name);
|
|
3901
4201
|
}
|
|
3902
4202
|
return out;
|
|
3903
4203
|
}
|
|
@@ -3914,6 +4214,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3914
4214
|
}
|
|
3915
4215
|
catch (e) {
|
|
3916
4216
|
this.emit("error", e);
|
|
4217
|
+
if (isPermissionDenied(e))
|
|
4218
|
+
throw e;
|
|
3917
4219
|
return 0;
|
|
3918
4220
|
}
|
|
3919
4221
|
const noop = { ack: () => { }, nak: () => { }, durable: false };
|
|
@@ -3941,14 +4243,18 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3941
4243
|
/**
|
|
3942
4244
|
* Replay-gated pull of a channel's retained ambient from `sinceSeq` (exclusive) forward — the
|
|
3943
4245
|
* focus-recall read behind `cotal_inbox`. Returns the messages (NOT emitted — this is a pull,
|
|
3944
|
-
* not a push into context) plus `dropped: true` when the
|
|
3945
|
-
* is already newer than the watermark
|
|
3946
|
-
* the
|
|
4246
|
+
* not a push into context) plus `dropped: true` when the window is not complete: either the
|
|
4247
|
+
* channel's earliest *retained* message is already newer than the watermark (some ambient aged
|
|
4248
|
+
* out of the per-subject window), or replay is off for the channel below. Either way the caller
|
|
4249
|
+
* must say so rather than silently reporting an empty, complete window.
|
|
3947
4250
|
*
|
|
3948
4251
|
* Honors the **same** per-channel replay gate as join-backfill ({@link joinPolicyFresh}): a
|
|
3949
|
-
* `replay=off` channel returns
|
|
3950
|
-
* that denies replay to everyone else (the read ACL bounds *which* channels recall can
|
|
3951
|
-
* app gate bounds *whether* a permitted channel replays).
|
|
4252
|
+
* `replay=off` channel returns no messages, so `focus` can't become a history bypass for a
|
|
4253
|
+
* channel that denies replay to everyone else (the read ACL bounds *which* channels recall can
|
|
4254
|
+
* touch; this app gate bounds *whether* a permitted channel replays). Ingest still ack-drops
|
|
4255
|
+
* focus-mode ambient/mentions on this channel on the promise that they stay recallable (#977) —
|
|
4256
|
+
* the gate means that promise cannot be kept, so it reports `dropped: true` rather than pretend
|
|
4257
|
+
* the window was empty and complete.
|
|
3952
4258
|
*/
|
|
3953
4259
|
async recallChannel(channel, sinceSeq) {
|
|
3954
4260
|
if (!this.jsm)
|
|
@@ -3957,7 +4263,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3957
4263
|
return { messages: [], dropped: false };
|
|
3958
4264
|
const policy = await this.joinPolicyFresh(channel);
|
|
3959
4265
|
if (!policy.replay)
|
|
3960
|
-
return { messages: [], dropped:
|
|
4266
|
+
return { messages: [], dropped: true };
|
|
3961
4267
|
const subject = chatSubject(this.space, "*", "*", channel);
|
|
3962
4268
|
let raw;
|
|
3963
4269
|
try {
|
|
@@ -3965,6 +4271,8 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
3965
4271
|
}
|
|
3966
4272
|
catch (e) {
|
|
3967
4273
|
this.emit("error", e);
|
|
4274
|
+
if (isPermissionDenied(e))
|
|
4275
|
+
throw e;
|
|
3968
4276
|
raw = [];
|
|
3969
4277
|
}
|
|
3970
4278
|
const collected = [];
|
|
@@ -4054,6 +4362,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4054
4362
|
let hydrated;
|
|
4055
4363
|
this.presenceSnapshot = new Promise((resolve) => { hydrated = resolve; });
|
|
4056
4364
|
const iter = await this.kv.watch();
|
|
4365
|
+
this.presenceWatchIter = iter;
|
|
4057
4366
|
void (async () => {
|
|
4058
4367
|
let ready = false;
|
|
4059
4368
|
for await (const e of iter) {
|
|
@@ -4074,6 +4383,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4074
4383
|
if (!this.channelKv)
|
|
4075
4384
|
return;
|
|
4076
4385
|
const iter = await this.channelKv.watch();
|
|
4386
|
+
this.channelWatchIter = iter;
|
|
4077
4387
|
void (async () => {
|
|
4078
4388
|
for await (const e of iter)
|
|
4079
4389
|
this.handleChannelEntry(e);
|
|
@@ -4105,6 +4415,7 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4105
4415
|
}
|
|
4106
4416
|
}
|
|
4107
4417
|
handleKvEntry(e) {
|
|
4418
|
+
this.lastPresenceWatchAt = Date.now();
|
|
4108
4419
|
if (e.operation === "DEL" || e.operation === "PURGE") {
|
|
4109
4420
|
this.markOffline(e.key);
|
|
4110
4421
|
return;
|
|
@@ -4128,6 +4439,19 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4128
4439
|
return;
|
|
4129
4440
|
const prev = this.roster.get(id);
|
|
4130
4441
|
const stale = Date.now() - raw.ts > this.ttlMs;
|
|
4442
|
+
// A watch recovering from a stall replays the bucket. Those PUTs still carry the publisher's
|
|
4443
|
+
// pre-stall timestamps, so they look TTL-expired even though the peers kept heartbeating on
|
|
4444
|
+
// the broker. Materializing them as offline is the empty-to-full flicker #1045 named: the
|
|
4445
|
+
// observer's catch-up, not N deaths. Keep last-known rows, stay view-stale, and wait for a
|
|
4446
|
+
// live timestamp (or an explicit offline/delete) before changing a known peer.
|
|
4447
|
+
if (stale && raw.status !== "offline") {
|
|
4448
|
+
if (prev)
|
|
4449
|
+
return;
|
|
4450
|
+
this.roster.set(id, this.toOffline(raw));
|
|
4451
|
+
this.emit("roster", this.getRoster());
|
|
4452
|
+
return;
|
|
4453
|
+
}
|
|
4454
|
+
this.setPresenceViewFresh(true);
|
|
4131
4455
|
// Any offline materialization (a stale snapshot OR a graceful-leave record) drops the advisory
|
|
4132
4456
|
// attention fields — an offline peer must not carry a stale `[focus]`/`locally muted` hint.
|
|
4133
4457
|
const p = stale || raw.status === "offline" ? this.toOffline(raw) : raw;
|
|
@@ -4181,8 +4505,22 @@ export class CotalEndpoint extends EventEmitter {
|
|
|
4181
4505
|
this.emit("presence", { type: "offline", presence: offline });
|
|
4182
4506
|
this.emit("roster", this.getRoster());
|
|
4183
4507
|
}
|
|
4508
|
+
setPresenceViewFresh(fresh) {
|
|
4509
|
+
if (fresh === this.presenceViewFresh)
|
|
4510
|
+
return;
|
|
4511
|
+
this.presenceViewFresh = fresh;
|
|
4512
|
+
this.emit("presence-view", this.presenceView());
|
|
4513
|
+
}
|
|
4184
4514
|
sweep() {
|
|
4185
4515
|
const now = Date.now();
|
|
4516
|
+
// Whole-bucket silence past TTL is THIS observer going deaf. Real rosters do not lose every
|
|
4517
|
+
// peer in one window, and treating that silence as N offline verdicts empties an online-only
|
|
4518
|
+
// sidebar with nothing saying the window went blind (#1045). Gate the per-peer age-out on
|
|
4519
|
+
// watch freshness; surface the view as stale instead.
|
|
4520
|
+
if (this.lastPresenceWatchAt !== 0 && now - this.lastPresenceWatchAt > this.ttlMs) {
|
|
4521
|
+
this.setPresenceViewFresh(false);
|
|
4522
|
+
return;
|
|
4523
|
+
}
|
|
4186
4524
|
let changed = false;
|
|
4187
4525
|
for (const [id, p] of this.roster) {
|
|
4188
4526
|
if (p.status !== "offline" && now - p.ts > this.ttlMs) {
|
|
@@ -4381,6 +4719,18 @@ export function isPermissionDenied(e) {
|
|
|
4381
4719
|
return true;
|
|
4382
4720
|
return /permissions?\s+violation/i.test(String(e?.message ?? ""));
|
|
4383
4721
|
}
|
|
4722
|
+
/** True only for the structured JetStream API absence codes named by the caller. A status 404 or
|
|
4723
|
+
* message regex is too broad here: the catch sites use absence to produce a successful empty/fresh
|
|
4724
|
+
* result, so a permission denial, timeout, or protocol failure must never pass as "not found". */
|
|
4725
|
+
function isJetStreamMissing(e, ...codes) {
|
|
4726
|
+
return e instanceof JetStreamApiError && codes.includes(e.code);
|
|
4727
|
+
}
|
|
4728
|
+
/** The signal's exact abort reason, or the platform-standard AbortError when none was supplied. */
|
|
4729
|
+
function abortReason(signal) {
|
|
4730
|
+
return signal?.reason instanceof Error
|
|
4731
|
+
? signal.reason
|
|
4732
|
+
: new DOMException("This operation was aborted", "AbortError");
|
|
4733
|
+
}
|
|
4384
4734
|
/** True ONLY for a denial on a **publish** — the single case that proves the message was never
|
|
4385
4735
|
* ACCEPTED or stored. (Not "never reached the server": the server necessarily received enough of
|
|
4386
4736
|
* it to reject it. The distinction matters precisely here, because this helper exists to separate
|