@cello-protocol/daemon 0.0.172 → 0.0.174
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/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +174 -31
- package/dist/close-session-handler.js.map +1 -1
- package/dist/daemon.js +5 -3
- package/dist/daemon.js.map +1 -1
- package/dist/restart-seal-resolver.d.ts.map +1 -1
- package/dist/restart-seal-resolver.js +4 -0
- package/dist/restart-seal-resolver.js.map +1 -1
- package/dist/seal-coordinator.d.ts.map +1 -1
- package/dist/seal-coordinator.js +11 -4
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-flows.d.ts +9 -0
- package/dist/seal-flows.d.ts.map +1 -1
- package/dist/seal-flows.js +4 -1
- package/dist/seal-flows.js.map +1 -1
- package/dist/session-node-manager.d.ts +43 -0
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +187 -9
- package/dist/session-node-manager.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
|
@@ -118,6 +118,13 @@ const REDIAL_COOLDOWN_MS = 15_000;
|
|
|
118
118
|
* Two hours: comfortably longer than any churn worth attacking with, comfortably shorter than
|
|
119
119
|
* "yesterday's conversation still blocks me".
|
|
120
120
|
*/
|
|
121
|
+
/**
|
|
122
|
+
* DOD-M12B-RESERVATION-RETRY-1 — how many times to re-ask for a refused reservation before saying
|
|
123
|
+
* the agent is undialable and stopping. Bounded because a reservation is scarce and a fleet that
|
|
124
|
+
* retries forever is how a relay is exhausted. With the 5-minute base and doubling, five retries
|
|
125
|
+
* span about 2.5 hours.
|
|
126
|
+
*/
|
|
127
|
+
export const SR_RESERVATION_MAX_RETRIES = 5;
|
|
121
128
|
export const CAP_INTERRUPTED_TTL_MS = Number(process.env["CELLO_CAP_INTERRUPTED_TTL_MS"]) || 2 * 60 * 60 * 1000;
|
|
122
129
|
/**
|
|
123
130
|
* DOD-CAP-SELF-HEAL-1 — what counts against a per-sender acceptance bound.
|
|
@@ -293,6 +300,15 @@ export class SessionNodeManager {
|
|
|
293
300
|
#srReservationTimeoutMs;
|
|
294
301
|
/** DOD-NAT-REACHABILITY-1: watchdog for a SILENTLY lost reservation. */
|
|
295
302
|
#srWatchdogIntervalMs;
|
|
303
|
+
#srReservationRetryMs;
|
|
304
|
+
/**
|
|
305
|
+
* DOD-M12B-RESERVATION-RETRY-1: per-agent re-attempt state for a receiver the relay refused a
|
|
306
|
+
* reservation to. `attempts` counts RETRIES, not the creation attempt.
|
|
307
|
+
*/
|
|
308
|
+
/** The reason the last reservation attempt was refused, per agent — captured at the rejection so
|
|
309
|
+
* the retry and give-up can name a CAUSE instead of only their own exit point. */
|
|
310
|
+
#srLastRejectionReason = new Map();
|
|
311
|
+
#srReservationRetry = new Map();
|
|
296
312
|
#reservationWatchdog = null;
|
|
297
313
|
/** DOD-PARK-DRAIN-1: how often the backstop drain rides the watchdog grid — see #parkedDrainBackstopTick. */
|
|
298
314
|
#parkedDrainBackstopMs;
|
|
@@ -540,6 +556,7 @@ export class SessionNodeManager {
|
|
|
540
556
|
this.#srRetryDelaysMs = opts.standingReceiverRetryDelaysMs ?? [1_000, 5_000, 15_000];
|
|
541
557
|
this.#srReservationTimeoutMs = opts.standingReceiverReservationTimeoutMs ?? 15_000;
|
|
542
558
|
this.#srWatchdogIntervalMs = opts.standingReceiverWatchdogIntervalMs ?? 30_000;
|
|
559
|
+
this.#srReservationRetryMs = opts.standingReceiverReservationRetryMs ?? 5 * 60_000;
|
|
543
560
|
this.#parkedDrainBackstopMs = opts.parkedDrainBackstopMs ?? 300_000;
|
|
544
561
|
// REQUIRED, no fallback (INV-9, audit finding). This line used to read
|
|
545
562
|
// `opts.securityGateway ?? new PassthroughGatewayClient()` — the identical shape as the defect
|
|
@@ -1944,6 +1961,31 @@ export class SessionNodeManager {
|
|
|
1944
1961
|
return this.#standingReceivers.has(agentName);
|
|
1945
1962
|
return this.#standingReceivers.size > 0;
|
|
1946
1963
|
}
|
|
1964
|
+
/**
|
|
1965
|
+
* DOD-M12B-RESERVATION-RETRY-1 — whether a NAT'd peer can actually DIAL this agent.
|
|
1966
|
+
*
|
|
1967
|
+
* `standing_receiver_ready` answers "is there a receiver?", which is true for a plain TCP node
|
|
1968
|
+
* that no relay would give a circuit reservation to. Behind NAT that node is reachable by nobody,
|
|
1969
|
+
* and the difference was visible only in the log — where it was visible 481 times and nobody
|
|
1970
|
+
* acted. `"retrying"` and `"unreachable"` are the states an operator can do something about.
|
|
1971
|
+
*
|
|
1972
|
+
* reserved — holds a circuit reservation; a NAT'd peer can dial it.
|
|
1973
|
+
* retrying — no reservation yet, still re-asking on a backoff.
|
|
1974
|
+
* unreachable — no circuit reservation and the automatic re-attempts are spent, so only peers
|
|
1975
|
+
* that can connect DIRECTLY will get in. It is not permanent: a directory
|
|
1976
|
+
* reconnect carrying a DIFFERENT relay pool re-arms the budget, because a relay we
|
|
1977
|
+
* have never tried is new information.
|
|
1978
|
+
* absent — no receiver at all (the agent is not online).
|
|
1979
|
+
*/
|
|
1980
|
+
getStandingReceiverReachability(agentName) {
|
|
1981
|
+
const sr = this.#standingReceivers.get(agentName);
|
|
1982
|
+
if (!sr)
|
|
1983
|
+
return "absent";
|
|
1984
|
+
if (sr.hasReservation && sr.relayPeerId !== undefined)
|
|
1985
|
+
return "reserved";
|
|
1986
|
+
const retry = this.#srReservationRetry.get(agentName);
|
|
1987
|
+
return retry !== undefined && retry.attempts > SR_RESERVATION_MAX_RETRIES ? "unreachable" : "retrying";
|
|
1988
|
+
}
|
|
1947
1989
|
/** First ready standing receiver (any agent) — for agent-agnostic OUTBOUND use (gater-open). */
|
|
1948
1990
|
#anyStandingReceiver() {
|
|
1949
1991
|
for (const sr of this.#standingReceivers.values())
|
|
@@ -3127,6 +3169,8 @@ export class SessionNodeManager {
|
|
|
3127
3169
|
}
|
|
3128
3170
|
})), "standing_receivers", this.#standingReceivers.size);
|
|
3129
3171
|
this.#standingReceivers.clear();
|
|
3172
|
+
this.#srReservationRetry.clear();
|
|
3173
|
+
this.#srLastRejectionReason.clear();
|
|
3130
3174
|
// Release the SQLite handle so the DB file is no longer held open after shutdown
|
|
3131
3175
|
// (review L5). Queries guard on `#db === null` and degrade to empty/null.
|
|
3132
3176
|
if (this.#db) {
|
|
@@ -5096,6 +5140,14 @@ export class SessionNodeManager {
|
|
|
5096
5140
|
/** DOD-M12B-INDEX-1 — this agent's own K_local pubkey, for attributing its own held content.
|
|
5097
5141
|
* Null when it cannot be resolved: an UNATTRIBUTED annex row is true, a falsely attributed one
|
|
5098
5142
|
* is not, and this is the record that outlives the session. */
|
|
5143
|
+
/**
|
|
5144
|
+
* Test seam for `#recoverOwnSealCtrlLeaf` (documented on the method itself, below). The
|
|
5145
|
+
* distinction it draws — "there is none" versus "I could not tell" — is the whole safety
|
|
5146
|
+
* property, and it had no coverage at any level.
|
|
5147
|
+
*/
|
|
5148
|
+
recoverOwnSealCtrlLeafForTest(agentName, sessionId) {
|
|
5149
|
+
return this.#recoverOwnSealCtrlLeaf(agentName, sessionId);
|
|
5150
|
+
}
|
|
5099
5151
|
/**
|
|
5100
5152
|
* DOD-M12B-INTERRUPTED-ESCALATE-1 — our own SEAL ctrl leaf, if a previous run already posted one.
|
|
5101
5153
|
*
|
|
@@ -6786,7 +6838,21 @@ export class SessionNodeManager {
|
|
|
6786
6838
|
* for a session handoff that (being unreachable) would never come.
|
|
6787
6839
|
*/
|
|
6788
6840
|
setDirectoryRelayEndpoints(agentName, endpoints) {
|
|
6841
|
+
// DOD-M12B-RESERVATION-RETRY-1: a DIFFERENT relay pool re-arms the retry budget; the same one
|
|
6842
|
+
// does not. This fires on every signaling connect AND reconnect, so clearing unconditionally
|
|
6843
|
+
// would reset the bound on a short grid and defeat the whole point of having one. But once the
|
|
6844
|
+
// budget is spent, `getStandingReceiverReachability` reports `unreachable` for the rest of the
|
|
6845
|
+
// online episode — including while this path is actively re-attempting against relays we have
|
|
6846
|
+
// never tried. A new relay is new information; a repeat of the same list is not.
|
|
6847
|
+
const previous = this.#directoryRelayEndpoints.get(agentName);
|
|
6848
|
+
const poolChanged = previous === undefined ||
|
|
6849
|
+
previous.length !== endpoints.length ||
|
|
6850
|
+
endpoints.some((e, i) => e.relayPeerId !== previous[i]?.relayPeerId);
|
|
6789
6851
|
this.#directoryRelayEndpoints.set(agentName, endpoints);
|
|
6852
|
+
if (poolChanged) {
|
|
6853
|
+
this.#srReservationRetry.delete(agentName);
|
|
6854
|
+
this.#srLastRejectionReason.delete(agentName);
|
|
6855
|
+
}
|
|
6790
6856
|
if (endpoints.length === 0 || this.#shuttingDown)
|
|
6791
6857
|
return;
|
|
6792
6858
|
const sr = this.#standingReceivers.get(agentName);
|
|
@@ -6911,14 +6977,97 @@ export class SessionNodeManager {
|
|
|
6911
6977
|
* already degraded and already loud (reservation.none / reservation.timeout);
|
|
6912
6978
|
* rebuilding it on a timer would just thrash against relays we know are refusing.
|
|
6913
6979
|
*/
|
|
6980
|
+
/**
|
|
6981
|
+
* DOD-M12B-RESERVATION-RETRY-1 — ask again for a reservation the relay refused.
|
|
6982
|
+
*
|
|
6983
|
+
* The rebuild is the re-attempt: a circuit listener is fixed at node creation, so the only way to
|
|
6984
|
+
* obtain a reservation is to build a new node asking for one.
|
|
6985
|
+
*/
|
|
6986
|
+
#retryReservationIfDue(agentName) {
|
|
6987
|
+
const now = Date.now();
|
|
6988
|
+
const state = this.#srReservationRetry.get(agentName)
|
|
6989
|
+
?? { attempts: 0, nextAt: now + this.#srReservationRetryMs, correlationId: randomUUID() };
|
|
6990
|
+
// The reason the LAST attempt was refused, captured where it is actually known.
|
|
6991
|
+
const lastReason = this.#srLastRejectionReason.get(agentName);
|
|
6992
|
+
if (lastReason !== undefined)
|
|
6993
|
+
state.lastReason = lastReason;
|
|
6994
|
+
if (state.attempts === 0 && !this.#srReservationRetry.has(agentName)) {
|
|
6995
|
+
// First sighting — schedule, do not fire. The creation attempt just happened.
|
|
6996
|
+
this.#srReservationRetry.set(agentName, state);
|
|
6997
|
+
return;
|
|
6998
|
+
}
|
|
6999
|
+
if (now < state.nextAt)
|
|
7000
|
+
return;
|
|
7001
|
+
if (state.attempts >= SR_RESERVATION_MAX_RETRIES) {
|
|
7002
|
+
if (state.attempts === SR_RESERVATION_MAX_RETRIES) {
|
|
7003
|
+
state.attempts += 1; // mark as reported, so this fires exactly once
|
|
7004
|
+
this.#srReservationRetry.set(agentName, state);
|
|
7005
|
+
this.#logger.error("session.standing_receiver.reservation.gave_up", {
|
|
7006
|
+
agentName,
|
|
7007
|
+
attempts: SR_RESERVATION_MAX_RETRIES,
|
|
7008
|
+
correlationId: state.correlationId,
|
|
7009
|
+
// WHY, not just the consequence. Three different problems reach this one message and they
|
|
7010
|
+
// need three different responses: `relay_granted_no_reservation` is relay CAPACITY (and a
|
|
7011
|
+
// trustless-cello problem), `relay_unreachable` is the NETWORK, and
|
|
7012
|
+
// `reservation_did_not_complete_in_time` is LATENCY — and the only one of the three that
|
|
7013
|
+
// can pin a slot it never uses, so its appearance is also the signal that this retry
|
|
7014
|
+
// budget needs tightening.
|
|
7015
|
+
...(state.lastReason !== undefined ? { lastRejectionReason: state.lastReason } : {}),
|
|
7016
|
+
// "No relay would grant" and "there was no relay to ask" are different facts and lead to
|
|
7017
|
+
// different places — the first at relay capacity, the second at this agent's directory
|
|
7018
|
+
// connection. Without this they are the same sentence.
|
|
7019
|
+
reservationsRequested: (this.#directoryRelayEndpoints.get(agentName)?.length ?? 0) > 0,
|
|
7020
|
+
impact: "no relay would grant this agent a circuit reservation, so anyone behind NAT cannot reach or dial it — inbound sessions will only arrive from peers that can connect directly, and everything else falls back to the relay's store-and-forward",
|
|
7021
|
+
});
|
|
7022
|
+
}
|
|
7023
|
+
return;
|
|
7024
|
+
}
|
|
7025
|
+
state.attempts += 1;
|
|
7026
|
+
// The FINAL attempt gets a fixed settle window rather than another doubled wait: at the top of
|
|
7027
|
+
// the ladder that would be 80 minutes of silence after the last thing we did, which is a long
|
|
7028
|
+
// time to tell an operator nothing. Every earlier attempt doubles, which is what keeps a fleet
|
|
7029
|
+
// off a scarce relay.
|
|
7030
|
+
state.nextAt = now + (state.attempts >= SR_RESERVATION_MAX_RETRIES
|
|
7031
|
+
? this.#srReservationRetryMs
|
|
7032
|
+
: this.#srReservationRetryMs * 2 ** (state.attempts - 1));
|
|
7033
|
+
this.#srReservationRetry.set(agentName, state);
|
|
7034
|
+
this.#logger.warn("session.standing_receiver.reservation.retry", {
|
|
7035
|
+
agentName,
|
|
7036
|
+
attempt: state.attempts,
|
|
7037
|
+
maxAttempts: SR_RESERVATION_MAX_RETRIES,
|
|
7038
|
+
correlationId: state.correlationId,
|
|
7039
|
+
...(state.lastReason !== undefined ? { lastRejectionReason: state.lastReason } : {}),
|
|
7040
|
+
impact: "this agent currently holds no circuit reservation, so a NAT'd peer cannot dial it",
|
|
7041
|
+
});
|
|
7042
|
+
void this.#rebuildStandingReceiver(agentName);
|
|
7043
|
+
}
|
|
6914
7044
|
#reservationWatchdogTick() {
|
|
6915
7045
|
if (this.#shuttingDown)
|
|
6916
7046
|
return;
|
|
6917
7047
|
for (const [agentName, sr] of this.#standingReceivers) {
|
|
6918
|
-
if (!sr.hasReservation || sr.relayPeerId === undefined)
|
|
6919
|
-
continue; // never had one — not a LOSS
|
|
6920
7048
|
if (!this.#agentsWantingReceiver.has(agentName))
|
|
6921
7049
|
continue; // agent went offline
|
|
7050
|
+
// DOD-M12B-RESERVATION-RETRY-1 — NEVER HAD ONE IS NOT "NOTHING TO DO".
|
|
7051
|
+
//
|
|
7052
|
+
// This used to `continue` unconditionally, on the grounds that a receiver with no reservation
|
|
7053
|
+
// is "already degraded and already loud". Measured over 17 days: `reservation.none` fired 481
|
|
7054
|
+
// times and `relay.rejected` 2,215 — every one of the latter `relay_granted_no_reservation`,
|
|
7055
|
+
// a relay out of slots completing the handshake and granting nothing. Nothing ever acted on
|
|
7056
|
+
// the noise, and each of those receivers is a plain TCP node with no circuit address: behind
|
|
7057
|
+
// NAT, dialable by NOBODY, for its whole life. That is the silent loss of inbound this file
|
|
7058
|
+
// says three lines below it exists to kill.
|
|
7059
|
+
//
|
|
7060
|
+
// A relay out of slots at boot may have one minutes later, so re-attempt — on a BACKOFF and
|
|
7061
|
+
// BOUNDED, never on this 30-second grid. A reservation is scarce: the relay holds it for its
|
|
7062
|
+
// full TTL even after the client disconnects, and churning attempts across a fleet is how a
|
|
7063
|
+
// relay is exhausted (`#startReceiverNode` records that hazard).
|
|
7064
|
+
if (!sr.hasReservation || sr.relayPeerId === undefined) {
|
|
7065
|
+
this.#retryReservationIfDue(agentName);
|
|
7066
|
+
continue;
|
|
7067
|
+
}
|
|
7068
|
+
// It has one — any earlier retry budget, and the reason the last attempt failed, are stale.
|
|
7069
|
+
this.#srReservationRetry.delete(agentName);
|
|
7070
|
+
this.#srLastRejectionReason.delete(agentName);
|
|
6922
7071
|
// Watch the CONNECTION to the relay, not the circuit address.
|
|
6923
7072
|
//
|
|
6924
7073
|
// Killing the relay does NOT make the /p2p-circuit address disappear: libp2p
|
|
@@ -7049,14 +7198,16 @@ export class SessionNodeManager {
|
|
|
7049
7198
|
if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
|
|
7050
7199
|
return candidate;
|
|
7051
7200
|
}
|
|
7201
|
+
const rejectionReason = outcome === "started"
|
|
7202
|
+
? "relay_granted_no_reservation"
|
|
7203
|
+
: outcome === "failed"
|
|
7204
|
+
? "relay_unreachable"
|
|
7205
|
+
: "reservation_did_not_complete_in_time";
|
|
7206
|
+
this.#srLastRejectionReason.set(agentName, rejectionReason);
|
|
7052
7207
|
this.#logger.warn("session.standing_receiver.relay.rejected", {
|
|
7053
7208
|
agentName,
|
|
7054
7209
|
circuitAddr,
|
|
7055
|
-
reason:
|
|
7056
|
-
? "relay_granted_no_reservation"
|
|
7057
|
-
: outcome === "failed"
|
|
7058
|
-
? "relay_unreachable"
|
|
7059
|
-
: "reservation_did_not_complete_in_time",
|
|
7210
|
+
reason: rejectionReason,
|
|
7060
7211
|
...(error !== "" ? { error } : {}),
|
|
7061
7212
|
correlationId,
|
|
7062
7213
|
});
|
|
@@ -7138,8 +7289,27 @@ export class SessionNodeManager {
|
|
|
7138
7289
|
});
|
|
7139
7290
|
autoNat.emitInitialResult();
|
|
7140
7291
|
const circuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length;
|
|
7141
|
-
//
|
|
7142
|
-
|
|
7292
|
+
// FROM THE ADDRESS THE NODE ACTUALLY HOLDS, not from `reservations.addrs[0]`.
|
|
7293
|
+
//
|
|
7294
|
+
// `#startReceiverNode` tries candidates in order and returns the FIRST that actually grants —
|
|
7295
|
+
// so when candidate 0 refuses (the measured `relay_granted_no_reservation` case) and candidate 1
|
|
7296
|
+
// grants, reading candidate 0's address records a relay we are not connected to. The watchdog
|
|
7297
|
+
// then evaluates `getConnections().some(c => c.peerId === relayPeerId)` against that wrong peer,
|
|
7298
|
+
// finds it false on every tick forever, and rebuilds on the 30-second grid — churning the very
|
|
7299
|
+
// reservations this unit exists to conserve. Dormant while the pool is size 1; the pool is
|
|
7300
|
+
// designed to be larger.
|
|
7301
|
+
// PREFER the held address, FALL BACK to the candidate — strictly better than either alone.
|
|
7302
|
+
// The held address is authoritative about which relay actually granted, but it is libp2p's
|
|
7303
|
+
// string, not ours: if a transport ever reports the circuit address without the relay's peer id
|
|
7304
|
+
// in `/p2p/<id>/p2p-circuit` form, reading only it would yield UNDEFINED, and an undefined
|
|
7305
|
+
// relayPeerId makes the watchdog treat a perfectly healthy reservation as absent and rebuild it.
|
|
7306
|
+
// That would be a regression on the single-relay case that works today. The candidate string is
|
|
7307
|
+
// ours and always carries the id, so it is the safe floor.
|
|
7308
|
+
const heldCircuitAddr = node.listenAddresses().find((a) => a.includes("/p2p-circuit"));
|
|
7309
|
+
const CIRCUIT_RELAY_ID = /\/p2p\/([^/]+)\/p2p-circuit/;
|
|
7310
|
+
const reservedRelayPeerId = circuitAddrs > 0
|
|
7311
|
+
? (heldCircuitAddr?.match(CIRCUIT_RELAY_ID)?.[1] ?? reservations.addrs[0]?.match(CIRCUIT_RELAY_ID)?.[1])
|
|
7312
|
+
: undefined;
|
|
7143
7313
|
this.#standingReceivers.set(agentName, {
|
|
7144
7314
|
node,
|
|
7145
7315
|
gater,
|
|
@@ -7197,6 +7367,14 @@ export class SessionNodeManager {
|
|
|
7197
7367
|
// gets a fresh set on its next connect — holding the old ones would keep a
|
|
7198
7368
|
// retired agent's relay list alive for the daemon's lifetime.
|
|
7199
7369
|
this.#directoryRelayEndpoints.delete(agentName);
|
|
7370
|
+
// DOD-M12B-RESERVATION-RETRY-1: and the retry budget with them. A spent budget that survives an
|
|
7371
|
+
// offline→online cycle is a LATCH: the new receiver gets no reservation, the watchdog finds
|
|
7372
|
+
// `attempts` already past the cap, and returns having done nothing — no retry and not even a
|
|
7373
|
+
// second give-up. The agent is undialable and the machinery is inert and mute until a daemon
|
|
7374
|
+
// restart. Same reason the line above exists ("holding the old ones would keep a retired agent's
|
|
7375
|
+
// relay list alive for the daemon's lifetime").
|
|
7376
|
+
this.#srReservationRetry.delete(agentName);
|
|
7377
|
+
this.#srLastRejectionReason.delete(agentName);
|
|
7200
7378
|
const sr = this.#standingReceivers.get(agentName);
|
|
7201
7379
|
if (!sr) {
|
|
7202
7380
|
// L1: an #ensureStandingReceiver for this agent may be in flight (parked on start(), so no
|