@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/auth-provider.d.ts +24 -1
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/connector.d.ts +7 -1
- package/dist/connector.d.ts.map +1 -1
- package/dist/connector.js.map +1 -1
- package/dist/endpoint-invoke.js +1 -1
- package/dist/endpoint-invoke.js.map +1 -1
- package/dist/endpoint.d.ts +50 -3
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +125 -23
- package/dist/endpoint.js.map +1 -1
- package/dist/provision.d.ts +23 -0
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +71 -10
- package/dist/provision.js.map +1 -1
- package/dist/remote-manager-authority.d.ts +111 -0
- package/dist/remote-manager-authority.d.ts.map +1 -1
- package/dist/remote-manager-authority.js +175 -1
- package/dist/remote-manager-authority.js.map +1 -1
- package/dist/resolve.d.ts +13 -0
- package/dist/resolve.d.ts.map +1 -1
- package/dist/resolve.js +16 -0
- package/dist/resolve.js.map +1 -1
- package/dist/schema-profile.d.ts.map +1 -1
- package/dist/schema-profile.js +87 -8
- package/dist/schema-profile.js.map +1 -1
- package/dist/streams.d.ts +21 -21
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +52 -29
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +18 -0
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +21 -0
- package/dist/subjects.js.map +1 -1
- package/package.json +2 -2
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, {
|
|
1956
|
-
|
|
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 (
|
|
2532
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
6428
|
-
// address on a socket we own first,
|
|
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
|
-
|
|
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
|
-
//
|
|
6461
|
-
//
|
|
6462
|
-
//
|
|
6463
|
-
//
|
|
6464
|
-
//
|
|
6465
|
-
//
|
|
6466
|
-
|
|
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
|