@cello-protocol/daemon 0.0.194 → 0.0.195
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/assignment-verify.d.ts +39 -11
- package/dist/assignment-verify.d.ts.map +1 -1
- package/dist/assignment-verify.js +133 -17
- package/dist/assignment-verify.js.map +1 -1
- package/dist/authorship-verification.d.ts +70 -0
- package/dist/authorship-verification.d.ts.map +1 -0
- package/dist/authorship-verification.js +441 -0
- package/dist/authorship-verification.js.map +1 -0
- package/dist/db-identity-store.d.ts +1 -0
- package/dist/db-identity-store.d.ts.map +1 -1
- package/dist/db-identity-store.js +19 -1
- package/dist/db-identity-store.js.map +1 -1
- package/dist/held-content.d.ts +141 -0
- package/dist/held-content.d.ts.map +1 -0
- package/dist/held-content.js +388 -0
- package/dist/held-content.js.map +1 -0
- package/dist/inbound-refusals.d.ts +283 -0
- package/dist/inbound-refusals.d.ts.map +1 -0
- package/dist/inbound-refusals.js +919 -0
- package/dist/inbound-refusals.js.map +1 -0
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +65 -27
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +63 -0
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +108 -8
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-recovery.d.ts +205 -0
- package/dist/park-recovery.d.ts.map +1 -0
- package/dist/park-recovery.js +600 -0
- package/dist/park-recovery.js.map +1 -0
- package/dist/refusal-notices.d.ts +196 -0
- package/dist/refusal-notices.d.ts.map +1 -0
- package/dist/refusal-notices.js +496 -0
- package/dist/refusal-notices.js.map +1 -0
- package/dist/refusal-reasons.d.ts +11 -0
- package/dist/refusal-reasons.d.ts.map +1 -1
- package/dist/refusal-reasons.js +18 -0
- package/dist/refusal-reasons.js.map +1 -1
- package/dist/registration-manager.d.ts.map +1 -1
- package/dist/registration-manager.js +126 -5
- package/dist/registration-manager.js.map +1 -1
- package/dist/registration-persistence.d.ts +18 -0
- package/dist/registration-persistence.d.ts.map +1 -1
- package/dist/registration-persistence.js +6 -0
- package/dist/registration-persistence.js.map +1 -1
- package/dist/session-assignment-parser.d.ts.map +1 -1
- package/dist/session-assignment-parser.js +23 -1
- package/dist/session-assignment-parser.js.map +1 -1
- package/dist/session-ceremony.d.ts +12 -7
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +12 -7
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-ephemerals.d.ts +271 -0
- package/dist/session-ephemerals.d.ts.map +1 -0
- package/dist/session-ephemerals.js +546 -0
- package/dist/session-ephemerals.js.map +1 -0
- package/dist/session-leaf-records.d.ts +132 -0
- package/dist/session-leaf-records.d.ts.map +1 -0
- package/dist/session-leaf-records.js +320 -0
- package/dist/session-leaf-records.js.map +1 -0
- package/dist/session-liveness.d.ts +135 -0
- package/dist/session-liveness.d.ts.map +1 -0
- package/dist/session-liveness.js +347 -0
- package/dist/session-liveness.js.map +1 -0
- package/dist/session-node-manager.d.ts +300 -1670
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +871 -9951
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-node-types.d.ts +933 -0
- package/dist/session-node-types.d.ts.map +1 -0
- package/dist/session-node-types.js +576 -0
- package/dist/session-node-types.js.map +1 -0
- package/dist/session-queries.d.ts +476 -0
- package/dist/session-queries.d.ts.map +1 -0
- package/dist/session-queries.js +1006 -0
- package/dist/session-queries.js.map +1 -0
- package/dist/session-records.d.ts +320 -0
- package/dist/session-records.d.ts.map +1 -0
- package/dist/session-records.js +792 -0
- package/dist/session-records.js.map +1 -0
- package/dist/session-salts.d.ts +390 -0
- package/dist/session-salts.d.ts.map +1 -0
- package/dist/session-salts.js +1457 -0
- package/dist/session-salts.js.map +1 -0
- package/dist/session-schema.d.ts +30 -0
- package/dist/session-schema.d.ts.map +1 -0
- package/dist/session-schema.js +786 -0
- package/dist/session-schema.js.map +1 -0
- package/dist/standing-receivers.d.ts +280 -0
- package/dist/standing-receivers.d.ts.map +1 -0
- package/dist/standing-receivers.js +1100 -0
- package/dist/standing-receivers.js.map +1 -0
- package/dist/transport-selector.d.ts +13 -0
- package/dist/transport-selector.d.ts.map +1 -1
- package/dist/transport-selector.js.map +1 -1
- package/dist/witness-alerts.d.ts +40 -0
- package/dist/witness-alerts.d.ts.map +1 -0
- package/dist/witness-alerts.js +102 -0
- package/dist/witness-alerts.js.map +1 -0
- package/package.json +5 -5
|
@@ -0,0 +1,1100 @@
|
|
|
1
|
+
import { NodeAutoNatService } from "@cello-protocol/transport";
|
|
2
|
+
import { SessionConnectionGater } from "./session-connection-gater.js";
|
|
3
|
+
import { relayOnlyState, publishableEndpoint } from "./relay-only.js";
|
|
4
|
+
import { extractErrorMessage } from "./error-message.js";
|
|
5
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
|
+
import { relayPeerIdOf, heldRelayIdsOf, CIRCUIT_RELAY_ID, SR_RESERVATION_MAX_RETRIES, REVIVE_RESERVATION_CANDIDATES, REVIVE_RESERVATION_TIMEOUT_MS, } from "./session-node-types.js";
|
|
7
|
+
import { STANDING_RECEIVER_AGENT_NAME } from "./types.js";
|
|
8
|
+
export class StandingReceivers {
|
|
9
|
+
#ctx;
|
|
10
|
+
/**
|
|
11
|
+
* The dialer an inbound session offer named, per agent.
|
|
12
|
+
*
|
|
13
|
+
* ⚠️ OWNED HERE, not shared by reference like the other maps — because unlike them it has NO
|
|
14
|
+
* reader left in the manager. It was passed across with the rest on the first pass; a review
|
|
15
|
+
* measured that every read had moved with the four `*OfferedDialer` methods, so the by-reference
|
|
16
|
+
* argument that is correct for the other nine does not apply to it.
|
|
17
|
+
*/
|
|
18
|
+
#offeredDialer = new Map();
|
|
19
|
+
constructor(ctx) {
|
|
20
|
+
this.#ctx = ctx;
|
|
21
|
+
}
|
|
22
|
+
/** A getter so the moved reads still say `this.#db` and narrow exactly as they did. */
|
|
23
|
+
get #db() {
|
|
24
|
+
return this.#ctx.db();
|
|
25
|
+
}
|
|
26
|
+
async #startReceiverNode(agentName, sessionId, gater, candidateCircuitAddrs, correlationId) {
|
|
27
|
+
/**
|
|
28
|
+
* 032-RELAYSPREAD — **ONE SEED FOR THE RECEIVER, REUSED ACROSS RELAYS**, replacing
|
|
29
|
+
* DOD-M12B-SESSION-SEED-1's seed-per-candidate.
|
|
30
|
+
*
|
|
31
|
+
* The agent is ONE identity and must be dialable at ONE peer id through any of its circuits, so
|
|
32
|
+
* every reservation this walk collects has to belong to the same key. A seed per relay would
|
|
33
|
+
* give the agent a different peer id down each circuit — N half-agents, none of them the one
|
|
34
|
+
* the counterparty was told to dial.
|
|
35
|
+
*
|
|
36
|
+
* ⚠️ THE RULE THIS REPLACES WAS RIGHT ABOUT ITS OWN CASE, so here is what changed and what did
|
|
37
|
+
* not. Its hazard is real and survives: a rejected candidate is torn down while its `start()`
|
|
38
|
+
* may still be in flight, so two nodes can briefly be live on this peer id. Two things bound it
|
|
39
|
+
* now, and neither existed when that rule was written:
|
|
40
|
+
* - **THE ONE THAT CARRIES THE WEIGHT: DOD-M15-ASSIGN-1** made a standing receiver's gater
|
|
41
|
+
* admit NOBODY inbound until a session offer names the dialer. The old rule's stated danger
|
|
42
|
+
* — "sharing this gater, so it admits dials … an open endpoint under our advertised id" —
|
|
43
|
+
* is not true of this gater any more. `#startReceiverNode` has exactly one caller and it
|
|
44
|
+
* constructs that gater with `allowedPeerId: null` and an empty reserved set, so an
|
|
45
|
+
* overlapping candidate is an endpoint that refuses everyone.
|
|
46
|
+
* - the teardown is chained onto the candidate's OWN start promise (the `#buildRevivedNode`
|
|
47
|
+
* pattern, verified against libp2p 3.3.2: `stop()` returns immediately unless the status is
|
|
48
|
+
* `started`, and through the whole timeout window it is `starting`, so the old unawaited
|
|
49
|
+
* `stop()` stopped nothing). ⚠️ This bounds the LEAK, not the OVERLAP — a timed-out
|
|
50
|
+
* candidate is not awaited and the walk moves straight to the next one on the same seed, so
|
|
51
|
+
* overlap is the normal shape of that case, not a remote possibility. It guarantees the
|
|
52
|
+
* loser dies, and nothing more.
|
|
53
|
+
* `#buildRevivedNode` already runs a fixed identity through this same walk for the same reason.
|
|
54
|
+
*/
|
|
55
|
+
const receiverSeed = randomBytes(32);
|
|
56
|
+
/** Circuit addresses whose relay ACTUALLY GRANTED this identity a reservation. */
|
|
57
|
+
const grantedAddrs = [];
|
|
58
|
+
// For `spread.grant_not_bound` below: the walk's own duration is measured against the relay's
|
|
59
|
+
// two-minute proof memory, so it has to be a number rather than an inference.
|
|
60
|
+
const walkStartedAt = Date.now();
|
|
61
|
+
for (const circuitAddr of candidateCircuitAddrs) {
|
|
62
|
+
const candidateSeed = receiverSeed;
|
|
63
|
+
/**
|
|
64
|
+
* DOD-M15-RELAYSLOTS-1 — **TWO ATTEMPTS PER RELAY: ask, prove, ask again.**
|
|
65
|
+
*
|
|
66
|
+
* The relay now refuses a reservation from a peer that has not shown it belongs to a
|
|
67
|
+
* registered agent. A brand-new receiver has shown nothing, so its FIRST ask is refused —
|
|
68
|
+
* expected, not a failure. It then authenticates over `/cello/relay/1.0.0`, which tells the
|
|
69
|
+
* relay this transport identity is a registered agent's, and asks again on a fresh connection
|
|
70
|
+
* carrying the SAME identity (that is what reusing `candidateSeed` buys).
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ It has to be two connections, and that was measured rather than chosen. Taking the
|
|
73
|
+
* reservation by hand on the same connection as the proof DOES get a slot — and libp2p then
|
|
74
|
+
* announces no circuit address for it, because it only announces addresses for reservations
|
|
75
|
+
* its own relay-discovery made. The agent would hold a slot nobody could dial through.
|
|
76
|
+
*/
|
|
77
|
+
let candidateGranted = false;
|
|
78
|
+
// Set when the relay refused the AGENT rather than being unwilling itself: every other relay
|
|
79
|
+
// in the pool answers identically, so the walk ends here rather than reproducing it N times.
|
|
80
|
+
let candidateRefusedAgent = false;
|
|
81
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
82
|
+
const candidate = await this.createAgentNode(agentName, {
|
|
83
|
+
sessionId,
|
|
84
|
+
connectionGater: gater,
|
|
85
|
+
nodeType: "standing_receiver",
|
|
86
|
+
circuitRelayListenAddrs: [circuitAddr],
|
|
87
|
+
transportPrivateKey: candidateSeed,
|
|
88
|
+
});
|
|
89
|
+
let timer;
|
|
90
|
+
const timedOut = Symbol("reservation_timeout");
|
|
91
|
+
let outcome = "failed";
|
|
92
|
+
let error = "";
|
|
93
|
+
// KEEP THE START PROMISE. Every candidate now carries the receiver's identity, so an
|
|
94
|
+
// abandoned one must be reliably torn down rather than best-effort — and only its own start
|
|
95
|
+
// promise says when it is stoppable (see the seed note above).
|
|
96
|
+
const startP = candidate.start();
|
|
97
|
+
try {
|
|
98
|
+
outcome = await Promise.race([
|
|
99
|
+
startP.then(() => "started"),
|
|
100
|
+
new Promise((resolve) => {
|
|
101
|
+
timer = setTimeout(() => resolve(timedOut), this.#ctx.srReservationTimeoutMs);
|
|
102
|
+
}),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
error = extractErrorMessage(err);
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
if (timer !== undefined)
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
}
|
|
112
|
+
// The only proof that counts: the relay actually GRANTED the reservation.
|
|
113
|
+
// start() resolving is not enough — a relay that is out of reservation slots
|
|
114
|
+
// completes the handshake and simply grants nothing, leaving a node that looks
|
|
115
|
+
// started and is reachable by nobody.
|
|
116
|
+
if (outcome === "started" && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
|
|
117
|
+
candidateGranted = true;
|
|
118
|
+
// The probe has done its job: this relay grants THIS identity. Tear it down and ask the
|
|
119
|
+
// next relay — the reservation is re-taken by the final node below, which is the only one
|
|
120
|
+
// that can listen on every granted address at once. AWAITED, because the next probe comes
|
|
121
|
+
// up on this same peer id.
|
|
122
|
+
try {
|
|
123
|
+
await candidate.stop();
|
|
124
|
+
}
|
|
125
|
+
catch { /* it may never have finished starting */ }
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* No reservation. On the FIRST attempt that is the expected answer for a receiver that has
|
|
130
|
+
* not proved itself yet, so prove and go round once more. `proveReservation` opens its own
|
|
131
|
+
* stream from this node, which is what binds this transport identity to the agent at the
|
|
132
|
+
* relay; the relay remembers it across the reconnect below.
|
|
133
|
+
*/
|
|
134
|
+
if (attempt === 0 && outcome === "started") {
|
|
135
|
+
const verdict = await this.#ctx.proveToRelay(agentName, circuitAddr, candidate, correlationId, true);
|
|
136
|
+
// AWAITED, not fire-and-forget: the retry rebuilds on this same transport identity, and two
|
|
137
|
+
// live nodes sharing one peer id is the defect DOD-M12B-SESSION-SEED-1 exists to prevent.
|
|
138
|
+
try {
|
|
139
|
+
await candidate.stop();
|
|
140
|
+
}
|
|
141
|
+
catch { /* it may never have finished starting */ }
|
|
142
|
+
/**
|
|
143
|
+
* DOD-M15-RELAYSLOTS-1 clause 9 — **A CLIENT-SIDE REFUSAL ENDS THE WALK.**
|
|
144
|
+
*
|
|
145
|
+
* `slot_cap_exceeded` and an expired or missing token are classified `tryAnotherRelay:
|
|
146
|
+
* false` because they reproduce on every relay in the pool: the cap is per AGENT, and the
|
|
147
|
+
* token comes from the directory, not from here. Walking on costs a node build and two
|
|
148
|
+
* dials per remaining relay to arrive at the same answer, and it makes one client-side
|
|
149
|
+
* fault look like a fleet-wide outage in the logs. The refusal is already recorded where
|
|
150
|
+
* `cello_status` reads it, so stopping is not silence.
|
|
151
|
+
*/
|
|
152
|
+
if (verdict === "refused_this_agent") {
|
|
153
|
+
this.#ctx.srLastRejectionReason.set(agentName, "relay_refused_this_agent");
|
|
154
|
+
this.#ctx.logger.warn("session.standing_receiver.relay.rejected", {
|
|
155
|
+
agentName,
|
|
156
|
+
circuitAddr,
|
|
157
|
+
reason: "relay_refused_this_agent",
|
|
158
|
+
attempts: attempt + 1,
|
|
159
|
+
correlationId,
|
|
160
|
+
impact: "the relay refused this AGENT rather than this relay being unwilling or " +
|
|
161
|
+
"unwell, so every other relay would refuse it identically. Stopped here; " +
|
|
162
|
+
"cello_status carries the cause and what to do about it.",
|
|
163
|
+
});
|
|
164
|
+
candidateRefusedAgent = true;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* ⚠️ RETRY ONLY WHAT A PROOF CAN FIX. The second attempt exists because the relay now
|
|
169
|
+
* remembers this transport identity; if the proof did not land, it remembers nothing and
|
|
170
|
+
* the retry is a node build and a dial spent to be refused identically. Only `proven`
|
|
171
|
+
* earns the retry — everything else moves to the next relay.
|
|
172
|
+
*/
|
|
173
|
+
if (verdict !== "proven") {
|
|
174
|
+
this.#ctx.srLastRejectionReason.set(agentName, "relay_proof_refused");
|
|
175
|
+
this.#ctx.logger.warn("session.standing_receiver.relay.rejected", {
|
|
176
|
+
agentName,
|
|
177
|
+
circuitAddr,
|
|
178
|
+
reason: "relay_proof_refused",
|
|
179
|
+
attempts: attempt + 1,
|
|
180
|
+
correlationId,
|
|
181
|
+
impact: "this relay would not take the agent's proof, so it will refuse the retry the " +
|
|
182
|
+
"same way. Moving to the next relay rather than asking this one twice.",
|
|
183
|
+
});
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const rejectionReason = outcome === "started"
|
|
189
|
+
? /**
|
|
190
|
+
* ⚠️ Review MEDIUM-7 — **"STARTED" DOES NOT MEAN THE RELAY ANSWERED.** A circuit listen
|
|
191
|
+
* entry sets `FaultTolerance.NO_FATAL`, and `start()` only throws when the DIRECT
|
|
192
|
+
* listener fails, so a relay that is simply DOWN resolves `started` with no circuit
|
|
193
|
+
* address — indistinguishable, here, from a relay that answered and granted nothing.
|
|
194
|
+
* Reporting that as `relay_granted_no_reservation` sends the operator to look at relay
|
|
195
|
+
* capacity for what is a network fault. An open connection to the relay peer is the
|
|
196
|
+
* thing that separates them, and we have one to ask.
|
|
197
|
+
*/
|
|
198
|
+
(candidate.getConnections().some((c) => c.peerId === relayPeerIdOf(circuitAddr))
|
|
199
|
+
? "relay_granted_no_reservation"
|
|
200
|
+
: "relay_unreachable")
|
|
201
|
+
: outcome === "failed"
|
|
202
|
+
? "relay_unreachable"
|
|
203
|
+
: "reservation_did_not_complete_in_time";
|
|
204
|
+
this.#ctx.srLastRejectionReason.set(agentName, rejectionReason);
|
|
205
|
+
this.#ctx.logger.warn("session.standing_receiver.relay.rejected", {
|
|
206
|
+
agentName,
|
|
207
|
+
circuitAddr,
|
|
208
|
+
reason: rejectionReason,
|
|
209
|
+
attempts: attempt + 1,
|
|
210
|
+
...(error !== "" ? { error } : {}),
|
|
211
|
+
correlationId,
|
|
212
|
+
});
|
|
213
|
+
// Abandon it — but on its OWN settlement, never best-effort. `start()` may still be parked on
|
|
214
|
+
// a dial, and this candidate carries the receiver's identity: an unawaited `stop()` on a node
|
|
215
|
+
// whose status is still `starting` returns without stopping anything, and the node then goes
|
|
216
|
+
// live on our peer id with nothing left holding a reference to kill it.
|
|
217
|
+
void startP.then(() => candidate.stop().catch(() => { }), () => { });
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
if (candidateGranted)
|
|
221
|
+
grantedAddrs.push(circuitAddr);
|
|
222
|
+
// 032-RELAYSPREAD: DO NOT BREAK ON THE FIRST GRANT. The walk used to stop here, which is why
|
|
223
|
+
// an agent held exactly one reservation and losing that relay cost it every NAT'd caller for
|
|
224
|
+
// however long detection happened to take. It now asks every remaining relay.
|
|
225
|
+
if (candidateRefusedAgent)
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* THE RECEIVER, listening on EVERY granted circuit address.
|
|
230
|
+
*
|
|
231
|
+
* One node per agent, as before — what changed is how many circuits it announces. Each address
|
|
232
|
+
* here belongs to a relay that granted THIS seed moments ago and therefore still remembers the
|
|
233
|
+
* identity, so the final node's first ask is the one that succeeds; the two-attempt dance was
|
|
234
|
+
* already paid per relay in the walk.
|
|
235
|
+
*
|
|
236
|
+
* ⚠️ RACED AGAINST A DEADLINE, and that is measured rather than cautious: `#buildRevivedNode`
|
|
237
|
+
* records a live 2026-08-18 result where a node handed two relay addresses at once with no
|
|
238
|
+
* deadline never finished starting at all (10,002ms and counting). Its identity was unproven at
|
|
239
|
+
* both relays, which is not this case — but "not this case" is a prediction, and the standing
|
|
240
|
+
* receiver is the thing that makes an agent reachable, so it does not wait on one.
|
|
241
|
+
*
|
|
242
|
+
* An empty `grantedAddrs` yields the plain TCP floor, exactly as before: reachable by peers
|
|
243
|
+
* that can dial directly, and loud about it (`session.standing_receiver.reservation.none`).
|
|
244
|
+
*/
|
|
245
|
+
const node = await this.createAgentNode(agentName, {
|
|
246
|
+
sessionId,
|
|
247
|
+
connectionGater: gater,
|
|
248
|
+
nodeType: "standing_receiver",
|
|
249
|
+
...(grantedAddrs.length > 0 ? { circuitRelayListenAddrs: grantedAddrs } : {}),
|
|
250
|
+
transportPrivateKey: receiverSeed,
|
|
251
|
+
});
|
|
252
|
+
if (grantedAddrs.length === 0) {
|
|
253
|
+
await node.start();
|
|
254
|
+
return { node, seed: receiverSeed };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* ⚠️ SLOW AND FAILED ARE DIFFERENT ANSWERS AND MUST NOT SHARE A BRANCH. Review F1: a single
|
|
258
|
+
* `.catch(() => false)` around this race collapsed every `start()` REJECTION into the deadline
|
|
259
|
+
* branch — and `CelloNodeImpl.start()` rejects by design, stopping the node and throwing
|
|
260
|
+
* `listen_failed` when no direct (non-circuit) listener materialised. That is the guard the
|
|
261
|
+
* transport keeps precisely so `FaultTolerance.NO_FATAL` cannot mask a real `EADDRINUSE`.
|
|
262
|
+
*
|
|
263
|
+
* Swallowed, it installed a STOPPED node as the agent's front door: no addresses to advertise,
|
|
264
|
+
* `#tryCreateStandingReceiver` never saw a failure so the M8B F14 retry never fired, and the
|
|
265
|
+
* operator was told the receiver "did not finish binding every circuit inside the deadline" and
|
|
266
|
+
* "is reachable through those" — sending them to the relay fleet for a port held by an orphan
|
|
267
|
+
* daemon on their own machine. The rejection is rethrown so it reaches
|
|
268
|
+
* `session.node.create.failed` with its own cause, exactly as it does on the no-relay path.
|
|
269
|
+
*/
|
|
270
|
+
let deadline;
|
|
271
|
+
let startError;
|
|
272
|
+
const started = node.start().then(() => "ok", (err) => { startError = err; return "failed"; });
|
|
273
|
+
const outcome = await Promise.race([
|
|
274
|
+
started,
|
|
275
|
+
new Promise((resolve) => {
|
|
276
|
+
// Per granted relay: each circuit listener is its own dial and its own reservation, so a
|
|
277
|
+
// pool of three must not be judged on a budget sized for one.
|
|
278
|
+
deadline = setTimeout(() => resolve("slow"), this.#ctx.srReservationTimeoutMs * grantedAddrs.length);
|
|
279
|
+
}),
|
|
280
|
+
]);
|
|
281
|
+
if (deadline !== undefined)
|
|
282
|
+
clearTimeout(deadline);
|
|
283
|
+
if (outcome === "failed")
|
|
284
|
+
throw startError;
|
|
285
|
+
/**
|
|
286
|
+
* GRANTED IN THE WALK, REFUSED AT INSTALL — a distinct fact and, until this line, an invisible
|
|
287
|
+
* one. The receiver would simply report `reservationsHeld: 2` where 3 relays granted, with
|
|
288
|
+
* nothing naming which relay went missing or why.
|
|
289
|
+
*
|
|
290
|
+
* ⚠️ IT HAS A KNOWN CAUSE AND A CROSS-REPO CLOCK. The walk stops the granted candidate and the
|
|
291
|
+
* node below RE-ASKS, which works because the relay remembers the proof — for
|
|
292
|
+
* `PROVEN_PEER_MEMORY_MS = 2 minutes` (`relay-connection-gater.ts`, trustless-cello). The walk
|
|
293
|
+
* costs up to `#srReservationTimeoutMs` × 2 attempts per relay, so a pool of three at the
|
|
294
|
+
* 15s default can spend 90 seconds before the final node asks relay 1 again. The earliest
|
|
295
|
+
* proof can expire before it is used, and that is what this event catches.
|
|
296
|
+
*/
|
|
297
|
+
const boundRelays = new Set(heldRelayIdsOf(node));
|
|
298
|
+
const grantedButUnbound = grantedAddrs
|
|
299
|
+
.map((a) => CIRCUIT_RELAY_ID.exec(a)?.[1])
|
|
300
|
+
.filter((id) => id !== undefined && !boundRelays.has(id));
|
|
301
|
+
if (grantedButUnbound.length > 0) {
|
|
302
|
+
this.#ctx.logger.warn("session.standing_receiver.spread.grant_not_bound", {
|
|
303
|
+
agentName,
|
|
304
|
+
relayPeerIds: grantedButUnbound,
|
|
305
|
+
relaysGranted: grantedAddrs.length,
|
|
306
|
+
reservationsHeld: boundRelays.size,
|
|
307
|
+
walkMs: Date.now() - walkStartedAt,
|
|
308
|
+
correlationId,
|
|
309
|
+
impact: "these relays granted this agent a reservation during the walk and then bound no " +
|
|
310
|
+
"circuit on the receiver itself, so the agent is reachable through fewer relays than it " +
|
|
311
|
+
"earned. The relay remembers a proof for two minutes; if walkMs is near or past that, " +
|
|
312
|
+
"the proof expired before the receiver asked and the walk is what needs shortening — " +
|
|
313
|
+
"not the relay fleet.",
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
if (outcome === "slow") {
|
|
317
|
+
// NOT a teardown, and now this line means only what it says: the node is starting and has not
|
|
318
|
+
// finished. It is installed with whatever circuits did materialise, because some reachability
|
|
319
|
+
// beats none and the reservation watchdog is what settles the rest.
|
|
320
|
+
this.#ctx.logger.warn("session.standing_receiver.spread.slow_start", {
|
|
321
|
+
agentName,
|
|
322
|
+
relaysGranted: grantedAddrs.length,
|
|
323
|
+
circuitAddrs: node.listenAddresses().filter((a) => a.includes("/p2p-circuit")).length,
|
|
324
|
+
budgetMs: this.#ctx.srReservationTimeoutMs * grantedAddrs.length,
|
|
325
|
+
correlationId,
|
|
326
|
+
impact: "the receiver did not finish binding every circuit it was granted inside the " +
|
|
327
|
+
"deadline, so it is being installed with the circuits it has. It is reachable through " +
|
|
328
|
+
"those; the reservation watchdog re-checks the rest on its next tick.",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return { node, seed: receiverSeed };
|
|
332
|
+
}
|
|
333
|
+
/** One standing-receiver create attempt (extracted for the M8B F14 retry loop). */
|
|
334
|
+
async #tryCreateStandingReceiver(agentName, correlationId) {
|
|
335
|
+
const sessionId = `standing_receiver_${randomUUID()}`;
|
|
336
|
+
const gater = new SessionConnectionGater({
|
|
337
|
+
sessionId,
|
|
338
|
+
// No named peer: admits NOBODY inbound until a session offer names the dialer, while leaving
|
|
339
|
+
// this node's own outbound errands open (DOD-M15-ASSIGN-1). It does NOT mean "open".
|
|
340
|
+
allowedPeerId: null,
|
|
341
|
+
logger: this.#ctx.logger,
|
|
342
|
+
});
|
|
343
|
+
// DOD-NAT-REACHABILITY-1: reserve with the agent's known relays. The relay
|
|
344
|
+
// peers are allowed OUTBOUND on the gater up front, so reservation refreshes
|
|
345
|
+
// keep working after the receiver is claimed and setAllowedPeer() narrows
|
|
346
|
+
// the inbound gate to the session counterparty.
|
|
347
|
+
const reservations = this.#ctx.reservationCircuitAddrs(agentName);
|
|
348
|
+
for (const relayPeerId of reservations.relayPeerIds) {
|
|
349
|
+
gater.setAllowedOutboundPeer(relayPeerId);
|
|
350
|
+
}
|
|
351
|
+
let node;
|
|
352
|
+
/**
|
|
353
|
+
* DOD-M12B-SESSION-SEED-1 — the transport identity of this receiver.
|
|
354
|
+
*
|
|
355
|
+
* Minted ONCE inside `#startReceiverNode` and returned with the node, not minted here. It is
|
|
356
|
+
* one seed for the whole walk (032-RELAYSPREAD): the receiver reserves with every relay that
|
|
357
|
+
* grants, and an agent must be dialable at ONE peer id through any of its circuits, so every
|
|
358
|
+
* reservation has to belong to the same key. What makes that safe is DOD-M15-ASSIGN-1 — the
|
|
359
|
+
* gater above admits NOBODY inbound — not the teardown, which bounds how long a rejected
|
|
360
|
+
* candidate lives rather than preventing it from overlapping. See the seed note in
|
|
361
|
+
* `#startReceiverNode` for the full argument.
|
|
362
|
+
*
|
|
363
|
+
* FRESH EVERY TIME, which is the privacy property rather than an implementation detail. A
|
|
364
|
+
* receiver serves at most one session (it is promoted into the session at handoff and replaced),
|
|
365
|
+
* so no identifier is ever shared between two sessions and the 2026-04-11 rationale —
|
|
366
|
+
* unlinkability of an agent's sessions to a passive observer — survives intact.
|
|
367
|
+
*/
|
|
368
|
+
let seed;
|
|
369
|
+
try {
|
|
370
|
+
({ node, seed } = await this.#startReceiverNode(agentName, sessionId, gater, reservations.addrs, correlationId));
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
// extractErrorMessage, NOT String(err): the transport throws structured
|
|
374
|
+
// plain objects ({ reason, message }), and String() destroys both into
|
|
375
|
+
// "[object Object]" — the loud failure must carry its cause.
|
|
376
|
+
const error = extractErrorMessage(err);
|
|
377
|
+
this.#ctx.logger.error("session.node.create.failed", {
|
|
378
|
+
sessionId,
|
|
379
|
+
agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
|
|
380
|
+
error,
|
|
381
|
+
correlationId,
|
|
382
|
+
});
|
|
383
|
+
return { outcome: "failed", error };
|
|
384
|
+
}
|
|
385
|
+
// M2: gracefulShutdown may have begun while this node was starting (ensure runs un-awaited).
|
|
386
|
+
// Don't install an orphan bound to a TCP port — stop it and bail.
|
|
387
|
+
if (this.#ctx.shuttingDown()) {
|
|
388
|
+
try {
|
|
389
|
+
await node.stop();
|
|
390
|
+
}
|
|
391
|
+
catch { /* best-effort */ }
|
|
392
|
+
return { outcome: "aborted" };
|
|
393
|
+
}
|
|
394
|
+
// L1: the agent may have gone offline (cello_set_agent_offline → removeStandingReceiverForAgent)
|
|
395
|
+
// while this ensure was parked on start(). Removal found no map entry to delete, so the
|
|
396
|
+
// tombstone is how we learn of it — tear the fresh node down rather than install an SR for
|
|
397
|
+
// an offline agent.
|
|
398
|
+
if (this.#ctx.standingReceiverRemoving.has(agentName)) {
|
|
399
|
+
this.#ctx.standingReceiverRemoving.delete(agentName);
|
|
400
|
+
try {
|
|
401
|
+
await node.stop();
|
|
402
|
+
}
|
|
403
|
+
catch { /* best-effort */ }
|
|
404
|
+
return { outcome: "aborted" };
|
|
405
|
+
}
|
|
406
|
+
// CELLO-M7-TRANSPORT-001: wrap in a NodeAutoNatService so its dialability drives session-
|
|
407
|
+
// address advertisement and the transport.autonat.* events fire.
|
|
408
|
+
const autoNat = new NodeAutoNatService({
|
|
409
|
+
node,
|
|
410
|
+
logger: this.#ctx.logger,
|
|
411
|
+
nodeType: "standing_receiver",
|
|
412
|
+
probers: this.#ctx.autoNatProbers(),
|
|
413
|
+
});
|
|
414
|
+
autoNat.emitInitialResult();
|
|
415
|
+
/**
|
|
416
|
+
* EVERY RELAY THE NODE ACTUALLY HOLDS A CIRCUIT WITH — derived from the addresses the node
|
|
417
|
+
* holds, never from `reservations.addrs`.
|
|
418
|
+
*
|
|
419
|
+
* The old code read `reservations.addrs[0]`'s relay id as a fallback, and its own comment
|
|
420
|
+
* called the hazard "dormant while the pool is size 1; the pool is designed to be larger."
|
|
421
|
+
* THIS UNIT IS WHAT MAKES THE POOL LARGER, so the dormant case wakes up: candidate 0 refusing
|
|
422
|
+
* while candidate 1 grants recorded a relay we are not connected to, the watchdog found it
|
|
423
|
+
* absent on every tick forever, and it rebuilt on the 30-second grid — churning the very
|
|
424
|
+
* reservations this unit exists to conserve. A candidate is a relay we ASKED; only a held
|
|
425
|
+
* address is a relay that ANSWERED, and the fallback conflated the two.
|
|
426
|
+
*
|
|
427
|
+
* The fallback's own stated worry stands, and is answered by the count rather than by the
|
|
428
|
+
* candidate list: if a transport ever reports a circuit address without the relay's peer id in
|
|
429
|
+
* `/p2p/<id>/p2p-circuit` form, that address yields no id and is not counted as held — so the
|
|
430
|
+
* receiver reads as degraded and gets rebuilt, instead of reading as healthy against a relay
|
|
431
|
+
* nobody is connected to. Degrading toward "rebuild" is the safe direction; the other one is
|
|
432
|
+
* the silent unreachability this whole file exists to kill.
|
|
433
|
+
*/
|
|
434
|
+
const heldRelayPeerIds = heldRelayIdsOf(node);
|
|
435
|
+
const circuitAddrs = heldRelayPeerIds.length;
|
|
436
|
+
const heldCircuitAddrs = node.listenAddresses().filter((a) => a.includes("/p2p-circuit"));
|
|
437
|
+
// DOD-M15-ASSIGN-1 review N3, widened by 032-RELAYSPREAD: the relays this receiver actually
|
|
438
|
+
// reserved with earn the inbound AutoNAT carve-out — nothing else does. Populated only from
|
|
439
|
+
// reservations that genuinely completed, so a directory that merely NAMES a relay cannot dial
|
|
440
|
+
// in behind it, however many relays it names.
|
|
441
|
+
gater.setReservedRelayPeers(heldRelayPeerIds);
|
|
442
|
+
// The re-spread clock starts HERE, at the build, not at the epoch. Otherwise the first decay
|
|
443
|
+
// re-spreads instantly — undoing the "a lost relay does not rebuild the receiver" rule seconds
|
|
444
|
+
// after it fires, and changing the peer id of an agent that just lost one relay of three. The
|
|
445
|
+
// ratchet this guards against runs over hours; nothing about it needs answering in a second.
|
|
446
|
+
this.#ctx.srLastRespreadAt.set(agentName, Date.now());
|
|
447
|
+
this.#ctx.standingReceivers.set(agentName, {
|
|
448
|
+
node,
|
|
449
|
+
gater,
|
|
450
|
+
autoNat,
|
|
451
|
+
seed,
|
|
452
|
+
relayPeerIds: heldRelayPeerIds,
|
|
453
|
+
});
|
|
454
|
+
this.#ctx.logger.info("session.node.created", {
|
|
455
|
+
sessionId,
|
|
456
|
+
agentName: `${STANDING_RECEIVER_AGENT_NAME}:${agentName}`,
|
|
457
|
+
sessionPeerId: node.getPeerId(),
|
|
458
|
+
correlationId,
|
|
459
|
+
});
|
|
460
|
+
// DOD-M15-RELAYAUTH-1: authenticate to the reservation relay NOW, not when a session first
|
|
461
|
+
// needs one. The relay times out a reservation nobody has proven key possession for
|
|
462
|
+
// (relay-connection-gater.ts, trustless-cello) — proving it here, instead of waiting for a
|
|
463
|
+
// real session to exist, is what keeps this reservation alive past that grace window.
|
|
464
|
+
// Best-effort and unawaited: a failure here costs nothing beyond the relay's own grace-window
|
|
465
|
+
// revoke, which the reservation watchdog already treats as an ordinary lost reservation.
|
|
466
|
+
// ONCE PER HELD RELAY. Each relay revokes independently — it times out the reservation of any
|
|
467
|
+
// peer that has not proven key possession TO IT — so proving to one of three and calling the
|
|
468
|
+
// receiver authenticated would lose the other two circuits about fifteen seconds later, which
|
|
469
|
+
// is the same silent unreachability with two more relays paying for it.
|
|
470
|
+
for (const relayPeerId of heldRelayPeerIds) {
|
|
471
|
+
const heldCircuitAddr = heldCircuitAddrs.find((a) => a.includes(`/p2p/${relayPeerId}/p2p-circuit`));
|
|
472
|
+
if (heldCircuitAddr === undefined)
|
|
473
|
+
continue;
|
|
474
|
+
void this.#ctx.authenticateStandingReceiver(agentName, node, relayPeerId, heldCircuitAddr, correlationId)
|
|
475
|
+
.catch((err) => {
|
|
476
|
+
this.#ctx.logger.warn("session.standing_receiver.relay_auth.failed", {
|
|
477
|
+
agentName,
|
|
478
|
+
relayPeerId,
|
|
479
|
+
error: extractErrorMessage(err),
|
|
480
|
+
correlationId,
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
// DOD-NAT-REACHABILITY-1 observability: how reachable did this receiver come up? Zero held
|
|
485
|
+
// while relays were offered means every relay refused or was unreachable — the agent is deaf
|
|
486
|
+
// to NAT'd initiators (public ones can still connect directly). That must be LOUD, not a quiet
|
|
487
|
+
// shrug.
|
|
488
|
+
//
|
|
489
|
+
// 032-RELAYSPREAD — TWO NUMBERS, SO TWO NAMES. Both events used to carry one field,
|
|
490
|
+
// `reservationsRequested`, holding `reservations.addrs.length` — the size of the CANDIDATE
|
|
491
|
+
// list, under a name that reads as a count of asks. That is why "the client already requests a
|
|
492
|
+
// reservation with every relay it knows" read as true in an audit: the outcome was one and the
|
|
493
|
+
// request was one too, and a single field could report neither.
|
|
494
|
+
// relaysOffered — how many relays were in the candidate list (deduped by relay peer id in
|
|
495
|
+
// `#reservationCircuitAddrs`, so it counts relays, not addresses).
|
|
496
|
+
// reservationsHeld — how many reservations this node actually holds, counted the only way
|
|
497
|
+
// that proves a grant: ANNOUNCED /p2p-circuit listen addresses. `start()`
|
|
498
|
+
// resolving is not enough — a relay out of reservation slots completes the
|
|
499
|
+
// handshake, grants nothing, and leaves a node that looks started and is
|
|
500
|
+
// dialable by nobody.
|
|
501
|
+
this.#ctx.logger.info("session.standing_receiver.reachability", {
|
|
502
|
+
agentName,
|
|
503
|
+
relaysOffered: reservations.addrs.length,
|
|
504
|
+
reservationsHeld: circuitAddrs,
|
|
505
|
+
correlationId,
|
|
506
|
+
});
|
|
507
|
+
if (reservations.addrs.length > 0 && circuitAddrs === 0) {
|
|
508
|
+
this.#ctx.logger.warn("session.standing_receiver.reservation.none", {
|
|
509
|
+
agentName,
|
|
510
|
+
relaysOffered: reservations.addrs.length,
|
|
511
|
+
// Zero by this branch's own condition, and stated rather than implied: the event reads
|
|
512
|
+
// "offered 3, held 0" on its own, without the reader having to find the gate above it.
|
|
513
|
+
reservationsHeld: circuitAddrs,
|
|
514
|
+
relayPeerIds: reservations.relayPeerIds,
|
|
515
|
+
correlationId,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
// DOD-PARK-DRAIN-1: this agent has a receiver again — drain whatever parked while it did not.
|
|
519
|
+
// Fired from the ONE place every path converges on (first ensure, the watchdog rebuild after a
|
|
520
|
+
// lost reservation, and the auth_ok rebuild), because the defect this closes was a trigger
|
|
521
|
+
// hooked to the wrong connection: content parks when the RELAY link dies, and the drain was
|
|
522
|
+
// waiting on DIRECTORY SIGNALING to reconnect — which it never had to, having never dropped.
|
|
523
|
+
this.#ctx.park.fireParkedDrain(agentName, "standing_receiver_ready");
|
|
524
|
+
return { outcome: "installed" };
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* DOD-LOOP-1: ensure the given agent has a standing receiver node (idempotent). Created when an
|
|
528
|
+
* agent comes online (cello_start_agent) and replaced after it is handed off to a session. The
|
|
529
|
+
* `#standingReceiverCreating` guard prevents two concurrent ensure() calls (e.g. the
|
|
530
|
+
* cello_start_agent hook racing a consume-site retry) from building two nodes for one agent.
|
|
531
|
+
*
|
|
532
|
+
* M8B F14: a create failure no longer strands the agent deaf. Each ensure runs a BOUNDED
|
|
533
|
+
* retry loop (`standingReceiverRetryDelaysMs`, default 1s/5s/15s) — covering the fixed-port
|
|
534
|
+
* race where the consumed receiver still holds the port until its session node is torn down —
|
|
535
|
+
* and when every attempt fails, fires the alarm-worthy `session.standing_receiver.dead`
|
|
536
|
+
* (error level), distinct from the per-attempt `session.node.create.failed`. Re-arm is also
|
|
537
|
+
* kicked from destroySessionNode/retireSessionNode (the moment the port frees) and from the
|
|
538
|
+
* inbound accept path (ensure on demand), so one failure can never leave the agent deaf forever.
|
|
539
|
+
*/
|
|
540
|
+
async ensureStandingReceiver(agentName, correlationId = randomUUID()) {
|
|
541
|
+
if (this.#ctx.standingReceivers.has(agentName) || this.#ctx.standingReceiverCreating.has(agentName))
|
|
542
|
+
return;
|
|
543
|
+
if (this.#ctx.shuttingDown())
|
|
544
|
+
return;
|
|
545
|
+
// A fresh ensure request supersedes any pending removal (agent toggled offline→online).
|
|
546
|
+
this.#ctx.standingReceiverRemoving.delete(agentName);
|
|
547
|
+
this.#ctx.standingReceiverCreating.add(agentName);
|
|
548
|
+
try {
|
|
549
|
+
let lastError = "";
|
|
550
|
+
for (let attempt = 0; attempt <= this.#ctx.srRetryDelaysMs.length; attempt++) {
|
|
551
|
+
if (attempt > 0) {
|
|
552
|
+
await new Promise((r) => setTimeout(r, this.#ctx.srRetryDelaysMs[attempt - 1]));
|
|
553
|
+
}
|
|
554
|
+
if (this.#ctx.shuttingDown())
|
|
555
|
+
return;
|
|
556
|
+
// L1 tombstone: the agent went offline while we were creating / backing off.
|
|
557
|
+
if (this.#ctx.standingReceiverRemoving.has(agentName)) {
|
|
558
|
+
this.#ctx.standingReceiverRemoving.delete(agentName);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const result = await this.#tryCreateStandingReceiver(agentName, correlationId);
|
|
562
|
+
if (result.outcome !== "failed")
|
|
563
|
+
return; // installed, or cleanly aborted (shutdown/offline)
|
|
564
|
+
lastError = result.error;
|
|
565
|
+
}
|
|
566
|
+
// M8B F14 (fix 4): an agent that WANTS a receiver has none after every attempt — the
|
|
567
|
+
// deaf-agent state. Fail LOUD so it is alarm-visible instead of a quiet degradation.
|
|
568
|
+
this.#ctx.logger.error("session.standing_receiver.dead", {
|
|
569
|
+
agentName,
|
|
570
|
+
reason: lastError,
|
|
571
|
+
attempts: this.#ctx.srRetryDelaysMs.length + 1,
|
|
572
|
+
correlationId,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
finally {
|
|
576
|
+
this.#ctx.standingReceiverCreating.delete(agentName);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Replace an agent's reservation-less standing receiver with one that reserves.
|
|
581
|
+
*
|
|
582
|
+
* Deliberately NOT removeStandingReceiverForAgent()+ensureStandingReceiverForAgent():
|
|
583
|
+
* the public remove CLEARS #agentsWantingReceiver, so a cello_set_agent_offline landing in
|
|
584
|
+
* the window while node.stop() is awaited would find no map entry and no creating
|
|
585
|
+
* marker, leave no tombstone, and the re-ensure would then RESURRECT a receiver for
|
|
586
|
+
* an agent that asked to go dark — accepting inbound sessions for an offline agent.
|
|
587
|
+
* Here the want-flag is left intact and re-checked after the stop: a concurrent stop
|
|
588
|
+
* clears it, and the rebuild correctly no-ops.
|
|
589
|
+
*/
|
|
590
|
+
async rebuildStandingReceiver(agentName) {
|
|
591
|
+
try {
|
|
592
|
+
const sr = this.#ctx.standingReceivers.get(agentName);
|
|
593
|
+
if (sr) {
|
|
594
|
+
this.#ctx.standingReceivers.delete(agentName);
|
|
595
|
+
/**
|
|
596
|
+
* DOD-M12B-SESSION-SEED-1 (review F8): drop it zeroed, like every other seed.
|
|
597
|
+
*
|
|
598
|
+
* (review F7, STILL DECIDED AGAINST — deliberately NOT reusing this seed for the
|
|
599
|
+
* replacement — but its stated blocker is GONE and the reason has changed. Restated rather
|
|
600
|
+
* than reworded, because a decision whose premise has been reversed is a decision nobody
|
|
601
|
+
* has actually made.)
|
|
602
|
+
*
|
|
603
|
+
* Reuse is attractive: this receiver's peer id may already be inside a `session_offer_accept`
|
|
604
|
+
* the counterparty is acting on, and a rebuild in that window is the documented "we record
|
|
605
|
+
* an identity that no longer exists… every send in this direction parks forever" defect.
|
|
606
|
+
*
|
|
607
|
+
* The old blocker was that a preserved identity would reach the candidate loop, whose
|
|
608
|
+
* rejected candidates were stopped WITHOUT awaiting `start()`, putting two live nodes on one
|
|
609
|
+
* advertised peer id. **032-RELAYSPREAD already crossed that line**: the walk now runs one
|
|
610
|
+
* shared seed through every candidate, with a settlement-chained teardown, and it is safe
|
|
611
|
+
* there because the receiver's gater admits nobody inbound.
|
|
612
|
+
*
|
|
613
|
+
* What still stops reuse HERE is different and is about the OLD node, not the new one. This
|
|
614
|
+
* rebuild path awaits `sr.node.stop()`, but a stop can hang on a stuck libp2p teardown, and
|
|
615
|
+
* handing the replacement the same identity before the previous receiver is provably dead
|
|
616
|
+
* would put two nodes on a peer id a COUNTERPARTY has been told to dial — which is not the
|
|
617
|
+
* candidate case at all: that node has a content handler and can be promoted. Doing it
|
|
618
|
+
* safely needs a bounded, verified teardown first. Still follow-on work.
|
|
619
|
+
*/
|
|
620
|
+
sr.seed.fill(0);
|
|
621
|
+
try {
|
|
622
|
+
sr.autoNat.stop();
|
|
623
|
+
await sr.node.stop();
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
this.#ctx.logger.warn("session.standing_receiver.teardown.failed", {
|
|
627
|
+
agentName,
|
|
628
|
+
error: extractErrorMessage(err),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// The agent may have gone offline while we were stopping the old node. Its
|
|
633
|
+
// want-flag is the authority — never resurrect a receiver it disowned.
|
|
634
|
+
if (!this.#ctx.agentsWantingReceiver.has(agentName) || this.#ctx.shuttingDown())
|
|
635
|
+
return;
|
|
636
|
+
await this.ensureStandingReceiver(agentName);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
this.#ctx.logger.warn("session.standing_receiver.reservation.rebuild.failed", {
|
|
640
|
+
agentName,
|
|
641
|
+
error: extractErrorMessage(err),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* DOD-M12B-SESSION-SEED-1 — build a revived session node that is REACHABLE, without ever hanging.
|
|
647
|
+
*
|
|
648
|
+
* MEASURED 2026-08-18, live, three ways:
|
|
649
|
+
* - handed 2 relay addrs at once, no deadline: `start()` never completes (10,002ms and counting)
|
|
650
|
+
* - handed none: `start()` in 1ms, but NOBODY can dial the node —
|
|
651
|
+
* the counterparty's re-dial fails
|
|
652
|
+
* `counterparty_dial_failed` and every message in
|
|
653
|
+
* both directions has to go the relay park route
|
|
654
|
+
* - this: one candidate at a time, each raced against its
|
|
655
|
+
* own deadline, plain node as the floor
|
|
656
|
+
*
|
|
657
|
+
* The middle option is what shipped for one test run and it made the session half-dead: revived,
|
|
658
|
+
* `active`, and unreachable. The first is what shipped before that and it hung. Neither is a
|
|
659
|
+
* choice between "fast" and "reliable" — the per-candidate race is how `#startReceiverNode` has
|
|
660
|
+
* always done it, and it is the shape that works in production every day.
|
|
661
|
+
*
|
|
662
|
+
* A FAILED CANDIDATE IS TORN DOWN AT SETTLEMENT. The first version awaited `stop()` immediately
|
|
663
|
+
* and claimed that made seed reuse safe; it did not — `libp2p.stop()` returns at once unless the
|
|
664
|
+
* node is `'started'`, and during the timeout window it is `'starting'` (review HIGH-3, verified
|
|
665
|
+
* against libp2p 3.3.2). The teardown is now chained onto the candidate's OWN start promise, so it
|
|
666
|
+
* runs whenever that settles, however late.
|
|
667
|
+
*
|
|
668
|
+
* A BRIEF OVERLAP IS THEREFORE POSSIBLE and is stated rather than denied: a candidate that grants
|
|
669
|
+
* at 4s comes up on this session's peer id and is stopped immediately after. What is guaranteed is
|
|
670
|
+
* that it dies, not that it never lives. The receiver path avoids even that by minting a seed per
|
|
671
|
+
* candidate; here the identity is fixed, which is the whole point of a revival, so that option
|
|
672
|
+
* does not exist.
|
|
673
|
+
*
|
|
674
|
+
* The floor is a plain node: a session that is usable over the relay park route beats no session.
|
|
675
|
+
*/
|
|
676
|
+
async buildRevivedNode(sessionId, gater, seed, candidateAddrs, agentName) {
|
|
677
|
+
for (const circuitAddr of candidateAddrs.slice(0, REVIVE_RESERVATION_CANDIDATES)) {
|
|
678
|
+
/**
|
|
679
|
+
* DOD-M15-RELAYSLOTS-1 — **A REVIVAL PROVES ITSELF TOO.**
|
|
680
|
+
*
|
|
681
|
+
* Review HIGH-3. The relay refuses a reservation to a peer that has not shown it belongs to
|
|
682
|
+
* a registered agent, and it remembers a proof for two minutes. A revival is almost never
|
|
683
|
+
* inside that window — the receiver last proved this peer id when the session was created,
|
|
684
|
+
* possibly days ago — so without this loop every revived session was refused by every
|
|
685
|
+
* candidate and came up on the plain floor: alive, `active`, and dialable by nobody, with
|
|
686
|
+
* every message in both directions forced through the relay park route.
|
|
687
|
+
*
|
|
688
|
+
* Two attempts, exactly as `#startReceiverNode` does it, and for the same measured reason:
|
|
689
|
+
* a reservation taken by hand on the same connection as the proof yields no dialable address.
|
|
690
|
+
* The seed is fixed here — that is what a revival IS — so the second attempt necessarily
|
|
691
|
+
* carries the identity the relay just recorded.
|
|
692
|
+
*/
|
|
693
|
+
let revivedNode;
|
|
694
|
+
let terminalRefusal = false;
|
|
695
|
+
for (let attempt = 0; attempt < 2 && !terminalRefusal; attempt++) {
|
|
696
|
+
const candidate = await this.createAgentNode(agentName, {
|
|
697
|
+
sessionId,
|
|
698
|
+
connectionGater: gater,
|
|
699
|
+
nodeType: "session",
|
|
700
|
+
inboundReachable: true,
|
|
701
|
+
transportPrivateKey: seed,
|
|
702
|
+
circuitRelayListenAddrs: [circuitAddr],
|
|
703
|
+
});
|
|
704
|
+
// KEEP THE START PROMISE. Review HIGH-3: `libp2p.stop()` opens with
|
|
705
|
+
// `if (this.status !== 'started') return`, and during the whole timeout window the status is
|
|
706
|
+
// `'starting'` — so awaiting `stop()` on a timed-out candidate stopped nothing and waited for
|
|
707
|
+
// nothing. The abandoned `start()` stayed in flight, and if the relay answered late the node
|
|
708
|
+
// went live holding THIS SESSION'S peer id, sharing the gater (so it admits the counterparty)
|
|
709
|
+
// with no content handler registered, and with no reference left to stop it. Verified against
|
|
710
|
+
// libp2p 3.3.2 rather than assumed.
|
|
711
|
+
const startP = candidate.start();
|
|
712
|
+
let startError;
|
|
713
|
+
const started = await Promise.race([
|
|
714
|
+
startP.then(() => true),
|
|
715
|
+
new Promise((res) => setTimeout(() => res(false), REVIVE_RESERVATION_TIMEOUT_MS).unref?.()),
|
|
716
|
+
]).catch((err) => { startError = err; return false; });
|
|
717
|
+
if (started && candidate.listenAddresses().some((a) => a.includes("/p2p-circuit"))) {
|
|
718
|
+
this.#ctx.logger.info("session.revive.reservation.granted", { agentName, sessionId, attempts: attempt + 1 });
|
|
719
|
+
revivedNode = candidate;
|
|
720
|
+
break;
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* No reservation on the first attempt is the EXPECTED answer for a peer whose proof has
|
|
724
|
+
* aged out. Prove and go round once more.
|
|
725
|
+
*
|
|
726
|
+
* Only when `started` is true: `libp2p.stop()` opens with `if (this.status !== 'started')
|
|
727
|
+
* return`, so a timed-out candidate cannot be torn down here and rebuilding on its seed
|
|
728
|
+
* would put two live nodes on one peer id. That case falls through to the settlement-chained
|
|
729
|
+
* teardown below, which is the only thing that reliably kills a still-starting node.
|
|
730
|
+
*/
|
|
731
|
+
if (attempt === 0 && started) {
|
|
732
|
+
const verdict = await this.#ctx.proveToRelay(agentName, circuitAddr, candidate, sessionId, false);
|
|
733
|
+
try {
|
|
734
|
+
await candidate.stop();
|
|
735
|
+
}
|
|
736
|
+
catch { /* best-effort */ }
|
|
737
|
+
if (verdict === "refused_this_agent") {
|
|
738
|
+
// The refusal is about this AGENT, so the remaining candidates would answer identically.
|
|
739
|
+
terminalRefusal = true;
|
|
740
|
+
this.#ctx.logger.warn("session.revive.reservation.declined", {
|
|
741
|
+
agentName,
|
|
742
|
+
sessionId,
|
|
743
|
+
circuitAddr,
|
|
744
|
+
reason: "relay_refused_this_agent",
|
|
745
|
+
impact: "the relay refused this agent rather than being unwilling or unwell, so every " +
|
|
746
|
+
"other relay refuses it the same way. The session comes up reachable only via the " +
|
|
747
|
+
"relay park route; cello_status carries the cause.",
|
|
748
|
+
});
|
|
749
|
+
break;
|
|
750
|
+
}
|
|
751
|
+
// Only a landed proof earns the retry — see the same rule in `#startReceiverNode`.
|
|
752
|
+
if (verdict !== "proven") {
|
|
753
|
+
this.#ctx.logger.warn("session.revive.reservation.declined", {
|
|
754
|
+
agentName,
|
|
755
|
+
sessionId,
|
|
756
|
+
circuitAddr,
|
|
757
|
+
reason: "relay_proof_refused",
|
|
758
|
+
impact: "this relay would not take the agent's proof, so asking it again would be " +
|
|
759
|
+
"refused the same way. Trying the next relay.",
|
|
760
|
+
});
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
// Started but granted nothing, or never started. Either way this node is not the one.
|
|
766
|
+
//
|
|
767
|
+
// Review MEDIUM-5: name WHICH of the three causes this was, the way `#startReceiverNode` does.
|
|
768
|
+
// "declined" alone stood for a relay that is full, a relay that is unreachable, and a relay
|
|
769
|
+
// that is merely slow — three different problems with three different responses, and the
|
|
770
|
+
// thrown error was discarded entirely.
|
|
771
|
+
const declineReason = started
|
|
772
|
+
? "relay_granted_no_reservation"
|
|
773
|
+
: startError !== undefined
|
|
774
|
+
? "relay_unreachable"
|
|
775
|
+
: "reservation_did_not_complete_in_time";
|
|
776
|
+
const isLast = circuitAddr === candidateAddrs.slice(0, REVIVE_RESERVATION_CANDIDATES).at(-1);
|
|
777
|
+
this.#ctx.logger.warn("session.revive.reservation.declined", {
|
|
778
|
+
agentName,
|
|
779
|
+
sessionId,
|
|
780
|
+
circuitAddr,
|
|
781
|
+
reason: declineReason,
|
|
782
|
+
...(startError !== undefined ? { error: extractErrorMessage(startError) } : {}),
|
|
783
|
+
impact: isLast
|
|
784
|
+
? "no relay granted; the session comes up reachable only via the relay park route"
|
|
785
|
+
: "trying the next relay",
|
|
786
|
+
});
|
|
787
|
+
// Teardown at SETTLEMENT, not now: a `stop()` issued while the node is still starting is a
|
|
788
|
+
// no-op (see above), so the only way to guarantee this node dies is to wait for its own start
|
|
789
|
+
// to finish first. Not awaited, so a hung start cannot hold the revival up — the point is that
|
|
790
|
+
// the teardown eventually happens, not that it happens before the next candidate.
|
|
791
|
+
void startP.then(() => candidate.stop().catch(() => { }), () => { });
|
|
792
|
+
break;
|
|
793
|
+
}
|
|
794
|
+
if (revivedNode)
|
|
795
|
+
return revivedNode;
|
|
796
|
+
if (terminalRefusal)
|
|
797
|
+
break;
|
|
798
|
+
}
|
|
799
|
+
// THE FLOOR. No reservation, so the counterparty cannot dial us directly — but their messages
|
|
800
|
+
// park at the relay and drain, which is how every message in the 2026-08-18 test arrived. A
|
|
801
|
+
// session usable one way beats a session that never comes back.
|
|
802
|
+
const plain = await this.createAgentNode(agentName, {
|
|
803
|
+
sessionId,
|
|
804
|
+
connectionGater: gater,
|
|
805
|
+
nodeType: "session",
|
|
806
|
+
inboundReachable: true,
|
|
807
|
+
transportPrivateKey: seed,
|
|
808
|
+
});
|
|
809
|
+
await plain.start();
|
|
810
|
+
if (candidateAddrs.length > 0) {
|
|
811
|
+
this.#ctx.logger.warn("session.revive.reservation.none", {
|
|
812
|
+
agentName,
|
|
813
|
+
sessionId,
|
|
814
|
+
candidates: candidateAddrs.length,
|
|
815
|
+
impact: "the revived session holds no circuit address — the counterparty cannot dial it, so "
|
|
816
|
+
+ "delivery in both directions depends on relay store-and-forward until it is rebuilt",
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
return plain;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* DOD-M15-RELAYONLY-1: build a transport node for THIS AGENT, with its privacy posture applied.
|
|
823
|
+
*
|
|
824
|
+
* ⚠️ THE CHOKE POINT FOR NODE CREATION, and it exists for the same reason as the one around
|
|
825
|
+
* `getStandingReceiverInfo`. Five call sites construct nodes; passing `relayOnly` at each would be
|
|
826
|
+
* a hand-kept list, and the SIXTH — added next month by someone who has never read this line —
|
|
827
|
+
* would build a node that hole-punches its way to a direct connection for an operator who asked
|
|
828
|
+
* never to be directly reachable. Here, a new caller inherits the posture instead of being told.
|
|
829
|
+
*
|
|
830
|
+
* `unknown` counts as ON, matching the publish and dial halves: a node that declines to hole-punch
|
|
831
|
+
* is reachable over the relay, while a disclosed address cannot be recalled.
|
|
832
|
+
*/
|
|
833
|
+
// ⚠️ NOT `async`. This wrapper sits in the standing-receiver startup path, and making it async
|
|
834
|
+
// added ONE extra microtask hop before the receiver was installed in `#standingReceivers` — which
|
|
835
|
+
// was enough for `createSessionNode` to run first and answer `standing_receiver_unavailable`. Two
|
|
836
|
+
// tests in `msg-021-session-seed` caught it. Returning the factory's promise directly keeps the
|
|
837
|
+
// await count identical to the call it replaced. **This is a real fragility in the install path,
|
|
838
|
+
// not a quirk of the tests:** anything that adds a tick here re-breaks it.
|
|
839
|
+
createAgentNode(agentName, config) {
|
|
840
|
+
// ⚠️ THE POSTURE READ MUST NEVER COST US A NODE. This sits in the standing-receiver startup
|
|
841
|
+
// path, whose caller treats a throw as "no receiver" and leaves the agent deaf to all inbound —
|
|
842
|
+
// surfacing to the operator as `standing_receiver_unavailable`, which names the transport for a
|
|
843
|
+
// fault in a settings lookup. `relayOnlyState` already absorbs a throwing GETTER; this absorbs
|
|
844
|
+
// everything else, including a resolution failure for an agent row that is not there yet.
|
|
845
|
+
//
|
|
846
|
+
// The fallback is ON, not off: an agent whose posture we cannot read gets the private-but-
|
|
847
|
+
// reachable node, because a node that declines to hole-punch still works over the relay while a
|
|
848
|
+
// disclosed address cannot be recalled.
|
|
849
|
+
let relayOnly = true;
|
|
850
|
+
try {
|
|
851
|
+
relayOnly = relayOnlyState((key) => this.#ctx.records.getSetting(agentName, key), this.#db !== null) !== "off";
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
this.#ctx.logger.warn("settings.relay_only.unreadable", {
|
|
855
|
+
agentName,
|
|
856
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
857
|
+
impact: "could not read this agent's relay-only posture, so the node is built WITHOUT the hole-punch",
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
return this.#ctx.factory.createNode({ ...config, relayOnly });
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* DOD-PARK-DRAIN-1 (review F6): why there is no standing-receiver node to dial from — named
|
|
864
|
+
* precisely, because `standing_receiver_unavailable` is the exit-point label that stood in for
|
|
865
|
+
* four different causes and misnamed this very incident 102 times.
|
|
866
|
+
*
|
|
867
|
+
* Only meaningful once `getStandingReceiverNode()` has returned null, which means NO agent on
|
|
868
|
+
* this daemon has a ready receiver — the dial node is not agent-scoped.
|
|
869
|
+
*/
|
|
870
|
+
standingReceiverAbsenceReason(agentName) {
|
|
871
|
+
if (this.#ctx.shuttingDown())
|
|
872
|
+
return "daemon_shutting_down";
|
|
873
|
+
if (this.#ctx.standingReceiverCreating.has(agentName))
|
|
874
|
+
return "standing_receiver_creating";
|
|
875
|
+
if (!this.#ctx.agentsWantingReceiver.has(agentName))
|
|
876
|
+
return "agent_offline";
|
|
877
|
+
return "no_standing_receiver";
|
|
878
|
+
}
|
|
879
|
+
getStandingReceiverInfo(agentName) {
|
|
880
|
+
// DOD-LOOP-1: the initiator advertises ITS OWN agent's standing receiver, which it then reuses
|
|
881
|
+
// as the session node — so the advertised endpoint matches the node the counterparty dials.
|
|
882
|
+
const sr = this.#ctx.standingReceivers.get(agentName);
|
|
883
|
+
if (!sr)
|
|
884
|
+
return null;
|
|
885
|
+
// DOD-M15-RELAYONLY-1: THE CHOKE POINT. Every path that publishes this agent's session
|
|
886
|
+
// addresses draws from here — `initiator_session_addrs` on the way out, and
|
|
887
|
+
// `counterparty_session_addrs` when answering an offer — and this method has no other kind of
|
|
888
|
+
// consumer: its whole purpose is to be advertised, as the docstring above says.
|
|
889
|
+
//
|
|
890
|
+
// The suppression lives HERE rather than at those call sites deliberately. Call-site gating
|
|
891
|
+
// would be a hand-kept list, and a fourth publish path added later would leak the operator's IP
|
|
892
|
+
// while every test stayed green. At the choke point a new caller inherits the protection
|
|
893
|
+
// instead of having to be told about it.
|
|
894
|
+
const endpoint = { peerId: sr.node.getPeerId(), addrs: sr.node.listenAddresses() };
|
|
895
|
+
// ⚠️ TRI-STATE, not a boolean, and the third state is the one that matters. `getSetting` answers
|
|
896
|
+
// `null` both for "unset" and for "there is no database", and reading the second as OFF fails
|
|
897
|
+
// TOWARD DISCLOSURE: the standing receiver outlives the DB during shutdown, so an offer arriving
|
|
898
|
+
// in that window would publish the operator's real addresses with relay-only switched on.
|
|
899
|
+
// `relayOnlyState` also absorbs a THROW — `#requireAgentId` throws for a retired agent, and this
|
|
900
|
+
// method is called from the offer ceremony inside a floating async with no catch, where the
|
|
901
|
+
// throw becomes an unhandled rejection and the offer vanishes with no local log.
|
|
902
|
+
// ⚠️ `!== null`, NOT `!== undefined`. The field is declared `DaemonDatabase | null` and is only
|
|
903
|
+
// ever assigned on open or set to `null` on close — **it is never `undefined` at any point in
|
|
904
|
+
// its lifetime**, so the first version of this line was a compile-time-constant `true` that
|
|
905
|
+
// TypeScript had no reason to complain about, and the whole `"unknown"` branch was unreachable
|
|
906
|
+
// dead code. The fix for the disclosure window silently did nothing, which is worse than not
|
|
907
|
+
// having written it: the DoD said the window was closed and it was wide open.
|
|
908
|
+
const state = relayOnlyState((key) => this.#ctx.records.getSetting(agentName, key), this.#db !== null);
|
|
909
|
+
if (state === "unknown") {
|
|
910
|
+
this.#ctx.logger.warn("settings.relay_only.unreadable", {
|
|
911
|
+
agentName,
|
|
912
|
+
impact: "cannot tell whether relay-only is on, so ONLY this agent's relay-circuit addresses are " +
|
|
913
|
+
"published — never a direct one. Publishing a real address is irreversible and a narrowed " +
|
|
914
|
+
"route is not, so this errs toward reachability loss rather than disclosure",
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
// ONE filter, not two. The `unknown` branch used to build its own filtered object inline, which
|
|
918
|
+
// put a second implementation inside the very method whose design rationale is that there is
|
|
919
|
+
// exactly one — and the bypass guard could not see it.
|
|
920
|
+
return publishableEndpoint(endpoint, state !== "off");
|
|
921
|
+
}
|
|
922
|
+
/** DOD-LOOP-1: whether the given agent has a standing receiver ready (any agent if omitted). */
|
|
923
|
+
getStandingReceiverReady(agentName) {
|
|
924
|
+
if (agentName !== undefined)
|
|
925
|
+
return this.#ctx.standingReceivers.has(agentName);
|
|
926
|
+
return this.#ctx.standingReceivers.size > 0;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* The standing receiver's libp2p node — a general-purpose node usable for OUTBOUND dials that
|
|
930
|
+
* are not session-scoped (e.g. the content-park deposit/pull to the relay, MSG-001-3b). Its
|
|
931
|
+
* gater admits nobody INBOUND until a session names them (DOD-M15-ASSIGN-1), but leaves these
|
|
932
|
+
* outbound errands open. Returns null until the receiver is ready.
|
|
933
|
+
*/
|
|
934
|
+
getStandingReceiverNode(agentName) {
|
|
935
|
+
// With an agentName: that agent's own standing-receiver node (needed when the dial must
|
|
936
|
+
// originate from a SPECIFIC agent — e.g. the startup content-park re-park, where the
|
|
937
|
+
// depositor is the original sender). Without one: any ready standing receiver (outbound
|
|
938
|
+
// content-park deposit/pull to the relay — not session-scoped, and OUTBOUND, which is the
|
|
939
|
+
// half of the gater that is open; inbound admits nobody until a dialer is named).
|
|
940
|
+
if (agentName !== undefined)
|
|
941
|
+
return this.#ctx.standingReceivers.get(agentName)?.node ?? null;
|
|
942
|
+
return this.#anyStandingReceiver()?.node ?? null;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* First ready standing receiver (any agent) — for agent-agnostic OUTBOUND use. Its gater admits
|
|
946
|
+
* nobody INBOUND until a session names them (DOD-M15-ASSIGN-1); outbound stays open, which is the
|
|
947
|
+
* property these callers depend on.
|
|
948
|
+
*/
|
|
949
|
+
#anyStandingReceiver() {
|
|
950
|
+
for (const sr of this.#ctx.standingReceivers.values())
|
|
951
|
+
return sr;
|
|
952
|
+
return null;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* DOD-M12B-RESERVATION-RETRY-1 — whether a NAT'd peer can actually DIAL this agent.
|
|
956
|
+
*
|
|
957
|
+
* `standing_receiver_ready` answers "is there a receiver?", which is true for a plain TCP node
|
|
958
|
+
* that no relay would give a circuit reservation to. Behind NAT that node is reachable by nobody,
|
|
959
|
+
* and the difference was visible only in the log — where it was visible 481 times and nobody
|
|
960
|
+
* acted. `"retrying"` and `"unreachable"` are the states an operator can do something about.
|
|
961
|
+
*
|
|
962
|
+
* reserved — holds a circuit reservation; a NAT'd peer can dial it.
|
|
963
|
+
* retrying — no reservation yet, still re-asking on a backoff.
|
|
964
|
+
* unreachable — no circuit reservation and the automatic re-attempts are spent, so only peers
|
|
965
|
+
* that can connect DIRECTLY will get in. It is not permanent: a directory
|
|
966
|
+
* reconnect carrying a DIFFERENT relay pool re-arms the budget, because a relay we
|
|
967
|
+
* have never tried is new information.
|
|
968
|
+
* absent — no receiver at all (the agent is not online).
|
|
969
|
+
*/
|
|
970
|
+
getStandingReceiverReachability(agentName) {
|
|
971
|
+
const sr = this.#ctx.standingReceivers.get(agentName);
|
|
972
|
+
if (!sr)
|
|
973
|
+
return "absent";
|
|
974
|
+
// AT LEAST ONE. Holding two circuits and losing one leaves the agent perfectly dialable, so it
|
|
975
|
+
// is not "retrying" — reporting it as such sends an operator hunting a fault that is not there.
|
|
976
|
+
if (sr.relayPeerIds.length > 0)
|
|
977
|
+
return "reserved";
|
|
978
|
+
const retry = this.#ctx.srReservationRetry.get(agentName);
|
|
979
|
+
return retry !== undefined && retry.attempts > SR_RESERVATION_MAX_RETRIES ? "unreachable" : "retrying";
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* CELLO-M7-TRANSPORT-001: the AutoNAT service wrapping the current standing
|
|
983
|
+
* receiver node, or null if the standing receiver is not ready. The composition
|
|
984
|
+
* root uses this as the daemon's runtime IAutoNatService — its getDialability()
|
|
985
|
+
* drives the SessionAssignment advertised address (AC-004/AC-019), and it is the
|
|
986
|
+
* source of the transport.autonat.result / transport.autonat.unavailable events.
|
|
987
|
+
*/
|
|
988
|
+
getStandingReceiverAutoNat() {
|
|
989
|
+
// DOD-LOOP-1: the daemon-level autonat source is any ready standing receiver; null until one
|
|
990
|
+
// exists (the composition root falls back to LocalAutoNatStub). Per-session advertised dialability
|
|
991
|
+
// comes from the initiating agent's own SR via getStandingReceiverInfo, not this daemon-level value.
|
|
992
|
+
return this.#anyStandingReceiver()?.autoNat ?? null;
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Which peer this agent's standing receiver is currently admitting INBOUND — `null` for nobody.
|
|
996
|
+
*
|
|
997
|
+
* Read-only, and it answers a question the daemon otherwise cannot: *"whose dial would this
|
|
998
|
+
* receiver accept right now?"* The gate is narrowed and re-closed from several paths (an offer
|
|
999
|
+
* arrives, an assignment is refused, a session is promoted), and until now the only way to know
|
|
1000
|
+
* where it had ended up was to reproduce the sequence in your head.
|
|
1001
|
+
*
|
|
1002
|
+
* Added for `DOD-M15-RESPONDER-VERIFY-1`, where a refusal for one session was closing the gate a
|
|
1003
|
+
* DIFFERENT session had narrowed — a defect with no observable symptom short of the second
|
|
1004
|
+
* session's initiator being refused with "nothing invited it".
|
|
1005
|
+
*/
|
|
1006
|
+
getStandingReceiverAllowedPeer(agentName) {
|
|
1007
|
+
return this.#ctx.standingReceivers.get(agentName)?.gater.getAllowedPeerId() ?? null;
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* DOD-M15-ASSIGN-1 — name the one peer allowed to dial this agent's standing receiver, at the
|
|
1011
|
+
* moment the directory's `session_offer` says who is coming.
|
|
1012
|
+
*
|
|
1013
|
+
* This is what makes the receiver's deny-by-default safe. The offer names
|
|
1014
|
+
* `initiator_session_peer_id`, and the responder answers it by advertising its OWN address in
|
|
1015
|
+
* `session_offer_accept`. Narrowing here — BEFORE that answer goes out — means the door opens to
|
|
1016
|
+
* exactly one peer at the same instant the address that reaches them is published, and never
|
|
1017
|
+
* before. The initiator cannot know where to dial until the accept it triggers has been sent.
|
|
1018
|
+
*
|
|
1019
|
+
* Returns WHICH failure it was, never a bare false (review F6). The caller reports a distinct
|
|
1020
|
+
* reason per cause: "no receiver" and "the directory named nobody" are different subsystems, and
|
|
1021
|
+
* collapsing them sent the operator to the directory for a local problem. This method never
|
|
1022
|
+
* widens the gate to compensate.
|
|
1023
|
+
*
|
|
1024
|
+
* Narrows INBOUND ONLY. The receiver is still the daemon's general-purpose dialer at this point
|
|
1025
|
+
* — no assignment exists yet — so revoking its outbound latitude here would break content
|
|
1026
|
+
* parking and restart-seal submission (review F2).
|
|
1027
|
+
*/
|
|
1028
|
+
admitOfferedDialer(agentName, initiatorSessionPeerId, sessionIdHex) {
|
|
1029
|
+
const sr = this.#ctx.standingReceivers.get(agentName);
|
|
1030
|
+
if (!sr)
|
|
1031
|
+
return "no_receiver";
|
|
1032
|
+
if (initiatorSessionPeerId === "")
|
|
1033
|
+
return "no_peer_named";
|
|
1034
|
+
sr.gater.admitInboundPeer(initiatorSessionPeerId);
|
|
1035
|
+
this.#offeredDialer.set(this.#ctx.sessionKey(agentName, sessionIdHex), initiatorSessionPeerId);
|
|
1036
|
+
return "narrowed";
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* What the UNSIGNED offer claimed, so the SIGNED assignment can be checked against it.
|
|
1040
|
+
*
|
|
1041
|
+
* DOD-M15-OFFER-SIGNED-1. Decision 2 rules that the listening socket is "gated on the
|
|
1042
|
+
* assignment", and the gate is narrowed from `session_offer` — a frame carrying no signature —
|
|
1043
|
+
* because that is the only thing that arrives early enough. Timing forced the offer; it does not
|
|
1044
|
+
* excuse trusting it.
|
|
1045
|
+
*
|
|
1046
|
+
* Keeping what the offer said turns the two frames into a CHECK ON EACH OTHER. The assignment is
|
|
1047
|
+
* FROST-signed by the initiator's own threshold group, which no single directory can produce, and
|
|
1048
|
+
* it names the same peer id. A directory that says one peer in the offer and another in the
|
|
1049
|
+
* assignment is naming two different dialers for one session — which a truthful directory never
|
|
1050
|
+
* does, and which is exactly the move a compromised one would make to slip a peer past the gate
|
|
1051
|
+
* before the signed document arrives.
|
|
1052
|
+
*/
|
|
1053
|
+
getOfferedDialer(agentName, sessionIdHex) {
|
|
1054
|
+
return this.#offeredDialer.get(this.#ctx.sessionKey(agentName, sessionIdHex)) ?? null;
|
|
1055
|
+
}
|
|
1056
|
+
/** Forget the offered dialer for ONE session — called on BOTH the claim and the refusal paths. */
|
|
1057
|
+
clearOfferedDialer(agentName, sessionIdHex) {
|
|
1058
|
+
this.#offeredDialer.delete(this.#ctx.sessionKey(agentName, sessionIdHex));
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* RE-CLOSE the standing receiver — but ONLY if this session is still the one holding it.
|
|
1062
|
+
*
|
|
1063
|
+
* DOD-M15-OFFER-SIGNED-1 review F4, then N1. The first version closed the gate unconditionally,
|
|
1064
|
+
* and that was worse than the defect it fixed: an agent has ONE standing receiver with ONE allowed
|
|
1065
|
+
* peer, so a refusal for session P closed the gate that offer Q had narrowed. Q's initiator —
|
|
1066
|
+
* invited, legitimate — was then refused with *"nothing invited it"*, which this daemon had.
|
|
1067
|
+
*
|
|
1068
|
+
* That is the same cross-session interference F1 was written to remove, moved one method along,
|
|
1069
|
+
* and triggerable the same way: one bogus offer/assignment pair collapses a concurrent real
|
|
1070
|
+
* session.
|
|
1071
|
+
*
|
|
1072
|
+
* So the gate is closed only when it still names the peer THIS session opened it to. If a later
|
|
1073
|
+
* offer has already re-narrowed it, that offer owns the receiver and its narrowing stands.
|
|
1074
|
+
*
|
|
1075
|
+
* NO EVICTION SWEEP, deliberately (N4). The sweep evicts by "not the allowed peer", and
|
|
1076
|
+
* `getConnections()` returns OUTBOUND connections too — including the content-park and
|
|
1077
|
+
* restart-seal dials this node makes as the daemon's general-purpose dialer, whose targets are on
|
|
1078
|
+
* no allowlist by construction. Sweeping here hung those up, and the failure surfaced as
|
|
1079
|
+
* `relay_unavailable`: a transport label for a local decision, which is the exact substitution
|
|
1080
|
+
* that comment was written to prevent. The load-bearing control is `DOD-M15-FRAME-1`'s frame gate,
|
|
1081
|
+
* which refuses what an unauthorised peer sends; closing the door is enough here.
|
|
1082
|
+
*/
|
|
1083
|
+
revokeOfferedDialer(agentName, sessionIdHex, offeredPeerId) {
|
|
1084
|
+
this.clearOfferedDialer(agentName, sessionIdHex);
|
|
1085
|
+
const sr = this.#ctx.standingReceivers.get(agentName);
|
|
1086
|
+
if (!sr || offeredPeerId === null)
|
|
1087
|
+
return;
|
|
1088
|
+
if (sr.gater.getAllowedPeerId() !== offeredPeerId) {
|
|
1089
|
+
// A later offer already owns the receiver. Closing it would refuse THAT session's initiator.
|
|
1090
|
+
this.#ctx.logger.debug("session.gate.revoke.skipped", {
|
|
1091
|
+
agentName,
|
|
1092
|
+
sessionId: sessionIdHex,
|
|
1093
|
+
reason: "a later offer has re-narrowed this receiver; its narrowing stands",
|
|
1094
|
+
});
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
sr.gater.closeInbound();
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
//# sourceMappingURL=standing-receivers.js.map
|