@cotal-ai/core 0.54.0 → 0.56.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/endpoint.js CHANGED
@@ -48,6 +48,27 @@ export const MULTI_FILTER_BATCH = 1_000;
48
48
  * issuing one read per channel: a space large enough to need batches must not get the fan-out back
49
49
  * under another name. */
50
50
  export const MULTI_FILTER_READ_CONCURRENCY = 4;
51
+ /** A presence bucket that has refused consecutive writes for at least one full presence TTL.
52
+ * The condition is non-transient: ordinary heartbeat retry has already failed for the whole
53
+ * liveness window, so callers should report the roster as last-known until a write succeeds. */
54
+ export class PresenceWriteStuckError extends Error {
55
+ bucket;
56
+ since;
57
+ consecutiveFailures;
58
+ ttlMs;
59
+ lastError;
60
+ code = "presence-write-stuck";
61
+ transient = false;
62
+ constructor(bucket, since, consecutiveFailures, ttlMs, lastError) {
63
+ super(`presence writes to bucket ${JSON.stringify(bucket)} have failed ${consecutiveFailures} consecutive times for at least one TTL (${ttlMs}ms); the presence view is not live until a write succeeds or the broker store is repaired${lastError ? ` (last refusal: ${lastError})` : ""}`);
64
+ this.bucket = bucket;
65
+ this.since = since;
66
+ this.consecutiveFailures = consecutiveFailures;
67
+ this.ttlMs = ttlMs;
68
+ this.lastError = lastError;
69
+ this.name = "PresenceWriteStuckError";
70
+ }
71
+ }
51
72
  /**
52
73
  * Events: "message" (CotalMessage), "presence" (PresenceEvent), "roster" (Presence[]), "error" (Error),
53
74
  * "connection" ({ connected: boolean }) — true on every successful (re)bind (initial start, manual
@@ -225,6 +246,10 @@ export class CotalEndpoint extends EventEmitter {
225
246
  presenceWriteFailingSince;
226
247
  /** #1356: the broker's last refusal message, kept alongside the start time for diagnosis. */
227
248
  lastPresenceWriteError;
249
+ /** Consecutive latest-evidence refusals on the current connection epoch. A success resets it. */
250
+ presenceWriteFailures = 0;
251
+ /** One named non-transient warning per failed run. A success re-arms it. */
252
+ presenceWriteEscalated = false;
228
253
  /** #1461: monotonic per-put generation on the presence path, so a settle can tell whether it is
229
254
  * the latest evidence on its epoch. {@link publishPresence} snapshots it at put start and every
230
255
  * settle records its generation here: a settle is the latest evidence only while no put that
@@ -1949,11 +1974,18 @@ export class CotalEndpoint extends EventEmitter {
1949
1974
  const caller = this.serviceCaller();
1950
1975
  const resolve = async (signal = opts.signal) => {
1951
1976
  signal?.throwIfAborted();
1952
- const cached = this.resolvedServices.get(endpoint);
1977
+ const cached = opts.instanceId === undefined ? this.resolvedServices.get(endpoint) : undefined;
1953
1978
  if (cached)
1954
1979
  return cached;
1955
- const svc = await resolveService(nc, this.space, endpoint, caller, { deadlineMs: opts.deadlineMs ?? 10_000, signal });
1956
- this.resolvedServices.set(endpoint, svc);
1980
+ const svc = await resolveService(nc, this.space, endpoint, caller, {
1981
+ deadlineMs: opts.deadlineMs ?? 10_000,
1982
+ signal,
1983
+ ...(opts.instanceId !== undefined ? { instanceId: opts.instanceId } : {}),
1984
+ });
1985
+ // A pinned resolve never enters the endpoint-only class cache. Otherwise a later unpinned call
1986
+ // could silently inherit that instance, or a different pin could reuse the wrong manager.
1987
+ if (opts.instanceId === undefined)
1988
+ this.resolvedServices.set(endpoint, svc);
1957
1989
  return svc;
1958
1990
  };
1959
1991
  const invokeOpts = { ...(opts.target ? { target: opts.target } : {}), ...(opts.deadlineMs !== undefined ? { deadlineMs: opts.deadlineMs } : {}) };
@@ -2453,10 +2485,10 @@ export class CotalEndpoint extends EventEmitter {
2453
2485
  * including the initial replay — the caller debounces + re-reads {@link readMembership}. The async
2454
2486
  * stop handle resolves only after its ordered broker consumer is deleted. Best-effort: a feed the
2455
2487
  * cred can't read (or absent) surfaces as an `error` event and the dashboard keeps its last snapshot. */
