@cotal-ai/manager 0.161.1 → 0.163.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/manager.d.ts +21 -9
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +218 -47
- package/dist/manager.js.map +1 -1
- package/package.json +12 -12
package/dist/manager.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { drainAndClose } from "@cotal-ai/core";
|
|
2
|
+
import { drainAndClose, requestSafeReconnect } from "@cotal-ai/core";
|
|
3
3
|
import { hostname, homedir } from "node:os";
|
|
4
4
|
import { listDirectory } from "./list-dir.js";
|
|
5
5
|
import { credsAuthenticator, RequestError } from "@nats-io/transport-node";
|
|
@@ -177,6 +177,7 @@ const STARTUP_RECONCILING = "startup static lifecycle reconciliation";
|
|
|
177
177
|
* authority. The finite ~36s window is intentionally not a liveness inference: exhausting it
|
|
178
178
|
* leaves the durable alias held and requires a later manager start. */
|
|
179
179
|
export const STATIC_RECONCILE_RETRY_DELAYS_MS = [1_000, 5_000, 30_000];
|
|
180
|
+
const REMOTE_STANDING_BUDGET_MS = 10_000;
|
|
180
181
|
const STATIC_RECONCILE_MAX_ATTEMPTS = STATIC_RECONCILE_RETRY_DELAYS_MS.length + 1;
|
|
181
182
|
/** The owner a retained seat resumes under: its inventory identity's, which is the same value the
|
|
182
183
|
* adopted seat records as `userOwner`, so a resumed seat is counted against the owner that held
|
|
@@ -245,6 +246,13 @@ function withTimeout(p, ms, msg) {
|
|
|
245
246
|
});
|
|
246
247
|
return Promise.race([p.finally(() => clearTimeout(timer)), timeout]);
|
|
247
248
|
}
|
|
249
|
+
/** One absolute budget across authority, preflight and adoption waits, or one closed-serve attempt. */
|
|
250
|
+
function remoteStandingTimeLeft(deadline) {
|
|
251
|
+
const left = deadline - performance.now();
|
|
252
|
+
if (left <= 0)
|
|
253
|
+
throw new Error("remote manager standing operation exceeded its 10000ms aggregate budget");
|
|
254
|
+
return Math.ceil(left);
|
|
255
|
+
}
|
|
248
256
|
function sameStrings(a, b) {
|
|
249
257
|
return JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
|
|
250
258
|
}
|
|
@@ -886,6 +894,8 @@ export class Manager {
|
|
|
886
894
|
remoteExecutorCreds;
|
|
887
895
|
remoteSupervisorCreds;
|
|
888
896
|
remoteBundleRenewal;
|
|
897
|
+
remoteBundleAdoption;
|
|
898
|
+
finishRemoteBundleAdoption;
|
|
889
899
|
/** A refused or failed standing candidate is cleanup debt, never a silent loss: the last-good
|
|
890
900
|
* family stays live and this records what the host may have signed but the manager never adopted. */
|
|
891
901
|
remoteRenewalDebt;
|
|
@@ -900,6 +910,25 @@ export class Manager {
|
|
|
900
910
|
const servers = this.servers ?? DEFAULT_SERVER;
|
|
901
911
|
return dialerFor(servers)({ ...opts, servers });
|
|
902
912
|
}
|
|
913
|
+
/** Initial NATS timeout is per dial, not per server list. Keep the aggregate deadline too, and
|
|
914
|
+
* close a late result rather than letting an abandoned preflight or recovery install a transport. */
|
|
915
|
+
async dialRemoteStanding(opts, deadline) {
|
|
916
|
+
const ms = remoteStandingTimeLeft(deadline);
|
|
917
|
+
const pending = this.dial({ ...opts, timeout: ms });
|
|
918
|
+
try {
|
|
919
|
+
const nc = await withTimeout(pending, ms, "remote manager standing dial exceeded its 10000ms aggregate budget");
|
|
920
|
+
remoteStandingTimeLeft(deadline);
|
|
921
|
+
if (this.leaseStopping)
|
|
922
|
+
throw new Error("manager stopped while a remote standing dial was in flight");
|
|
923
|
+
return nc;
|
|
924
|
+
}
|
|
925
|
+
catch (e) {
|
|
926
|
+
// Timing out our wait does not cancel connect(). It still owns its socket until it rejects
|
|
927
|
+
// or returns a connection, which must close without serving or changing a current holder.
|
|
928
|
+
void pending.then((nc) => nc.close(), () => { }).catch((error) => console.error(`! remote manager abandoned standing dial cleanup: ${rejectionText(error)}`));
|
|
929
|
+
throw e;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
903
932
|
constructor(opts) {
|
|
904
933
|
if (opts.expectedConnectorGeneration !== undefined && opts.expectedConnectorGeneration !== MANAGER_VERSION)
|
|
905
934
|
throw new Error(`expected connector generation ${opts.expectedConnectorGeneration} differs from this Manager's loaded generation ${MANAGER_VERSION}; restart from the installed CLI`);
|
|
@@ -1105,8 +1134,8 @@ export class Manager {
|
|
|
1105
1134
|
* reads `--allow-subscribe` only for the agent profile while the observer arm of `permissionsFor`
|
|
1106
1135
|
* hardcodes `chat.>`: an operator narrowing a reader to one event plane was handed a reader of every
|
|
1107
1136
|
* channel in the space. The user half named a bare `cotal actor grant`, and an omitted flag there is
|
|
1108
|
-
* not "leave it alone" but the WIDE default (`>` read, `>` post, `spawn,role:default` scope), so the same
|
|
1109
|
-
* sentence handed out a full-mesh reader-writer with spawn. The static half was found by RUNNING
|
|
1137
|
+
* not "leave it alone" but the WIDE default (`>` read, `>` post, `spawn,run,role:default` scope), so the same
|
|
1138
|
+
* sentence handed out a full-mesh reader-writer with spawn and workflow scope. The static half was found by RUNNING
|
|
1110
1139
|
* the printed command and decoding what it produced; the user half was found by READING it against
|
|
1111
1140
|
* `runActor`'s defaults. Neither is the only way in, and a remedy string is not proved by either
|
|
1112
1141
|
* one alone.
|
|
@@ -1167,7 +1196,7 @@ export class Manager {
|
|
|
1167
1196
|
case "user":
|
|
1168
1197
|
return (`\`cotal actor grant <reader> --owner ${owner} --scope '' --allow-subscribe '${channel}' ` +
|
|
1169
1198
|
`--allow-publish ''\`, with every field spelled out: \`actor grant\` is an upsert of the ` +
|
|
1170
|
-
`WHOLE row, and an omitted flag is refused, or with \`--full\` is the WIDE default (\`>\` read, \`>\` post, \`spawn,role:default\` ` +
|
|
1199
|
+
`WHOLE row, and an omitted flag is refused, or with \`--full\` is the WIDE default (\`>\` read, \`>\` post, \`spawn,run,role:default\` ` +
|
|
1171
1200
|
`scope).`);
|
|
1172
1201
|
case "static":
|
|
1173
1202
|
return (`\`cotal mint <reader> --profile agent --allow-subscribe ${channel} --provision\`, where there ` +
|
|
@@ -1797,15 +1826,65 @@ export class Manager {
|
|
|
1797
1826
|
this.endpointServeRenewalTimer.unref?.();
|
|
1798
1827
|
console.error(`manager endpoint-serve renewal scheduled at ${renewAt}`);
|
|
1799
1828
|
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1829
|
+
credentialPushRetry;
|
|
1830
|
+
credentialPushes = new Map();
|
|
1831
|
+
supervisorCredentialPush;
|
|
1832
|
+
pendingCredentialPushes = new Set();
|
|
1833
|
+
supervisorCredentialPushPending = false;
|
|
1834
|
+
scheduleCredentialPushRetry() {
|
|
1835
|
+
if (this.credentialPushRetry || this.leaseStopping)
|
|
1836
|
+
return;
|
|
1837
|
+
this.credentialPushRetry = setTimeout(() => {
|
|
1838
|
+
this.credentialPushRetry = undefined;
|
|
1839
|
+
if (this.leaseStopping)
|
|
1840
|
+
return;
|
|
1841
|
+
void Promise.all([
|
|
1842
|
+
...[...this.pendingCredentialPushes].map((nc) => this.pushRenewedCreds(nc)),
|
|
1843
|
+
...(this.supervisorCredentialPushPending ? [this.pushSupervisorCreds()] : []),
|
|
1844
|
+
]);
|
|
1845
|
+
}, 1_000);
|
|
1846
|
+
this.credentialPushRetry.unref();
|
|
1847
|
+
}
|
|
1848
|
+
/** Retain a busy transport and retry its adopted credential without replaying requests. */
|
|
1849
|
+
pushRenewedCreds(nc) {
|
|
1850
|
+
const active = this.credentialPushes.get(nc);
|
|
1851
|
+
if (active)
|
|
1852
|
+
return active;
|
|
1853
|
+
const push = this.pushRenewedCredsOnce(nc).finally(() => { this.credentialPushes.delete(nc); });
|
|
1854
|
+
this.credentialPushes.set(nc, push);
|
|
1855
|
+
return push;
|
|
1856
|
+
}
|
|
1857
|
+
async pushRenewedCredsOnce(nc) {
|
|
1858
|
+
this.pendingCredentialPushes.add(nc);
|
|
1859
|
+
try {
|
|
1860
|
+
await requestSafeReconnect(nc);
|
|
1861
|
+
this.pendingCredentialPushes.delete(nc);
|
|
1862
|
+
}
|
|
1863
|
+
catch (e) {
|
|
1864
|
+
if (nc.isClosed())
|
|
1865
|
+
this.pendingCredentialPushes.delete(nc);
|
|
1866
|
+
console.error(`! manager credential push deferred: ${rejectionText(e)}`);
|
|
1867
|
+
if (!nc.isClosed())
|
|
1868
|
+
this.scheduleCredentialPushRetry();
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
pushSupervisorCreds() {
|
|
1872
|
+
if (this.supervisorCredentialPush)
|
|
1873
|
+
return this.supervisorCredentialPush;
|
|
1874
|
+
const push = this.pushSupervisorCredsOnce().finally(() => { this.supervisorCredentialPush = undefined; });
|
|
1875
|
+
this.supervisorCredentialPush = push;
|
|
1876
|
+
return push;
|
|
1877
|
+
}
|
|
1878
|
+
async pushSupervisorCredsOnce() {
|
|
1879
|
+
this.supervisorCredentialPushPending = true;
|
|
1880
|
+
try {
|
|
1881
|
+
await this.ep.refreshCredsRequestSafe();
|
|
1882
|
+
this.supervisorCredentialPushPending = false;
|
|
1883
|
+
}
|
|
1884
|
+
catch (e) {
|
|
1885
|
+
console.error(`! remote manager supervisor credential push deferred: ${rejectionText(e)}`);
|
|
1886
|
+
this.scheduleCredentialPushRetry();
|
|
1887
|
+
}
|
|
1809
1888
|
}
|
|
1810
1889
|
/** #2073: the shared bounded-retry shape for a closed standing connection: wait out each of
|
|
1811
1890
|
* {@link STATIC_RECONCILE_RETRY_DELAYS_MS}, trying `attempt` after each. A stop landing while
|
|
@@ -2022,14 +2101,19 @@ export class Manager {
|
|
|
2022
2101
|
return this.remoteBundleRenewal;
|
|
2023
2102
|
if (this.leaseStopping)
|
|
2024
2103
|
return;
|
|
2025
|
-
|
|
2104
|
+
this.remoteBundleAdoption = new Promise((resolve) => { this.finishRemoteBundleAdoption = resolve; });
|
|
2105
|
+
const renewal = this.renewRemoteStandingBundleOnce(force, performance.now() + REMOTE_STANDING_BUDGET_MS);
|
|
2026
2106
|
this.remoteBundleRenewal = renewal;
|
|
2027
2107
|
try {
|
|
2028
2108
|
await renewal;
|
|
2029
2109
|
}
|
|
2030
2110
|
finally {
|
|
2031
|
-
|
|
2111
|
+
this.finishRemoteBundleAdoption?.();
|
|
2112
|
+
if (this.remoteBundleRenewal === renewal) {
|
|
2032
2113
|
this.remoteBundleRenewal = undefined;
|
|
2114
|
+
this.remoteBundleAdoption = undefined;
|
|
2115
|
+
this.finishRemoteBundleAdoption = undefined;
|
|
2116
|
+
}
|
|
2033
2117
|
}
|
|
2034
2118
|
}
|
|
2035
2119
|
/** A signerless manager's closed standing connection re-dials through the all-duty family only.
|
|
@@ -2042,18 +2126,29 @@ export class Manager {
|
|
|
2042
2126
|
if (inspectCredHealth(read() ?? "").state !== "healthy")
|
|
2043
2127
|
throw new Error(`the ${label} credential is not healthy after all-duty renewal; last-good is kept and nothing is minted locally`);
|
|
2044
2128
|
}
|
|
2045
|
-
async renewRemoteStandingBundleOnce(force) {
|
|
2129
|
+
async renewRemoteStandingBundleOnce(force, deadline) {
|
|
2046
2130
|
const remote = this.remoteAuthority;
|
|
2047
2131
|
if (!remote?.renewStandingBundle || !this.serviceServe)
|
|
2048
2132
|
return;
|
|
2049
2133
|
const current = [this.remoteSupervisorCreds, this.remoteExecutorCreds, this.serviceServe.creds,
|
|
2050
2134
|
this.goalWriterCreds, this.sessionLedgerCreds];
|
|
2051
|
-
if (!force && current.every((cred) => cred && inspectCredHealth(cred).state === "healthy"))
|
|
2135
|
+
if (!force && current.every((cred) => cred && inspectCredHealth(cred).state === "healthy")) {
|
|
2136
|
+
await Promise.all([
|
|
2137
|
+
...[...this.pendingCredentialPushes].map((nc) => this.pushRenewedCreds(nc)),
|
|
2138
|
+
...(this.supervisorCredentialPushPending ? [this.pushSupervisorCreds()] : []),
|
|
2139
|
+
]);
|
|
2052
2140
|
return;
|
|
2141
|
+
}
|
|
2053
2142
|
const processEpoch = this.serviceServe.grant.epoch;
|
|
2054
2143
|
let candidate;
|
|
2144
|
+
const serving = this.serviceServe;
|
|
2145
|
+
const previous = { nc: serving.nc, handle: serving.handle };
|
|
2146
|
+
let replacement;
|
|
2147
|
+
let adopted = false;
|
|
2055
2148
|
try {
|
|
2056
|
-
|
|
2149
|
+
// Only this continuation can adopt. A host operation may still issue after this wait times
|
|
2150
|
+
// out, but its late result cannot re-enter preflight or change any Manager holder.
|
|
2151
|
+
candidate = await withTimeout(remote.renewStandingBundle(processEpoch), remoteStandingTimeLeft(deadline), "remote manager standing authority exceeded its 10000ms aggregate budget; issuance outcome is unknown");
|
|
2057
2152
|
if (Object.keys(candidate).sort().join(",") !== [...REMOTE_MANAGER_IDENTITY_NAMES].sort().join(","))
|
|
2058
2153
|
throw new Error("host returned an incomplete or widened standing credential family");
|
|
2059
2154
|
for (const name of REMOTE_MANAGER_IDENTITY_NAMES) {
|
|
@@ -2064,14 +2159,26 @@ export class Manager {
|
|
|
2064
2159
|
if (credsClaims(candidate[name]).sub !== remote.identities[name].id)
|
|
2065
2160
|
throw new Error(`host returned a ${name} credential for a different nkey than the held identity`);
|
|
2066
2161
|
const staged = candidate[name];
|
|
2067
|
-
const nc = await this.
|
|
2162
|
+
const nc = await this.dialRemoteStanding({ authenticator: (nonce) => credsAuthenticator(new TextEncoder().encode(staged))(nonce), maxReconnectAttempts: 0 }, deadline);
|
|
2068
2163
|
await nc.close();
|
|
2069
2164
|
}
|
|
2070
|
-
|
|
2071
|
-
|
|
2165
|
+
remoteStandingTimeLeft(deadline);
|
|
2166
|
+
if (this.leaseStopping || this.serviceServe !== serving || serving.nc !== previous.nc || serving.grant.epoch !== processEpoch)
|
|
2167
|
+
throw new Error("manager changed its active serve epoch or connection while standing renewal was in flight");
|
|
2168
|
+
const stagedServeCreds = candidate.serve;
|
|
2169
|
+
replacement = await this.dialServeConnection({ ...serving, creds: stagedServeCreds }, () => adopted ? serving.creds : stagedServeCreds, deadline);
|
|
2170
|
+
remoteStandingTimeLeft(deadline);
|
|
2171
|
+
if (this.leaseStopping || this.serviceServe !== serving || serving.nc !== previous.nc || serving.grant.epoch !== processEpoch)
|
|
2172
|
+
throw new Error("manager changed its active serve epoch or connection while standing renewal was in flight");
|
|
2173
|
+
if (REMOTE_MANAGER_IDENTITY_NAMES.some((name) => inspectCredHealth(candidate[name]).state !== "healthy"))
|
|
2174
|
+
throw new Error("standing candidate is no longer healthy after broker preflight");
|
|
2175
|
+
serving.nc = replacement.nc;
|
|
2176
|
+
serving.handle = replacement.handle;
|
|
2177
|
+
replacement = undefined;
|
|
2072
2178
|
this.remoteSupervisorCreds = candidate.supervisor;
|
|
2073
2179
|
this.remoteExecutorCreds = candidate.executor;
|
|
2074
2180
|
this.serviceServe.creds = candidate.serve;
|
|
2181
|
+
adopted = true;
|
|
2075
2182
|
this.goalWriterCreds = candidate.goalWriter;
|
|
2076
2183
|
this.sessionLedgerCreds = candidate.sessionLedger;
|
|
2077
2184
|
if (this.goalWriter)
|
|
@@ -2081,14 +2188,35 @@ export class Manager {
|
|
|
2081
2188
|
// Adoption is committed above; a connection that cannot come back yet is a push failure the
|
|
2082
2189
|
// endpoint's own reconnect loop retries with the adopted credential, not unadopted debt.
|
|
2083
2190
|
this.remoteRenewalDebt = undefined;
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2191
|
+
// Maintenance needed by admitted handlers must not wait for those same handlers to drain.
|
|
2192
|
+
this.finishRemoteBundleAdoption?.();
|
|
2193
|
+
await withTimeout(Promise.all([
|
|
2194
|
+
this.pushSupervisorCreds(),
|
|
2195
|
+
(async () => {
|
|
2196
|
+
try {
|
|
2197
|
+
// A handler or a lost broker PONG can keep stop/flush pending forever. Only the
|
|
2198
|
+
// retired transport is expendable: never retry its requests or close the replacement.
|
|
2199
|
+
await withTimeout((async () => {
|
|
2200
|
+
await previous.handle.stop();
|
|
2201
|
+
await previous.nc.flush();
|
|
2202
|
+
})(), 5_000, "old serve drain unfinished after 5000ms; closing retired transport, in-flight request outcomes are unknown and must not be retried");
|
|
2203
|
+
}
|
|
2204
|
+
finally {
|
|
2205
|
+
await previous.nc.close();
|
|
2206
|
+
}
|
|
2207
|
+
})().catch((e) => console.error(`! remote manager retired serve drainage after renewal: ${rejectionText(e)}`)),
|
|
2087
2208
|
...(this.goalWriter ? [this.pushRenewedCreds(this.goalWriter.nc)] : []),
|
|
2088
2209
|
...(this.sessionLedgerConn ? [this.pushRenewedCreds(this.sessionLedgerConn.nc)] : []),
|
|
2089
|
-
]);
|
|
2210
|
+
]), remoteStandingTimeLeft(deadline), "remote manager standing reconnect exceeded its 10000ms aggregate budget; adopted transports keep their native reconnect ownership");
|
|
2090
2211
|
}
|
|
2091
2212
|
catch (e) {
|
|
2213
|
+
if (replacement) {
|
|
2214
|
+
await this.discardServeCandidate(replacement.nc, replacement.handle);
|
|
2215
|
+
}
|
|
2216
|
+
if (adopted) {
|
|
2217
|
+
console.error(`! remote manager adopted standing family: ${rejectionText(e)}`);
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2092
2220
|
const unadoptedSubjects = candidate && typeof candidate === "object"
|
|
2093
2221
|
? Object.values(candidate).flatMap((cred) => { try {
|
|
2094
2222
|
return typeof cred === "string" && credsClaims(cred).sub ? [credsClaims(cred).sub] : [];
|
|
@@ -2096,7 +2224,7 @@ export class Manager {
|
|
|
2096
2224
|
catch {
|
|
2097
2225
|
return [];
|
|
2098
2226
|
} })
|
|
2099
|
-
: [];
|
|
2227
|
+
: REMOTE_MANAGER_IDENTITY_NAMES.map((name) => remote.identities[name].id);
|
|
2100
2228
|
this.remoteRenewalDebt = { at: Date.now(), processEpoch, reason: rejectionText(e), unadoptedSubjects };
|
|
2101
2229
|
console.error(`! remote manager all-duty renewal: ${this.remoteRenewalDebt.reason} - no unsigned or local replacement is available`);
|
|
2102
2230
|
if (force)
|
|
@@ -2695,19 +2823,13 @@ export class Manager {
|
|
|
2695
2823
|
handle = runtime.adopt(reference);
|
|
2696
2824
|
}
|
|
2697
2825
|
catch (e) {
|
|
2698
|
-
const reap = runtime.reap;
|
|
2699
|
-
if (typeof reap !== "function")
|
|
2700
|
-
return;
|
|
2701
|
-
let outcome;
|
|
2702
2826
|
try {
|
|
2703
|
-
|
|
2827
|
+
await requireRuntimeReap(runtime, reference);
|
|
2704
2828
|
}
|
|
2705
2829
|
catch {
|
|
2706
2830
|
// The runtime could not prove the seat gone. Leave the record for a later start.
|
|
2707
2831
|
return;
|
|
2708
2832
|
}
|
|
2709
|
-
if (outcome !== "reaped")
|
|
2710
|
-
return;
|
|
2711
2833
|
console.error(`! ${name}: the released seat is gone (${rejectionText(e)}); retiring its lifecycle`);
|
|
2712
2834
|
handle = {
|
|
2713
2835
|
name,
|
|
@@ -2849,6 +2971,8 @@ export class Manager {
|
|
|
2849
2971
|
}
|
|
2850
2972
|
async stopOnce(withAgents) {
|
|
2851
2973
|
this.leaseStopping = true;
|
|
2974
|
+
clearTimeout(this.credentialPushRetry);
|
|
2975
|
+
this.credentialPushRetry = undefined;
|
|
2852
2976
|
this.transcripts?.stop();
|
|
2853
2977
|
this.staticReconcileStopping = true;
|
|
2854
2978
|
this.chatExpiry.stop();
|
|
@@ -2875,8 +2999,8 @@ export class Manager {
|
|
|
2875
2999
|
// still move the key's revision after we clear the timer, and releasing against a cached
|
|
2876
3000
|
// revision it has since moved is a CAS the broker correctly refuses, silently, forever.
|
|
2877
3001
|
await this.leaseRenewTask?.catch(() => { });
|
|
2878
|
-
//
|
|
2879
|
-
//
|
|
3002
|
+
// Join the bounded Manager renewal. Its stop/deadline fence refuses late adoption, but the
|
|
3003
|
+
// host still owns any submitted issuance and its outcome can remain unknown after timeout.
|
|
2880
3004
|
await this.remoteBundleRenewal?.catch(() => { });
|
|
2881
3005
|
// A spawn admitted before the fence closed may still be launching. Its seat must be in the
|
|
2882
3006
|
// snapshot below, or it would outlive a stop that reported success.
|
|
@@ -3061,6 +3185,14 @@ export class Manager {
|
|
|
3061
3185
|
}
|
|
3062
3186
|
catch { /* best effort */ }
|
|
3063
3187
|
}
|
|
3188
|
+
/** A failed candidate must not extend preparation by draining subscriptions on a stalled socket.
|
|
3189
|
+
* Close only that candidate first. Native stop still owns any already-dispatched handlers, whose
|
|
3190
|
+
* outcomes remain unknown to callers and must not be replayed; they do not hold family adoption. */
|
|
3191
|
+
async discardServeCandidate(nc, handle) {
|
|
3192
|
+
await nc.close();
|
|
3193
|
+
if (handle)
|
|
3194
|
+
void handle.stop().catch((e) => console.error(`! remote manager discarded serve candidate cleanup: ${rejectionText(e)}`));
|
|
3195
|
+
}
|
|
3064
3196
|
/** Track a dedicated connection's transport into {@link transportDown} from the first moment:
|
|
3065
3197
|
* nats.js reports `disconnect` when the socket drops and `reconnect` only after it has re-sent the
|
|
3066
3198
|
* connection's subscriptions. If the status stream itself fails, the transport can no longer be
|
|
@@ -8541,8 +8673,19 @@ export class Manager {
|
|
|
8541
8673
|
// maintenance executor through the existing host operation; never resurrect standing duties.
|
|
8542
8674
|
if (this.leaseStopping)
|
|
8543
8675
|
await this.renewRemoteExecutor(true);
|
|
8676
|
+
else if (this.remoteAuthority.renewStandingBundle) {
|
|
8677
|
+
// The renewal drains old handlers after adopting. A handler waiting here must be
|
|
8678
|
+
// released at adoption, not after its own completion is required by that drain.
|
|
8679
|
+
const renewal = this.renewRemoteStandingBundle(true);
|
|
8680
|
+
void renewal.catch((error) => console.error(`! remote manager standing renewal completion: ${rejectionText(error)}`));
|
|
8681
|
+
await this.remoteBundleAdoption;
|
|
8682
|
+
if (!this.remoteExecutorCreds || inspectCredHealth(this.remoteExecutorCreds).state !== "healthy") {
|
|
8683
|
+
await renewal;
|
|
8684
|
+
throw new Error("standing renewal did not adopt a healthy scoped executor");
|
|
8685
|
+
}
|
|
8686
|
+
}
|
|
8544
8687
|
else
|
|
8545
|
-
await
|
|
8688
|
+
await this.renewRemoteExecutor(true);
|
|
8546
8689
|
}
|
|
8547
8690
|
const creds = this.remoteExecutorCreds ?? (this.auth
|
|
8548
8691
|
? await mintCreds(this.auth, identity, "endpoint-serve-executor", {
|
|
@@ -8714,13 +8857,14 @@ export class Manager {
|
|
|
8714
8857
|
* so there is one place that dials and serves this endpoint. Attaches {@link
|
|
8715
8858
|
* onServeConnectionClosed} as the close handler in both cases: a closed serve connection is
|
|
8716
8859
|
* always a fault this manager acts on, never a bare log line. */
|
|
8717
|
-
async dialServeConnection(state) {
|
|
8718
|
-
const
|
|
8860
|
+
async dialServeConnection(state, readCreds = () => state.creds, deadline) {
|
|
8861
|
+
const opts = {
|
|
8719
8862
|
// Open mesh: a bare serve connection (no credential exists; the broker enforces nothing).
|
|
8720
|
-
...(state.creds !== undefined ? standaloneConnectOpts({ creds:
|
|
8863
|
+
...(state.creds !== undefined ? standaloneConnectOpts({ creds: readCreds, tls: false }) : {}),
|
|
8721
8864
|
inboxPrefix: `_INBOX_${state.identity.id}`,
|
|
8722
8865
|
maxReconnectAttempts: -1,
|
|
8723
|
-
}
|
|
8866
|
+
};
|
|
8867
|
+
const nc = await (deadline === undefined ? this.dial(opts) : this.dialRemoteStanding(opts, deadline));
|
|
8724
8868
|
this.trackTransport(nc);
|
|
8725
8869
|
let handle;
|
|
8726
8870
|
try {
|
|
@@ -8758,14 +8902,22 @@ export class Manager {
|
|
|
8758
8902
|
},
|
|
8759
8903
|
...(this.omitClassCommands() ? { omitClassCommands: this.omitClassCommands() } : {}),
|
|
8760
8904
|
});
|
|
8905
|
+
// A subscribed replacement must be visible to the broker before the old rails retire.
|
|
8906
|
+
if (deadline === undefined)
|
|
8907
|
+
await nc.flush();
|
|
8908
|
+
else
|
|
8909
|
+
await withTimeout(nc.flush(), remoteStandingTimeLeft(deadline), "remote manager standing serve flush exceeded its 10000ms aggregate budget");
|
|
8761
8910
|
}
|
|
8762
8911
|
catch (e) {
|
|
8763
|
-
await
|
|
8912
|
+
await this.discardServeCandidate(nc, handle);
|
|
8764
8913
|
throw e;
|
|
8765
8914
|
}
|
|
8766
|
-
// Wired only once the connection serves: a
|
|
8915
|
+
// Wired only once the connection serves: a candidate this method closes after a
|
|
8767
8916
|
// refused serve is not a fault to recover, and the caller already sees the refusal.
|
|
8768
|
-
nc.closed().then((err) => {
|
|
8917
|
+
nc.closed().then((err) => {
|
|
8918
|
+
if (this.serviceServe?.nc === nc)
|
|
8919
|
+
void this.onServeConnectionClosed(err ?? undefined, nc);
|
|
8920
|
+
});
|
|
8769
8921
|
return { nc, handle };
|
|
8770
8922
|
}
|
|
8771
8923
|
/** #2073: re-mint the SAME `endpoint-serve` identity through the SAME scoped one-shot executor and
|
|
@@ -8792,7 +8944,7 @@ export class Manager {
|
|
|
8792
8944
|
* lease and answers nothing is the worst of the three states this issue named, and holding the
|
|
8793
8945
|
* connection open under unbounded reconnects (the transport-drop case) never reaches this method
|
|
8794
8946
|
* at all — `nc.closed()` does not resolve until the client itself gives up. */
|
|
8795
|
-
async onServeConnectionClosed(err) {
|
|
8947
|
+
async onServeConnectionClosed(err, closed) {
|
|
8796
8948
|
if (this.leaseStopping)
|
|
8797
8949
|
return;
|
|
8798
8950
|
const s = this.serviceServe;
|
|
@@ -8800,13 +8952,32 @@ export class Manager {
|
|
|
8800
8952
|
const recovered = await this.boundedReconnect("service endpoint", async () => {
|
|
8801
8953
|
if (!s)
|
|
8802
8954
|
return false;
|
|
8803
|
-
|
|
8804
|
-
|
|
8955
|
+
const deadline = this.remoteAuthority ? performance.now() + REMOTE_STANDING_BUDGET_MS : undefined;
|
|
8956
|
+
// Let an admitted renewal finish owning its candidate before recovery opens another.
|
|
8957
|
+
if (deadline === undefined)
|
|
8958
|
+
await this.remoteBundleRenewal;
|
|
8959
|
+
else
|
|
8960
|
+
await withTimeout(Promise.resolve(this.remoteBundleRenewal), remoteStandingTimeLeft(deadline), "remote manager closed-serve renewal wait exceeded its 10000ms aggregate budget");
|
|
8961
|
+
if (this.leaseStopping || this.serviceServe !== s || s.nc !== closed)
|
|
8962
|
+
return true;
|
|
8963
|
+
if (deadline !== undefined)
|
|
8964
|
+
await withTimeout(this.renewRemoteBeforeRedial("serve", () => s.creds), remoteStandingTimeLeft(deadline), "remote manager closed-serve renewal wait exceeded its 10000ms aggregate budget");
|
|
8805
8965
|
else if (inspectCredHealth(s.creds ?? "").state !== "healthy" && s.creds !== undefined) {
|
|
8806
8966
|
s.creds = await this.remintServeCreds(s);
|
|
8807
8967
|
this.scheduleServeRenewal(s.creds);
|
|
8808
8968
|
}
|
|
8809
|
-
|
|
8969
|
+
if (this.leaseStopping || this.serviceServe !== s || s.nc !== closed)
|
|
8970
|
+
return true;
|
|
8971
|
+
const { nc, handle } = await this.dialServeConnection(s, undefined, deadline);
|
|
8972
|
+
if (this.leaseStopping || this.serviceServe !== s || s.nc !== closed) {
|
|
8973
|
+
try {
|
|
8974
|
+
await handle.stop();
|
|
8975
|
+
}
|
|
8976
|
+
finally {
|
|
8977
|
+
await nc.close();
|
|
8978
|
+
}
|
|
8979
|
+
return true;
|
|
8980
|
+
}
|
|
8810
8981
|
s.nc = nc;
|
|
8811
8982
|
s.handle = handle;
|
|
8812
8983
|
console.error(`manager service endpoint re-dialed: ${MANAGER_ENDPOINT}/${this.managerInstanceId} (epoch ${s.grant.epoch})`);
|