2456
- async watchMembership(onChange) {
2488
+ async watchMembership(onChange, onClosed) {
2457
2489
  if (this.stopped)
2458
2490
  throw new Error("endpoint stopped - cannot watch membership");
2459
- const watch = { onChange, stopped: false, arm: Promise.resolve() };
2491
+ const watch = { onChange, onClosed, stopped: false, arm: Promise.resolve() };
2460
2492
  this.membershipFeedWatches.add(watch);
2461
2493
  watch.arm = watch.arm.catch(() => { }).then(() => this.armMembershipWatch(watch));
2462
2494
  try {
@@ -2528,8 +2560,11 @@ export class CotalEndpoint extends EventEmitter {
2528
2560
  return;
2529
2561
  }
2530
2562
  iter.closed().then(() => {
2531
- if (!watch.stopped && watch.consumer === consumer)
2532
- this.emit("error", new Error("membership watch closed"));
2563
+ if (watch.stopped || watch.consumer !== consumer)
2564
+ return;
2565
+ const err = new Error("membership watch closed");
2566
+ watch.onClosed?.(err);
2567
+ this.emit("error", err);
2533
2568
  }).catch(() => { });
2534
2569
  }
2535
2570
  /** Delete identity retained across a failed/closed-epoch consumer object using the CURRENT connection. */
@@ -2591,7 +2626,7 @@ export class CotalEndpoint extends EventEmitter {
2591
2626
  }
2592
2627
  else {
2593
2628
  const closedEpoch = err.name === "ClosedConnectionError" || /^closed connection$/i.test(err.message);
2594
- const timeout = err.name === "TimeoutError" || /timeout/i.test(err.message);
2629
+ const timeout = isTimeoutError(err);
2595
2630
  const dyingEpochTimeout = timeout && (this.reconnecting || !this.nc || this.nc.isClosed());
2596
2631
  // Cleanup of an ordered consumer: a delete timeout means the broker did not answer in time,
2597
2632
  // not that the endpoint is unusable. The broker reaps an idle/ephemeral consumer anyway.
@@ -3605,6 +3640,16 @@ export class CotalEndpoint extends EventEmitter {
3605
3640
  encodeDaemonRenewalLease(instanceId) {
3606
3641
  return new TextEncoder().encode(JSON.stringify({ instanceId, since: Date.now() }));
3607
3642
  }
3643
+ /** Read the per-space daemon-credential renewal-lease row (the one {@link holdDaemonRenewalLease}
3644
+ * CAS-writes), or `undefined` when nothing holds it. A `doctor auth --fix` that lost `hold` calls
3645
+ * this through the SAME bucket the hold attempt opened, to name the live holder in its refusal —
3646
+ * `managerLeaseKv` is private, so this is the exported read beside {@link readManagerLease}. */
3647
+ async readDaemonRenewalLease() {
3648
+ const e = await (await this.managerLeaseRegistry()).get(MANAGER_RENEWAL_LEASE_KEY);
3649
+ if (!e || e.operation !== "PUT")
3650
+ return undefined;
3651
+ return JSON.parse(new TextDecoder().decode(e.value));
3652
+ }
3608
3653
  encodeManagerLease(info) {
3609
3654
  return new TextEncoder().encode(JSON.stringify(info));
3610
3655
  }
@@ -3761,6 +3806,13 @@ export class CotalEndpoint extends EventEmitter {
3761
3806
  const matches = [...this.roster.values()].filter((p) => p.card.name.toLowerCase() === name.toLowerCase());
3762
3807
  return matches.length === 1 ? matches[0].card.id : undefined;
3763
3808
  }
3809
+ /** Plane-3 publish-dedupe key for a fan-out/transfer entry (#673, SPEC §4: two `id: ""` posts
3810
+ * MUST NOT collapse to one delivery). An empty `msg.id` contributes NO msgID — a plain publish,
3811
+ * never an empty or salted one — so an id-less message is at-least-once on the durable plane (a
3812
+ * redelivery may reach the member twice; the receiver already treats each as its own delivery). */
3813
+ plane3MsgId(entry, keyPrefix) {
3814
+ return entry.msg.id === "" ? {} : { msgID: `${entry.msg.id}:${keyPrefix}` };
3815
+ }
3764
3816
  /** Publish one fan-out entry into a member LIFECYCLE's mixed inbox (`dinbox.<o>.<a>.<uid>`, SPEC
3765
3817
  * §13.1: fan-out addresses the member row's RECORDED lifecycle, never the alias's current
3766
3818
  * occupant), idempotent via `Nats-Msg-Id` (`<msgId>:<principal>:<generation>`) so a catch-up copy
@@ -3775,7 +3827,7 @@ export class CotalEndpoint extends EventEmitter {
3775
3827
  // JetStream dedupe is STREAM-WIDE, so the id must carry the LIFECYCLE too: with an alias-keyed
3776
3828
  // id, lifecycle A's copy would suppress a same-alias successor B's copy of the same message
3777
3829
  // (both start at generation 1) — cross-lifecycle suppression, not dedup.
3778
- msgID: `${entry.msg.id}:${principal}:${lifecycleUid}:${entry.generation}`,
3830
+ ...this.plane3MsgId(entry, `${principal}:${lifecycleUid}:${entry.generation}`),
3779
3831
  });
3780
3832
  }
3781
3833
  /** The fan-out consumer's delivered stream-seq — the activation-fence upper bound (red-team
@@ -4639,7 +4691,7 @@ export class CotalEndpoint extends EventEmitter {
4639
4691
  // subject (streams.ts filter_subject), and a predecessor's transferred copy must never suppress
4640
4692
  // a successor's under the same alias (disjoint lifecycles, stream-wide dedupe).
4641
4693
  await this.js.publish(dlvSubject(this.space, pr.owner, pr.actor, pr.lifecycleUid), JSON.stringify(frame), {
4642
- msgID: `${entry.msg.id}:${owner}:${pr.lifecycleUid}:${entry.generation}`,
4694
+ ...this.plane3MsgId(entry, `${owner}:${pr.lifecycleUid}:${entry.generation}`),
4643
4695
  headers: frameHeaders,
4644
4696
  });
4645
4697
  }
@@ -4673,15 +4725,20 @@ export class CotalEndpoint extends EventEmitter {
4673
4725
  return;
4674
4726
  if (!this.ownLifecycleUid)
4675
4727
  return; // no lifecycle uid — never provisioned for Plane-3 (its durable is lifecycle-keyed)
4728
+ const durable = dlvDurable(this.owner, this.actor, this.ownLifecycleUid);
4676
4729
  let consumer;
4677
4730
  try {
4678
- consumer = await this.js.consumers.get(dlvStream(this.space), dlvDurable(this.owner, this.actor, this.ownLifecycleUid));
4731
+ consumer = await this.js.consumers.get(dlvStream(this.space), durable);
4679
4732
  }
4680
4733
  catch (e) {
4681
4734
  if (isJetStreamMissing(e, JetStreamApiCodes.ConsumerNotFound))
4682
4735
  return;
4683
4736
  throw e; // a denied bind is not proof Plane-3 is absent
4684
4737
  }
4738
+ const lease = await this.readDeliveryLease(0);
4739
+ const liveDeliveryPlane = lease?.ready === true;
4740
+ if (!liveDeliveryPlane)
4741
+ this.emit("warning", new Error(`delivery durable "${durable}" for space "${this.space}" bound while the plane this connection reaches has no ready delivery lease, so the durable delivers nothing until a delivery daemon serves this plane. If a daemon serves this space on another plane, reconnect against it, which re-binds the durable there.`));
4685
4742
  const msgs = await consumer.consume();
4686
4743
  this.streamMsgs.push(msgs);
4687
4744
  void (async () => {
@@ -5433,6 +5490,8 @@ export class CotalEndpoint extends EventEmitter {
5433
5490
  if (epoch === this.presenceEpoch && !this.stopped && !superseded) {
5434
5491
  this.presenceWriteFailingSince ??= Date.now();
5435
5492
  this.lastPresenceWriteError = e?.message ?? String(e);
5493
+ this.presenceWriteFailures++;
5494
+ this.escalatePresenceWriteFailureIfStuck();
5436
5495
  }
5437
5496
  throw e;
5438
5497
  }
@@ -5471,6 +5530,21 @@ export class CotalEndpoint extends EventEmitter {
5471
5530
  clearPresenceWriteFailure() {
5472
5531
  this.presenceWriteFailingSince = undefined;
5473
5532
  this.lastPresenceWriteError = undefined;
5533
+ this.presenceWriteFailures = 0;
5534
+ this.presenceWriteEscalated = false;
5535
+ }
5536
+ /** A heartbeat retry remains recoverable inside one TTL. Once two or more consecutive writes have
5537
+ * failed across the full window, ordinary retry has exhausted the roster's liveness budget: raise
5538
+ * one named, non-transient warning. Later failures keep the duration/count current without flooding
5539
+ * the operator log; the next successful write clears and re-arms the condition. */
5540
+ escalatePresenceWriteFailureIfStuck() {
5541
+ if (this.presenceWriteEscalated ||
5542
+ this.presenceWriteFailingSince === undefined ||
5543
+ this.presenceWriteFailures < 2 ||
5544
+ Date.now() - this.presenceWriteFailingSince < this.ttlMs)
5545
+ return;
5546
+ this.presenceWriteEscalated = true;
5547
+ this.emitRecoverable(new PresenceWriteStuckError(presenceBucket(this.space), this.presenceWriteFailingSince, this.presenceWriteFailures, this.ttlMs, this.lastPresenceWriteError));
5474
5548
  }
5475
5549
  /** #1356: the presence bucket has been refusing writes since this time, or `undefined` when the
5476
5550
  * last publish succeeded. Cleared by a successful write that is the latest evidence on its
@@ -5488,6 +5562,8 @@ export class CotalEndpoint extends EventEmitter {
5488
5562
  forMs: Date.now() - this.presenceWriteFailingSince,
5489
5563
  error: this.lastPresenceWriteError,
5490
5564
  bucket: presenceBucket(this.space),
5565
+ consecutiveFailures: this.presenceWriteFailures,
5566
+ stuck: this.presenceWriteEscalated,
5491
5567
  };
5492
5568
  }
5493
5569
  /** Bind a presence watch on the current connection. Resolves true when the watch was
@@ -6364,7 +6440,8 @@ function tcpInfoProbe(server, timeoutMs) {
6364
6440
  * after the probe already returned its answer. That is issue #389, and it is upstream: nothing a
6365
6441
  * caller passes (`reconnect: false`, `timeout`) reaches the orphan. Our socket, our `destroy()`,
6366
6442
  * on every exit path — never an `unref`/force-exit, which would hide the symptom and a future
6367
- * real hang with it. */
6443
+ * real hang with it. A second orphan case (#2156) needs a greeting, not just a handshake, so it is
6444
+ * gated by {@link tcpInfoProbe} instead wherever the dial will be plaintext. */
6368
6445
  function tcpDialable(server, timeoutMs) {
6369
6446
  return new Promise((resolve) => {
6370
6447
  let socket;
@@ -6424,13 +6501,17 @@ export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
6424
6501
  return tcpInfoProbe(servers, timeoutMs);
6425
6502
  }
6426
6503
  // The credless branch above already owns its socket. This one reaches `connect()`, so it carries
6427
- // the same orphaned-socket defect probeConnect did (#389) and takes the same gate: reach the
6428
- // address on a socket we own first, and give `connect()` the remainder of the budget its own
6504
+ // the same orphaned-socket defects probeConnect did (#389, and the slow-greeting case #2156) and
6505
+ // takes the same gate choice: reach the address on a socket we own first, requiring the NATS
6506
+ // greeting for a plaintext dial (the upstream transport orphans a socket whose handshake
6507
+ // completed but whose greeting came late) and only the handshake for a TLS-required or websocket
6508
+ // dial (no plaintext greeting to read), then give `connect()` the remainder of the budget its own
6429
6509
  // timeout always covered. A gate failure is a genuine connection failure, which is exactly the
6430
6510
  // `false` the catch below already returns for one — an auth rejection cannot reach us from an
6431
6511
  // address that never completed a handshake.
6432
6512
  const started = Date.now();
6433
- if (!(await tcpDialable(servers, timeoutMs)))
6513
+ const gate = opts.tls || wsServers(servers) ? tcpDialable : tcpInfoProbe;
6514
+ if (!(await gate(servers, timeoutMs)))
6434
6515
  return false;
6435
6516
  try {
6436
6517
  const nc = await dialerFor(servers)({
@@ -6447,6 +6528,14 @@ export async function isReachable(servers = DEFAULT_SERVER, opts = {}) {
6447
6528
  return e instanceof AuthorizationError || e instanceof UserAuthenticationExpiredError;
6448
6529
  }
6449
6530
  }
6531
+ /** True when `err` is a dial/consumer-op timeout rather than a real refusal — the one shared test
6532
+ * for "the operation ran out of its own budget", used both by {@link classifyProbeFailure} (#851:
6533
+ * a probe timeout must never collapse into `unreachable`, which a TLS-required target then
6534
+ * misreads as a trust failure) and by {@link Endpoint#disarmMembershipWatch}'s consumer-delete
6535
+ * cleanup, which predates it. */
6536
+ function isTimeoutError(err) {
6537
+ return err instanceof Error && (err.name === "TimeoutError" || /timeout/i.test(err.message));
6538
+ }
6450
6539
  /** Like {@link isReachable}, but distinguishes "up but won't take these creds" from "nothing there".
6451
6540
  * `spawn` needs the difference: auth-required → name the trust dir + next step; unreachable → the
6452
6541
  * mesh is down (prune the stale entry, tell the user to `cotal up`). Pass `creds` to confirm a
@@ -6456,14 +6545,22 @@ export async function probeConnect(server = DEFAULT_SERVER, opts = {}) {
6456
6545
  const timeoutMs = opts.timeoutMs ?? defaultProbeTimeoutMs(server);
6457
6546
  const started = Date.now();
6458
6547
  // Reach the address on a socket we own BEFORE handing it to `connect()`, which orphans the
6459
- // connection it never established (see {@link tcpDialable} for the upstream mechanism, #389).
6460
- // This cannot change any verdict: every address that gets past here had to complete a TCP
6461
- // handshake for `connect()` to have gotten anywhere either, and a gate failure is routed through
6462
- // the SAME classification the catch uses — so a locally-dead cred is still `stale-auth` and not
6463
- // silently downgraded to `unreachable` by the address being dark. The cost is one extra
6464
- // handshake on the reachable path; the deadline below is the REMAINDER of the budget, because
6465
- // `connect()`'s own `timeout` always covered its handshake too.
6466
- if (!(await tcpDialable(server, timeoutMs)))
6548
+ // connection it never established (see {@link tcpDialable} for the upstream mechanism, #389,
6549
+ // and {@link tcpInfoProbe} for the second orphan case below, #2156). For a plaintext dial the
6550
+ // gate must see the server's NATS greeting, not just the handshake: `connect()`'s own timeout
6551
+ // can fire while it is still waiting on that greeting, and the upstream transport only destroys
6552
+ // a socket whose handshake never completed, not one whose greeting came late (`_closed`'s guard
6553
+ // on `connected`, which is set only after the greeting is read) — so `tcpInfoProbe` (which owns
6554
+ // its socket and requires the greeting) is the gate there, while a TLS-required or websocket
6555
+ // dial has no plaintext greeting to read and keeps the handshake-only `tcpDialable` gate. This
6556
+ // cannot change any verdict: every address that gets past here had to complete a TCP handshake
6557
+ // for `connect()` to have gotten anywhere either, and a gate failure is routed through the SAME
6558
+ // classification the catch uses — so a locally-dead cred is still `stale-auth` and not silently
6559
+ // downgraded to `unreachable` by the address being dark. The cost is one extra handshake (and,
6560
+ // off TLS/ws, one extra greeting read) on the reachable path; the deadline below is the
6561
+ // REMAINDER of the budget, because `connect()`'s own `timeout` always covered its handshake too.
6562
+ const gate = opts.tls || wsServers(server) ? tcpDialable : tcpInfoProbe;
6563
+ if (!(await gate(server, timeoutMs)))
6467
6564
  return classifyProbeFailure(undefined, opts);
6468
6565
  try {
6469
6566
  const nc = await dialerFor(server)({
@@ -6504,6 +6601,11 @@ function classifyProbeFailure(e, opts) {
6504
6601
  // The broker answered but rejected these creds (so it IS up) — auth-required, not stale-auth.
6505
6602
  if (e instanceof AuthorizationError)
6506
6603
  return { ok: false, reason: "auth-required" };
6604
+ // A dial that ran out of its own budget is neither a refusal nor a dead broker — it is latency.
6605
+ // `e` is undefined when the tcpDialable gate refused before any connect() attempt; that path has
6606
+ // no timeout to inspect and must stay `unreachable` (nothing answered at all).
6607
+ if (e !== undefined && isTimeoutError(e))
6608
+ return { ok: false, reason: "timeout" };
6507
6609
  return { ok: false, reason: "unreachable" };
6508
6610
  }
6509
6611
  //# sourceMappingURL=endpoint.js.map