@peerbit/pubsub 5.3.12 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/index.d.ts +31 -13
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +622 -145
- package/dist/src/index.js.map +1 -1
- package/package.json +8 -7
- package/src/index.ts +914 -216
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts +0 -3
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js +0 -90
- package/dist/benchmark/fanout-tree-parent-upgrade-default-ready.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts +0 -2
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.js +0 -712
- package/dist/benchmark/fanout-tree-parent-upgrade-eval.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts +0 -2
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js +0 -1591
- package/dist/benchmark/fanout-tree-parent-upgrade-multi-eval.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts +0 -2
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js +0 -297
- package/dist/benchmark/fanout-tree-parent-upgrade-prepush.js.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts +0 -73
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.js +0 -183
- package/dist/benchmark/fanout-tree-parent-upgrade-preset.js.map +0 -1
- package/dist/benchmark/fanout-tree-sim-lib.d.ts +0 -303
- package/dist/benchmark/fanout-tree-sim-lib.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-sim-lib.js +0 -1752
- package/dist/benchmark/fanout-tree-sim-lib.js.map +0 -1
- package/dist/benchmark/fanout-tree-sim.d.ts +0 -2
- package/dist/benchmark/fanout-tree-sim.d.ts.map +0 -1
- package/dist/benchmark/fanout-tree-sim.js +0 -870
- package/dist/benchmark/fanout-tree-sim.js.map +0 -1
- package/dist/benchmark/index.d.ts +0 -8
- package/dist/benchmark/index.d.ts.map +0 -1
- package/dist/benchmark/index.js +0 -62
- package/dist/benchmark/index.js.map +0 -1
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts +0 -86
- package/dist/benchmark/pubsub-topic-sim-lib.d.ts.map +0 -1
- package/dist/benchmark/pubsub-topic-sim-lib.js +0 -680
- package/dist/benchmark/pubsub-topic-sim-lib.js.map +0 -1
- package/dist/benchmark/pubsub-topic-sim.d.ts +0 -9
- package/dist/benchmark/pubsub-topic-sim.d.ts.map +0 -1
- package/dist/benchmark/pubsub-topic-sim.js +0 -134
- package/dist/benchmark/pubsub-topic-sim.js.map +0 -1
- package/dist/benchmark/sim/bench-utils.d.ts +0 -25
- package/dist/benchmark/sim/bench-utils.d.ts.map +0 -1
- package/dist/benchmark/sim/bench-utils.js +0 -141
- package/dist/benchmark/sim/bench-utils.js.map +0 -1
package/src/index.ts
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
+
import { type PeerId as Libp2pPeerId } from "@libp2p/interface";
|
|
1
2
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
PublicSignKey,
|
|
4
|
+
getPublicKeyFromPeerId,
|
|
5
|
+
sha256Sync,
|
|
6
|
+
} from "@peerbit/crypto";
|
|
6
7
|
import { logger as loggerFn } from "@peerbit/logger";
|
|
7
8
|
import {
|
|
8
|
-
assertCanonicalTopicRootCandidates,
|
|
9
|
-
assertTopicRootCandidatesFrame,
|
|
10
9
|
DataEvent,
|
|
11
10
|
GetSubscribers,
|
|
12
|
-
isCanonicalTopicRootCandidate,
|
|
13
11
|
PeerUnavailable,
|
|
14
12
|
type PubSub,
|
|
15
13
|
PubSubData,
|
|
@@ -20,12 +18,18 @@ import {
|
|
|
20
18
|
SubscriptionData,
|
|
21
19
|
SubscriptionEvent,
|
|
22
20
|
TOPIC_ROOT_CANDIDATES_MAX,
|
|
21
|
+
TOPIC_ROOT_CANDIDATE_CLAIMS_MAX,
|
|
22
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES,
|
|
23
|
+
TopicRootCandidateClaims,
|
|
23
24
|
TopicRootCandidates,
|
|
24
25
|
TopicRootQuery,
|
|
25
26
|
TopicRootQueryResponse,
|
|
26
27
|
UnsubcriptionEvent,
|
|
27
|
-
type UnsubscriptionReason,
|
|
28
28
|
Unsubscribe,
|
|
29
|
+
type UnsubscriptionReason,
|
|
30
|
+
assertCanonicalTopicRootCandidates,
|
|
31
|
+
assertTopicRootCandidatesFrame,
|
|
32
|
+
isCanonicalTopicRootCandidate,
|
|
29
33
|
} from "@peerbit/pubsub-interface";
|
|
30
34
|
import {
|
|
31
35
|
DirectStream,
|
|
@@ -58,6 +62,7 @@ import {
|
|
|
58
62
|
} from "@peerbit/stream-interface";
|
|
59
63
|
import { AbortError, TimeoutError, delay } from "@peerbit/time";
|
|
60
64
|
import { Uint8ArrayList } from "uint8arraylist";
|
|
65
|
+
import { equals as bytesEqual } from "uint8arrays";
|
|
61
66
|
import { toUint8Array } from "./bytes.js";
|
|
62
67
|
import {
|
|
63
68
|
type DebouncedAccumulatorCounterMap,
|
|
@@ -107,7 +112,10 @@ const logErrorIfStarted = (e?: { message: string }) => {
|
|
|
107
112
|
e instanceof NotStartedError === false && logError(e);
|
|
108
113
|
};
|
|
109
114
|
|
|
110
|
-
const withAbort = async <T>(
|
|
115
|
+
const withAbort = async <T>(
|
|
116
|
+
promise: Promise<T>,
|
|
117
|
+
signal?: AbortSignal,
|
|
118
|
+
): Promise<T> => {
|
|
111
119
|
if (!signal) return promise;
|
|
112
120
|
if (signal.aborted) {
|
|
113
121
|
void promise.catch(() => {});
|
|
@@ -194,6 +202,35 @@ const DEFAULT_PUBSUB_SHARD_COUNT = 256;
|
|
|
194
202
|
const PUBSUB_SHARD_COUNT_HARD_CAP = 16_384;
|
|
195
203
|
const DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX = "/peerbit/pubsub-shard/1/";
|
|
196
204
|
const AUTO_TOPIC_ROOT_CANDIDATE_UPDATE_COOLDOWN_MS = 2_000;
|
|
205
|
+
const TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 = "/peerbit/topic-control-plane/2.1.0";
|
|
206
|
+
const TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 = "/peerbit/topic-control-plane/2.0.0";
|
|
207
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN = utf8Encoder.encode(
|
|
208
|
+
"peerbit/topic-root-candidate-claim/v1\0",
|
|
209
|
+
);
|
|
210
|
+
const TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN =
|
|
211
|
+
"peerbit/topic-root-candidate-scope/v1";
|
|
212
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS = 90_000;
|
|
213
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS = 30_000;
|
|
214
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS =
|
|
215
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS +
|
|
216
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS;
|
|
217
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS = 30_000;
|
|
218
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS = 5_000;
|
|
219
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS = 1_000;
|
|
220
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS = 10_000;
|
|
221
|
+
// A sparse leaf can receive a 64-origin snapshot plus two refresh waves through
|
|
222
|
+
// one relay in the first minute. Keep per-relay headroom for that honest burst;
|
|
223
|
+
// the separate global cap still bounds total signature work.
|
|
224
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST = 256;
|
|
225
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST = 512;
|
|
226
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS = 60_000;
|
|
227
|
+
const TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX = 4_096;
|
|
228
|
+
type TopicRootCandidateClaimRecord = {
|
|
229
|
+
bytes: Uint8Array;
|
|
230
|
+
timestamp: bigint;
|
|
231
|
+
expires: bigint;
|
|
232
|
+
acceptUntil: number;
|
|
233
|
+
};
|
|
197
234
|
const sameCandidates = (left: string[], right: string[]) =>
|
|
198
235
|
left.length === right.length &&
|
|
199
236
|
left.every((candidate, index) => candidate === right[index]);
|
|
@@ -378,8 +415,12 @@ export class TopicControlPlane
|
|
|
378
415
|
|
|
379
416
|
private readonly shardCount: number;
|
|
380
417
|
private readonly shardTopicPrefix: string;
|
|
418
|
+
private readonly topicRootCandidateClaimData: Uint8Array;
|
|
381
419
|
private readonly hostShards: boolean;
|
|
382
|
-
private readonly shardRootCache = new Map<
|
|
420
|
+
private readonly shardRootCache = new Map<
|
|
421
|
+
string,
|
|
422
|
+
{ root: string; authoritative: boolean }
|
|
423
|
+
>();
|
|
383
424
|
private readonly shardTopicCache = new Map<string, string>();
|
|
384
425
|
private readonly shardRefCounts = new Map<string, number>();
|
|
385
426
|
private readonly pinnedShards = new Set<string>();
|
|
@@ -413,10 +454,31 @@ export class TopicControlPlane
|
|
|
413
454
|
private topicRootCandidateResolutionAbortController = new AbortController();
|
|
414
455
|
private hostOwnedShardRootsInFlight?: Promise<void>;
|
|
415
456
|
private hostOwnedShardRootsDirty = false;
|
|
416
|
-
private
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
457
|
+
private readonly signedTopicRootCandidateClaims = new Map<
|
|
458
|
+
string,
|
|
459
|
+
TopicRootCandidateClaimRecord
|
|
460
|
+
>();
|
|
461
|
+
// Active claims expire, but their per-origin timestamp floors survive for the
|
|
462
|
+
// auto-mode lifecycle so a frozen wall clock cannot renew a replayed lease.
|
|
463
|
+
// Retaining only the deterministic lowest origins keeps this state hard-bounded.
|
|
464
|
+
private readonly topicRootCandidateClaimReplayFloors = new Map<
|
|
465
|
+
string,
|
|
466
|
+
bigint
|
|
467
|
+
>();
|
|
468
|
+
private localSignedTopicRootCandidateClaim?: TopicRootCandidateClaimRecord;
|
|
469
|
+
private topicRootCandidateClaimMaintenanceTimer?: ReturnType<
|
|
470
|
+
typeof setTimeout
|
|
471
|
+
>;
|
|
472
|
+
private topicRootCandidateClaimRefreshNotBefore?: number;
|
|
473
|
+
private topicRootCandidateClaimMaintenanceRefreshInFlight?: Promise<void>;
|
|
474
|
+
private readonly topicRootCandidateClaimVerifyBudgets = new Map<
|
|
475
|
+
string,
|
|
476
|
+
{ remaining: number; refilledAt: number }
|
|
477
|
+
>();
|
|
478
|
+
private topicRootCandidateClaimGlobalVerifyBudget = {
|
|
479
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
|
|
480
|
+
refilledAt: Date.now(),
|
|
481
|
+
};
|
|
420
482
|
private _onFanoutPeerUnreachable?: (
|
|
421
483
|
ev: CustomEvent<{ topic: string; root: string; publicKeyHash: string }>,
|
|
422
484
|
) => void;
|
|
@@ -459,7 +521,11 @@ export class TopicControlPlane
|
|
|
459
521
|
components: TopicControlPlaneComponents,
|
|
460
522
|
props?: TopicControlPlaneOptions,
|
|
461
523
|
) {
|
|
462
|
-
super(
|
|
524
|
+
super(
|
|
525
|
+
components,
|
|
526
|
+
[TOPIC_CONTROL_PLANE_PROTOCOL_V2_1, TOPIC_CONTROL_PLANE_PROTOCOL_V2_0],
|
|
527
|
+
props,
|
|
528
|
+
);
|
|
463
529
|
this.subscriptions = new Map();
|
|
464
530
|
this.pendingSubscriptions = new Set();
|
|
465
531
|
this.topics = new Map();
|
|
@@ -484,8 +550,8 @@ export class TopicControlPlane
|
|
|
484
550
|
this.fanout = props.fanout;
|
|
485
551
|
|
|
486
552
|
// Default to a local-only shard-root candidate set so standalone peers can
|
|
487
|
-
// subscribe/publish without explicit bootstraps.
|
|
488
|
-
// opportunistically as
|
|
553
|
+
// subscribe/publish without explicit bootstraps. Signed peer claims expand
|
|
554
|
+
// the set opportunistically as the ad-hoc overlay connects.
|
|
489
555
|
if (this.topicRootControlPlane.getTopicRootCandidates().length === 0) {
|
|
490
556
|
this.autoTopicRootCandidates = true;
|
|
491
557
|
this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
|
|
@@ -499,6 +565,23 @@ export class TopicControlPlane
|
|
|
499
565
|
);
|
|
500
566
|
const prefix = props?.shardTopicPrefix ?? DEFAULT_PUBSUB_SHARD_TOPIC_PREFIX;
|
|
501
567
|
this.shardTopicPrefix = prefix.endsWith("/") ? prefix : prefix + "/";
|
|
568
|
+
const scopeDigest = sha256Sync(
|
|
569
|
+
utf8Encoder.encode(
|
|
570
|
+
JSON.stringify([
|
|
571
|
+
TOPIC_ROOT_CANDIDATE_SCOPE_DOMAIN,
|
|
572
|
+
this.shardTopicPrefix,
|
|
573
|
+
this.shardCount,
|
|
574
|
+
]),
|
|
575
|
+
),
|
|
576
|
+
);
|
|
577
|
+
this.topicRootCandidateClaimData = new Uint8Array(
|
|
578
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength + scopeDigest.byteLength,
|
|
579
|
+
);
|
|
580
|
+
this.topicRootCandidateClaimData.set(TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN, 0);
|
|
581
|
+
this.topicRootCandidateClaimData.set(
|
|
582
|
+
scopeDigest,
|
|
583
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_DOMAIN.byteLength,
|
|
584
|
+
);
|
|
502
585
|
this.hostShards = props?.hostShards ?? false;
|
|
503
586
|
|
|
504
587
|
const baseFanoutChannelOptions = {
|
|
@@ -583,25 +666,21 @@ export class TopicControlPlane
|
|
|
583
666
|
* candidate mode.
|
|
584
667
|
*
|
|
585
668
|
* Auto mode is a convenience for small ad-hoc networks where no bootstraps/
|
|
586
|
-
* routers are configured.
|
|
587
|
-
*
|
|
669
|
+
* routers are configured. Automatic claims authenticate key ownership and
|
|
670
|
+
* freshness, not liveness, admission, or Sybil resistance; hostile or isolated
|
|
671
|
+
* deployments should configure explicit candidates. When an explicit candidate
|
|
672
|
+
* set is provided (e.g. from bootstraps or a test harness), we stop mutating the
|
|
588
673
|
* candidate set; otherwise shard root resolution can diverge and overlays can
|
|
589
674
|
* partition (especially in sparse graphs).
|
|
590
675
|
*/
|
|
591
676
|
public setTopicRootCandidates(candidates: string[]) {
|
|
592
677
|
this.topicRootControlPlane.setTopicRootCandidates(candidates);
|
|
593
678
|
|
|
594
|
-
// Disable auto mode and
|
|
679
|
+
// Disable auto mode and clear its signed-claim state/timers.
|
|
595
680
|
this.autoTopicRootCandidates = false;
|
|
596
681
|
this.autoTopicRootCandidateSet = undefined;
|
|
682
|
+
this.clearSignedTopicRootCandidateState();
|
|
597
683
|
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
598
|
-
for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
|
|
599
|
-
this.autoCandidatesBroadcastTimers = [];
|
|
600
|
-
if (this.autoCandidatesGossipInterval) {
|
|
601
|
-
clearInterval(this.autoCandidatesGossipInterval);
|
|
602
|
-
this.autoCandidatesGossipInterval = undefined;
|
|
603
|
-
}
|
|
604
|
-
this.autoCandidatesGossipUntil = 0;
|
|
605
684
|
|
|
606
685
|
// Re-resolve roots under the new mapping.
|
|
607
686
|
this.shardRootCache.clear();
|
|
@@ -614,19 +693,42 @@ export class TopicControlPlane
|
|
|
614
693
|
}
|
|
615
694
|
|
|
616
695
|
public override async start() {
|
|
696
|
+
// Match DirectStream's idempotent start guard before mutating automatic
|
|
697
|
+
// candidates. In particular, a repeated start must not restore unsigned
|
|
698
|
+
// self while a signed-protocol peer is connected.
|
|
699
|
+
if (this.started) return;
|
|
617
700
|
this.topicControlPlaneStopping = false;
|
|
701
|
+
if (
|
|
702
|
+
this.autoTopicRootCandidates &&
|
|
703
|
+
!this.localSignedTopicRootCandidateClaim
|
|
704
|
+
) {
|
|
705
|
+
this.autoTopicRootCandidateSet = new Set([this.publicKeyHash]);
|
|
706
|
+
this.topicRootControlPlane.setTopicRootCandidates([this.publicKeyHash]);
|
|
707
|
+
this.shardRootCache.clear();
|
|
708
|
+
}
|
|
618
709
|
if (this.topicRootResolutionAbortController.signal.aborted) {
|
|
619
710
|
this.topicRootResolutionAbortController = new AbortController();
|
|
620
711
|
}
|
|
621
712
|
await this.fanout.start();
|
|
622
713
|
this._onFanoutPeerUnreachable =
|
|
623
|
-
this._onFanoutPeerUnreachable ||
|
|
624
|
-
this.onFanoutPeerUnreachable.bind(this);
|
|
714
|
+
this._onFanoutPeerUnreachable || this.onFanoutPeerUnreachable.bind(this);
|
|
625
715
|
await this.fanout.addEventListener(
|
|
626
716
|
"fanout:peer-unreachable",
|
|
627
717
|
this._onFanoutPeerUnreachable as any,
|
|
628
718
|
);
|
|
629
719
|
await super.start();
|
|
720
|
+
if (this.autoTopicRootCandidates) {
|
|
721
|
+
let retryDelayMs = 0;
|
|
722
|
+
try {
|
|
723
|
+
if (!(await this.refreshLocalTopicRootCandidateClaim())) {
|
|
724
|
+
retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
725
|
+
}
|
|
726
|
+
} catch (error: any) {
|
|
727
|
+
logErrorIfStarted(error);
|
|
728
|
+
retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
729
|
+
}
|
|
730
|
+
this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
|
|
731
|
+
}
|
|
630
732
|
|
|
631
733
|
if (this.hostShards) {
|
|
632
734
|
await this.hostShardRootsNow();
|
|
@@ -645,6 +747,7 @@ export class TopicControlPlane
|
|
|
645
747
|
new AbortError("topic control plane stopped"),
|
|
646
748
|
);
|
|
647
749
|
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
750
|
+
this.clearSignedTopicRootCandidateState();
|
|
648
751
|
this.topicControlPlaneLifecycleRevision += 1;
|
|
649
752
|
this.reconcileShardOverlaysDirty = false;
|
|
650
753
|
for (const opening of this.ensureFanoutChannelInFlight.values()) {
|
|
@@ -683,13 +786,6 @@ export class TopicControlPlane
|
|
|
683
786
|
}
|
|
684
787
|
}
|
|
685
788
|
this.fanoutChannels.clear();
|
|
686
|
-
for (const t of this.autoCandidatesBroadcastTimers) clearTimeout(t);
|
|
687
|
-
this.autoCandidatesBroadcastTimers = [];
|
|
688
|
-
if (this.autoCandidatesGossipInterval) {
|
|
689
|
-
clearInterval(this.autoCandidatesGossipInterval);
|
|
690
|
-
this.autoCandidatesGossipInterval = undefined;
|
|
691
|
-
}
|
|
692
|
-
this.autoCandidatesGossipUntil = 0;
|
|
693
789
|
this.hostOwnedShardRootsDirty = false;
|
|
694
790
|
|
|
695
791
|
this.subscriptions.clear();
|
|
@@ -717,25 +813,8 @@ export class TopicControlPlane
|
|
|
717
813
|
this.reconcileShardOverlaysDirty = false;
|
|
718
814
|
}
|
|
719
815
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
connection: Connection,
|
|
723
|
-
) {
|
|
724
|
-
await super.onPeerConnected(peerId, connection);
|
|
725
|
-
|
|
726
|
-
// If we're in auto-candidate mode, expand the deterministic shard-root
|
|
727
|
-
// candidate set as neighbours connect, then reconcile shard overlays and
|
|
728
|
-
// re-announce subscriptions so membership knowledge converges.
|
|
729
|
-
if (!this.autoTopicRootCandidates) return;
|
|
730
|
-
let peerHash: string;
|
|
731
|
-
try {
|
|
732
|
-
peerHash = getPublicKeyFromPeerId(peerId).hashcode();
|
|
733
|
-
} catch {
|
|
734
|
-
return;
|
|
735
|
-
}
|
|
736
|
-
void this.maybeUpdateAutoTopicRootCandidates(peerHash);
|
|
737
|
-
}
|
|
738
|
-
|
|
816
|
+
// Candidate trust starts only after protocol negotiation in `addPeer()`;
|
|
817
|
+
// a transport connection alone cannot bypass signed 2.1 self-claims.
|
|
739
818
|
// Ensure auto-candidate mode converges even when libp2p topology callbacks
|
|
740
819
|
// are delayed or only fire for one side of a connection. `addPeer()` runs for
|
|
741
820
|
// both inbound + outbound protocol streams once the remote public key is known.
|
|
@@ -745,14 +824,70 @@ export class TopicControlPlane
|
|
|
745
824
|
protocol: string,
|
|
746
825
|
connId: string,
|
|
747
826
|
): PeerStreams {
|
|
827
|
+
const hadSignedPeer = [...this.peers.values()].some(
|
|
828
|
+
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
829
|
+
);
|
|
830
|
+
const existingPeer = this.peers.get(publicKey.hashcode());
|
|
748
831
|
const peer = super.addPeer(peerId, publicKey, protocol, connId);
|
|
749
832
|
if (this.autoTopicRootCandidates) {
|
|
750
|
-
|
|
751
|
-
|
|
833
|
+
const hasSignedPeer = [...this.peers.values()].some(
|
|
834
|
+
(candidate) => candidate.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
835
|
+
);
|
|
836
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
837
|
+
immediate: hadSignedPeer !== hasSignedPeer,
|
|
838
|
+
});
|
|
839
|
+
if (peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1) {
|
|
840
|
+
const sendWhenOutboundReady = () => {
|
|
841
|
+
if (
|
|
842
|
+
!this.autoTopicRootCandidates ||
|
|
843
|
+
this.peers.get(publicKey.hashcode()) !== peer
|
|
844
|
+
) {
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
void this.sendAutoTopicRootCandidates([peer]).catch(() => {});
|
|
848
|
+
};
|
|
849
|
+
if (peer.isWritable) {
|
|
850
|
+
void this.sendAutoTopicRootCandidates([peer]).catch(() => {});
|
|
851
|
+
}
|
|
852
|
+
if (!existingPeer) {
|
|
853
|
+
peer.addEventListener("stream:outbound", sendWhenOutboundReady);
|
|
854
|
+
peer.addEventListener(
|
|
855
|
+
"close",
|
|
856
|
+
() =>
|
|
857
|
+
peer.removeEventListener(
|
|
858
|
+
"stream:outbound",
|
|
859
|
+
sendWhenOutboundReady,
|
|
860
|
+
),
|
|
861
|
+
{ once: true },
|
|
862
|
+
);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
752
865
|
}
|
|
753
866
|
return peer;
|
|
754
867
|
}
|
|
755
868
|
|
|
869
|
+
protected override async _removePeer(publicKey: PublicSignKey) {
|
|
870
|
+
const hash = publicKey.hashcode();
|
|
871
|
+
const protocol = this.peers.get(hash)?.protocol;
|
|
872
|
+
const hadSignedPeer = [...this.peers.values()].some(
|
|
873
|
+
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
874
|
+
);
|
|
875
|
+
const removed = await super._removePeer(publicKey);
|
|
876
|
+
if (
|
|
877
|
+
this.autoTopicRootCandidates &&
|
|
878
|
+
(protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0 ||
|
|
879
|
+
protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1)
|
|
880
|
+
) {
|
|
881
|
+
const hasSignedPeer = [...this.peers.values()].some(
|
|
882
|
+
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
883
|
+
);
|
|
884
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(BigInt(Date.now()), {
|
|
885
|
+
immediate: hadSignedPeer !== hasSignedPeer,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
return removed;
|
|
889
|
+
}
|
|
890
|
+
|
|
756
891
|
private maybeDisableAutoTopicRootCandidatesIfExternallyConfigured(): boolean {
|
|
757
892
|
if (!this.autoTopicRootCandidates) return false;
|
|
758
893
|
|
|
@@ -768,6 +903,7 @@ export class TopicControlPlane
|
|
|
768
903
|
// intact and reconcile shard overlays under the new mapping.
|
|
769
904
|
this.autoTopicRootCandidates = false;
|
|
770
905
|
this.autoTopicRootCandidateSet = undefined;
|
|
906
|
+
this.clearSignedTopicRootCandidateState();
|
|
771
907
|
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
772
908
|
this.shardRootCache.clear();
|
|
773
909
|
|
|
@@ -780,129 +916,629 @@ export class TopicControlPlane
|
|
|
780
916
|
return true;
|
|
781
917
|
}
|
|
782
918
|
|
|
783
|
-
private
|
|
784
|
-
if (
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
return;
|
|
919
|
+
private clearTopicRootCandidateClaimTimer() {
|
|
920
|
+
if (this.topicRootCandidateClaimMaintenanceTimer) {
|
|
921
|
+
clearTimeout(this.topicRootCandidateClaimMaintenanceTimer);
|
|
922
|
+
this.topicRootCandidateClaimMaintenanceTimer = undefined;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
790
925
|
|
|
791
|
-
|
|
926
|
+
private clearSignedTopicRootCandidateState() {
|
|
927
|
+
this.clearTopicRootCandidateClaimTimer();
|
|
928
|
+
this.signedTopicRootCandidateClaims.clear();
|
|
929
|
+
this.topicRootCandidateClaimReplayFloors.clear();
|
|
930
|
+
this.localSignedTopicRootCandidateClaim = undefined;
|
|
931
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
932
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
|
|
933
|
+
this.topicRootCandidateClaimVerifyBudgets.clear();
|
|
934
|
+
this.topicRootCandidateClaimGlobalVerifyBudget = {
|
|
935
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
|
|
936
|
+
refilledAt: Date.now(),
|
|
937
|
+
};
|
|
792
938
|
}
|
|
793
939
|
|
|
794
|
-
private
|
|
795
|
-
|
|
796
|
-
|
|
940
|
+
private isTopicRootCandidateClaimTimeValid(
|
|
941
|
+
timestamp: bigint,
|
|
942
|
+
expires: bigint,
|
|
943
|
+
now: bigint,
|
|
944
|
+
): boolean {
|
|
945
|
+
return (
|
|
946
|
+
expires > now &&
|
|
947
|
+
timestamp <= now + BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_FUTURE_SKEW_MS) &&
|
|
948
|
+
expires - timestamp === BigInt(TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS)
|
|
797
949
|
);
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
private topicRootCandidateClaimAcceptanceDeadline(
|
|
953
|
+
timestamp: bigint,
|
|
954
|
+
expires: bigint,
|
|
955
|
+
wallNow: bigint,
|
|
956
|
+
monotonicNow: number,
|
|
957
|
+
maxRemainingMs: number,
|
|
958
|
+
): number | undefined {
|
|
959
|
+
if (!this.isTopicRootCandidateClaimTimeValid(timestamp, expires, wallNow)) {
|
|
960
|
+
return undefined;
|
|
961
|
+
}
|
|
962
|
+
return (
|
|
963
|
+
monotonicNow +
|
|
964
|
+
Math.min(Number(expires - wallNow), Math.max(0, maxRemainingMs))
|
|
965
|
+
);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
private async refreshLocalTopicRootCandidateClaim(): Promise<boolean> {
|
|
969
|
+
if (
|
|
970
|
+
!this.autoTopicRootCandidates ||
|
|
971
|
+
!this.started ||
|
|
972
|
+
this.stopping ||
|
|
973
|
+
this.topicControlPlaneStopping
|
|
974
|
+
) {
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
if (!this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
981
|
+
const claim = await this.createMessage(this.topicRootCandidateClaimData, {
|
|
982
|
+
mode: new AnyWhere(),
|
|
983
|
+
priority: 1,
|
|
984
|
+
expiresInMs: TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS,
|
|
985
|
+
skipRecipientValidation: true,
|
|
986
|
+
} as any);
|
|
987
|
+
const bytes = toUint8Array(claim.bytes());
|
|
988
|
+
if (bytes.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES) {
|
|
989
|
+
throw new Error(
|
|
990
|
+
`Local topic-root candidate claim exceeds ${TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES} bytes`,
|
|
802
991
|
);
|
|
803
992
|
}
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
993
|
+
if (
|
|
994
|
+
lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
|
|
995
|
+
!this.autoTopicRootCandidates ||
|
|
996
|
+
!this.started ||
|
|
997
|
+
this.stopping ||
|
|
998
|
+
this.topicControlPlaneStopping
|
|
999
|
+
) {
|
|
1000
|
+
return false;
|
|
807
1001
|
}
|
|
808
|
-
|
|
809
|
-
|
|
1002
|
+
const timestamp = claim.header.timestamp;
|
|
1003
|
+
const expires = claim.header.expires;
|
|
1004
|
+
const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(
|
|
1005
|
+
timestamp,
|
|
1006
|
+
expires,
|
|
1007
|
+
BigInt(Date.now()),
|
|
1008
|
+
performance.now(),
|
|
1009
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_LIFETIME_MS,
|
|
1010
|
+
);
|
|
1011
|
+
if (acceptanceDeadline === undefined) {
|
|
1012
|
+
return false;
|
|
1013
|
+
}
|
|
1014
|
+
const record = {
|
|
1015
|
+
bytes,
|
|
1016
|
+
timestamp,
|
|
1017
|
+
expires,
|
|
1018
|
+
acceptUntil: acceptanceDeadline,
|
|
1019
|
+
};
|
|
1020
|
+
const current = this.localSignedTopicRootCandidateClaim;
|
|
1021
|
+
if (current && record.timestamp <= current.timestamp) {
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
if (!this.retainSignedTopicRootCandidateClaim(this.publicKeyHash, record)) {
|
|
1025
|
+
return false;
|
|
810
1026
|
}
|
|
811
|
-
|
|
812
|
-
|
|
1027
|
+
this.localSignedTopicRootCandidateClaim = record;
|
|
1028
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
1029
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
1030
|
+
return this.localSignedTopicRootCandidateClaim === record;
|
|
813
1031
|
}
|
|
814
1032
|
|
|
815
|
-
private
|
|
816
|
-
if (
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1033
|
+
private scheduleTopicRootCandidateClaimMaintenance(minDelayMs = 0) {
|
|
1034
|
+
if (
|
|
1035
|
+
!this.autoTopicRootCandidates ||
|
|
1036
|
+
!this.started ||
|
|
1037
|
+
this.stopping ||
|
|
1038
|
+
this.topicControlPlaneStopping
|
|
1039
|
+
) {
|
|
821
1040
|
return;
|
|
822
1041
|
}
|
|
1042
|
+
this.clearTopicRootCandidateClaimTimer();
|
|
1043
|
+
const now = Date.now();
|
|
1044
|
+
const monotonicNow = performance.now();
|
|
1045
|
+
const local = this.localSignedTopicRootCandidateClaim;
|
|
1046
|
+
const localOriginEligible = this.canRetainTopicRootCandidateClaimOrigin(
|
|
1047
|
+
this.publicKeyHash,
|
|
1048
|
+
);
|
|
1049
|
+
if (localOriginEligible && minDelayMs > 0) {
|
|
1050
|
+
this.topicRootCandidateClaimRefreshNotBefore = Math.max(
|
|
1051
|
+
this.topicRootCandidateClaimRefreshNotBefore ?? 0,
|
|
1052
|
+
monotonicNow + minDelayMs,
|
|
1053
|
+
);
|
|
1054
|
+
} else if (!localOriginEligible) {
|
|
1055
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
1056
|
+
}
|
|
1057
|
+
const canScheduleLocalRefresh =
|
|
1058
|
+
localOriginEligible &&
|
|
1059
|
+
!this.topicRootCandidateClaimMaintenanceRefreshInFlight;
|
|
1060
|
+
const refreshDueAt = local
|
|
1061
|
+
? Number(local.timestamp) +
|
|
1062
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS +
|
|
1063
|
+
Math.floor(Math.random() * TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_JITTER_MS)
|
|
1064
|
+
: canScheduleLocalRefresh
|
|
1065
|
+
? now
|
|
1066
|
+
: Number.POSITIVE_INFINITY;
|
|
1067
|
+
let expiryDelayMs = Number.POSITIVE_INFINITY;
|
|
1068
|
+
for (const claim of this.signedTopicRootCandidateClaims.values()) {
|
|
1069
|
+
expiryDelayMs = Math.min(
|
|
1070
|
+
expiryDelayMs,
|
|
1071
|
+
Number(claim.expires) - now,
|
|
1072
|
+
claim.acceptUntil - monotonicNow,
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
if (local) {
|
|
1076
|
+
expiryDelayMs = Math.min(
|
|
1077
|
+
expiryDelayMs,
|
|
1078
|
+
Number(local.expires) - now,
|
|
1079
|
+
local.acceptUntil - monotonicNow,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
const refreshDelayMs = canScheduleLocalRefresh
|
|
1083
|
+
? Math.max(
|
|
1084
|
+
refreshDueAt - now,
|
|
1085
|
+
(this.topicRootCandidateClaimRefreshNotBefore ?? monotonicNow) -
|
|
1086
|
+
monotonicNow,
|
|
1087
|
+
0,
|
|
1088
|
+
)
|
|
1089
|
+
: Number.POSITIVE_INFINITY;
|
|
1090
|
+
expiryDelayMs = Math.max(Math.ceil(expiryDelayMs), 0);
|
|
1091
|
+
const delayMs = Math.min(2_147_483_647, refreshDelayMs, expiryDelayMs);
|
|
1092
|
+
const timer = setTimeout(() => {
|
|
1093
|
+
if (this.topicRootCandidateClaimMaintenanceTimer !== timer) return;
|
|
1094
|
+
this.topicRootCandidateClaimMaintenanceTimer = undefined;
|
|
1095
|
+
let retryDelayMs = 0;
|
|
1096
|
+
try {
|
|
1097
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
1098
|
+
const currentLocal = this.localSignedTopicRootCandidateClaim;
|
|
1099
|
+
const refreshDue =
|
|
1100
|
+
!this.topicRootCandidateClaimMaintenanceRefreshInFlight &&
|
|
1101
|
+
this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash) &&
|
|
1102
|
+
performance.now() >=
|
|
1103
|
+
(this.topicRootCandidateClaimRefreshNotBefore ?? 0) &&
|
|
1104
|
+
(!currentLocal ||
|
|
1105
|
+
Date.now() - Number(currentLocal.timestamp) >=
|
|
1106
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_MS);
|
|
1107
|
+
if (refreshDue) {
|
|
1108
|
+
this.startTopicRootCandidateClaimMaintenanceRefresh();
|
|
1109
|
+
}
|
|
1110
|
+
} catch (error: any) {
|
|
1111
|
+
logErrorIfStarted(error);
|
|
1112
|
+
retryDelayMs = TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
1113
|
+
}
|
|
1114
|
+
if (!this.topicRootCandidateClaimMaintenanceTimer) {
|
|
1115
|
+
// Re-arm immediately. While refresh is in flight, its due time is
|
|
1116
|
+
// treated as infinity but independent claim expiries remain scheduled.
|
|
1117
|
+
this.scheduleTopicRootCandidateClaimMaintenance(retryDelayMs);
|
|
1118
|
+
}
|
|
1119
|
+
}, delayMs);
|
|
1120
|
+
timer.unref?.();
|
|
1121
|
+
this.topicRootCandidateClaimMaintenanceTimer = timer;
|
|
1122
|
+
}
|
|
823
1123
|
|
|
824
|
-
|
|
825
|
-
this.
|
|
1124
|
+
private startTopicRootCandidateClaimMaintenanceRefresh() {
|
|
1125
|
+
if (this.topicRootCandidateClaimMaintenanceRefreshInFlight) return;
|
|
1126
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1127
|
+
let task!: Promise<void>;
|
|
1128
|
+
task = (async () => {
|
|
1129
|
+
try {
|
|
1130
|
+
const refreshed = await this.refreshLocalTopicRootCandidateClaim();
|
|
1131
|
+
if (
|
|
1132
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight !== task ||
|
|
1133
|
+
lifecycleRevision !== this.topicControlPlaneLifecycleRevision
|
|
1134
|
+
) {
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
const local = this.localSignedTopicRootCandidateClaim;
|
|
1138
|
+
if (!refreshed || !local) {
|
|
1139
|
+
if (this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)) {
|
|
1140
|
+
this.topicRootCandidateClaimRefreshNotBefore =
|
|
1141
|
+
performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS;
|
|
1142
|
+
} else {
|
|
1143
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
1144
|
+
}
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
this.topicRootCandidateClaimRefreshNotBefore = undefined;
|
|
1148
|
+
// Arm the new local lease's expiry before outbound I/O can stall.
|
|
1149
|
+
this.scheduleTopicRootCandidateClaimMaintenance();
|
|
1150
|
+
try {
|
|
1151
|
+
await this.sendSignedTopicRootCandidateClaims(
|
|
1152
|
+
[local.bytes],
|
|
1153
|
+
undefined,
|
|
1154
|
+
AbortSignal.timeout(
|
|
1155
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_ADVERTISEMENT_TIMEOUT_MS,
|
|
1156
|
+
),
|
|
1157
|
+
);
|
|
1158
|
+
} catch (error: any) {
|
|
1159
|
+
// A later renewal or outbound-stream snapshot retries this
|
|
1160
|
+
// best-effort advertisement; the signed lease itself is valid.
|
|
1161
|
+
logErrorIfStarted(error);
|
|
1162
|
+
}
|
|
1163
|
+
} catch (error: any) {
|
|
1164
|
+
if (
|
|
1165
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
|
|
1166
|
+
lifecycleRevision === this.topicControlPlaneLifecycleRevision
|
|
1167
|
+
) {
|
|
1168
|
+
logErrorIfStarted(error);
|
|
1169
|
+
this.topicRootCandidateClaimRefreshNotBefore =
|
|
1170
|
+
this.canRetainTopicRootCandidateClaimOrigin(this.publicKeyHash)
|
|
1171
|
+
? performance.now() + TOPIC_ROOT_CANDIDATE_CLAIM_REFRESH_RETRY_MS
|
|
1172
|
+
: undefined;
|
|
1173
|
+
}
|
|
1174
|
+
} finally {
|
|
1175
|
+
if (
|
|
1176
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight === task &&
|
|
1177
|
+
lifecycleRevision === this.topicControlPlaneLifecycleRevision
|
|
1178
|
+
) {
|
|
1179
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight = undefined;
|
|
1180
|
+
this.scheduleTopicRootCandidateClaimMaintenance();
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
})();
|
|
1184
|
+
this.topicRootCandidateClaimMaintenanceRefreshInFlight = task;
|
|
1185
|
+
}
|
|
826
1186
|
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
1187
|
+
private pruneExpiredTopicRootCandidateClaims(
|
|
1188
|
+
now: bigint = BigInt(Date.now()),
|
|
1189
|
+
monotonicNow: number = performance.now(),
|
|
1190
|
+
) {
|
|
1191
|
+
const localClaim = this.localSignedTopicRootCandidateClaim;
|
|
1192
|
+
if (
|
|
1193
|
+
localClaim &&
|
|
1194
|
+
(localClaim.expires <= now || localClaim.acceptUntil <= monotonicNow)
|
|
1195
|
+
) {
|
|
1196
|
+
this.localSignedTopicRootCandidateClaim = undefined;
|
|
1197
|
+
}
|
|
1198
|
+
let changed = false;
|
|
1199
|
+
for (const [origin, claim] of this.signedTopicRootCandidateClaims) {
|
|
1200
|
+
if (claim.expires <= now || claim.acceptUntil <= monotonicNow) {
|
|
1201
|
+
this.signedTopicRootCandidateClaims.delete(origin);
|
|
1202
|
+
changed = true;
|
|
1203
|
+
}
|
|
835
1204
|
}
|
|
1205
|
+
return changed;
|
|
1206
|
+
}
|
|
836
1207
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
1208
|
+
private rebuildAutoTopicRootCandidatesFromClaims(
|
|
1209
|
+
now: bigint = BigInt(Date.now()),
|
|
1210
|
+
options?: { immediate?: boolean },
|
|
1211
|
+
): boolean {
|
|
1212
|
+
if (!this.autoTopicRootCandidates) return false;
|
|
1213
|
+
const removedExpired = this.pruneExpiredTopicRootCandidateClaims(now);
|
|
1214
|
+
const legacyDirectCandidates = [...this.peers.values()]
|
|
1215
|
+
.filter((peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_0)
|
|
1216
|
+
.map((peer) => peer.publicKey.hashcode());
|
|
1217
|
+
const hasSignedPeer = [...this.peers.values()].some(
|
|
1218
|
+
(peer) => peer.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
1219
|
+
);
|
|
1220
|
+
const next = [
|
|
1221
|
+
...new Set([
|
|
1222
|
+
...(this.localSignedTopicRootCandidateClaim || hasSignedPeer
|
|
1223
|
+
? []
|
|
1224
|
+
: [this.publicKeyHash]),
|
|
1225
|
+
...legacyDirectCandidates,
|
|
1226
|
+
...this.signedTopicRootCandidateClaims.keys(),
|
|
1227
|
+
]),
|
|
1228
|
+
]
|
|
1229
|
+
.filter(isCanonicalTopicRootCandidate)
|
|
1230
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
1231
|
+
.slice(0, TOPIC_ROOT_CANDIDATES_MAX);
|
|
1232
|
+
return this.setAutoTopicRootCandidateSnapshot(next, {
|
|
1233
|
+
// A cooldown may coalesce additions, but it must never extend an expired
|
|
1234
|
+
// candidate's effective lease. The full snapshot is authoritative, so any
|
|
1235
|
+
// queued pre-expiry snapshot can be discarded safely here.
|
|
1236
|
+
immediate: removedExpired || options?.immediate,
|
|
1237
|
+
});
|
|
841
1238
|
}
|
|
842
1239
|
|
|
843
|
-
private
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1240
|
+
private retainSignedTopicRootCandidateClaim(
|
|
1241
|
+
origin: string,
|
|
1242
|
+
claim: TopicRootCandidateClaimRecord,
|
|
1243
|
+
): boolean {
|
|
1244
|
+
if (
|
|
1245
|
+
!this.canAdvanceTopicRootCandidateClaimReplayFloor(
|
|
1246
|
+
origin,
|
|
1247
|
+
claim.timestamp,
|
|
1248
|
+
)
|
|
1249
|
+
) {
|
|
1250
|
+
return false;
|
|
1251
|
+
}
|
|
1252
|
+
const existing = this.signedTopicRootCandidateClaims.get(origin);
|
|
1253
|
+
if (existing) {
|
|
1254
|
+
if (claim.timestamp <= existing.timestamp) {
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (!this.topicRootCandidateClaimReplayFloors.has(origin)) {
|
|
850
1259
|
if (
|
|
851
|
-
this.
|
|
852
|
-
|
|
1260
|
+
this.topicRootCandidateClaimReplayFloors.size >=
|
|
1261
|
+
TOPIC_ROOT_CANDIDATES_MAX
|
|
853
1262
|
) {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1263
|
+
const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
|
|
1264
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
1265
|
+
.at(-1)!;
|
|
1266
|
+
// `canAdvance...` admitted a lower origin. Removing the displaced
|
|
1267
|
+
// origin's live/local state preserves the same lowest-origin invariant;
|
|
1268
|
+
// because the retained maximum only decreases, it cannot re-enter.
|
|
1269
|
+
this.topicRootCandidateClaimReplayFloors.delete(highest);
|
|
1270
|
+
this.signedTopicRootCandidateClaims.delete(highest);
|
|
1271
|
+
if (highest === this.publicKeyHash) {
|
|
1272
|
+
this.localSignedTopicRootCandidateClaim = undefined;
|
|
857
1273
|
}
|
|
858
|
-
return;
|
|
859
1274
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
this.
|
|
1275
|
+
}
|
|
1276
|
+
this.topicRootCandidateClaimReplayFloors.set(origin, claim.timestamp);
|
|
1277
|
+
this.signedTopicRootCandidateClaims.set(origin, claim);
|
|
1278
|
+
return true;
|
|
863
1279
|
}
|
|
864
1280
|
|
|
865
|
-
private
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1281
|
+
private canAdvanceTopicRootCandidateClaimReplayFloor(
|
|
1282
|
+
origin: string,
|
|
1283
|
+
timestamp: bigint,
|
|
1284
|
+
): boolean {
|
|
1285
|
+
const floor = this.topicRootCandidateClaimReplayFloors.get(origin);
|
|
1286
|
+
if (floor !== undefined) {
|
|
1287
|
+
return timestamp > floor;
|
|
1288
|
+
}
|
|
1289
|
+
return this.canRetainTopicRootCandidateClaimOrigin(origin);
|
|
1290
|
+
}
|
|
869
1291
|
|
|
870
|
-
|
|
871
|
-
if (
|
|
1292
|
+
private canRetainTopicRootCandidateClaimOrigin(origin: string): boolean {
|
|
1293
|
+
if (this.topicRootCandidateClaimReplayFloors.has(origin)) {
|
|
1294
|
+
return true;
|
|
1295
|
+
}
|
|
1296
|
+
if (
|
|
1297
|
+
this.topicRootCandidateClaimReplayFloors.size < TOPIC_ROOT_CANDIDATES_MAX
|
|
1298
|
+
) {
|
|
1299
|
+
return true;
|
|
1300
|
+
}
|
|
1301
|
+
const highest = [...this.topicRootCandidateClaimReplayFloors.keys()]
|
|
1302
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
|
|
1303
|
+
.at(-1)!;
|
|
1304
|
+
return origin < highest;
|
|
1305
|
+
}
|
|
872
1306
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1307
|
+
private consumeTopicRootCandidateClaimVerifyBudget(
|
|
1308
|
+
peerHash: string,
|
|
1309
|
+
): boolean {
|
|
1310
|
+
const now = Date.now();
|
|
1311
|
+
if (
|
|
1312
|
+
now - this.topicRootCandidateClaimGlobalVerifyBudget.refilledAt >=
|
|
1313
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS
|
|
1314
|
+
) {
|
|
1315
|
+
this.topicRootCandidateClaimGlobalVerifyBudget = {
|
|
1316
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_GLOBAL_VERIFY_BURST,
|
|
1317
|
+
refilledAt: now,
|
|
1318
|
+
};
|
|
1319
|
+
}
|
|
1320
|
+
if (this.topicRootCandidateClaimGlobalVerifyBudget.remaining <= 0) {
|
|
1321
|
+
return false;
|
|
1322
|
+
}
|
|
1323
|
+
let budget = this.topicRootCandidateClaimVerifyBudgets.get(peerHash);
|
|
1324
|
+
if (!budget) {
|
|
1325
|
+
if (
|
|
1326
|
+
this.topicRootCandidateClaimVerifyBudgets.size >=
|
|
1327
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_PEERS_MAX
|
|
1328
|
+
) {
|
|
1329
|
+
const oldest = this.topicRootCandidateClaimVerifyBudgets.keys().next()
|
|
1330
|
+
.value as string | undefined;
|
|
1331
|
+
if (oldest) this.topicRootCandidateClaimVerifyBudgets.delete(oldest);
|
|
1332
|
+
}
|
|
1333
|
+
budget = {
|
|
1334
|
+
remaining: TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST,
|
|
1335
|
+
refilledAt: now,
|
|
1336
|
+
};
|
|
1337
|
+
this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
|
|
1338
|
+
} else if (
|
|
1339
|
+
now - budget.refilledAt >=
|
|
1340
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_REFILL_MS
|
|
1341
|
+
) {
|
|
1342
|
+
budget.remaining = TOPIC_ROOT_CANDIDATE_CLAIM_VERIFY_BURST;
|
|
1343
|
+
budget.refilledAt = now;
|
|
1344
|
+
// Refresh insertion order so bounded eviction remains O(1) and LRU-like.
|
|
1345
|
+
this.topicRootCandidateClaimVerifyBudgets.delete(peerHash);
|
|
1346
|
+
this.topicRootCandidateClaimVerifyBudgets.set(peerHash, budget);
|
|
1347
|
+
}
|
|
1348
|
+
if (budget.remaining <= 0) return false;
|
|
1349
|
+
budget.remaining -= 1;
|
|
1350
|
+
this.topicRootCandidateClaimGlobalVerifyBudget.remaining -= 1;
|
|
1351
|
+
return true;
|
|
880
1352
|
}
|
|
881
1353
|
|
|
882
|
-
private
|
|
883
|
-
|
|
884
|
-
|
|
1354
|
+
private async importSignedTopicRootCandidateClaim(
|
|
1355
|
+
rawClaim: Uint8Array,
|
|
1356
|
+
outerPeerHash: string,
|
|
1357
|
+
): Promise<boolean> {
|
|
1358
|
+
const lifecycleRevision = this.topicControlPlaneLifecycleRevision;
|
|
1359
|
+
if (
|
|
1360
|
+
rawClaim.byteLength === 0 ||
|
|
1361
|
+
rawClaim.byteLength > TOPIC_ROOT_CANDIDATE_CLAIM_MAX_BYTES
|
|
1362
|
+
) {
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
let claim: DataMessage;
|
|
1366
|
+
try {
|
|
1367
|
+
claim = DataMessage.from(new Uint8ArrayList(rawClaim));
|
|
1368
|
+
} catch {
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
const canonicalBytes = toUint8Array(claim.bytes());
|
|
1372
|
+
if (!bytesEqual(canonicalBytes, rawClaim)) return false;
|
|
1373
|
+
if (
|
|
1374
|
+
!claim.data ||
|
|
1375
|
+
!bytesEqual(claim.data, this.topicRootCandidateClaimData)
|
|
1376
|
+
) {
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
const signatures = claim.header.signatures?.signatures;
|
|
1380
|
+
if (!signatures || signatures.length !== 1) return false;
|
|
1381
|
+
const signer = signatures[0]?.publicKey;
|
|
1382
|
+
if (!signer) return false;
|
|
1383
|
+
const origin = signer.hashcode();
|
|
1384
|
+
if (!isCanonicalTopicRootCandidate(origin)) return false;
|
|
1385
|
+
if (origin === this.publicKeyHash) return false;
|
|
1386
|
+
|
|
1387
|
+
const now = BigInt(Date.now());
|
|
1388
|
+
const monotonicNow = performance.now();
|
|
1389
|
+
const timestamp = claim.header.timestamp;
|
|
1390
|
+
const expires = claim.header.expires;
|
|
1391
|
+
const acceptanceDeadline = this.topicRootCandidateClaimAcceptanceDeadline(
|
|
1392
|
+
timestamp,
|
|
1393
|
+
expires,
|
|
1394
|
+
now,
|
|
1395
|
+
monotonicNow,
|
|
1396
|
+
TOPIC_ROOT_CANDIDATE_CLAIM_RECEIVER_MAX_LIFETIME_MS,
|
|
1397
|
+
);
|
|
1398
|
+
if (acceptanceDeadline === undefined) {
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
if (!this.canAdvanceTopicRootCandidateClaimReplayFloor(origin, timestamp)) {
|
|
1402
|
+
return false;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
this.rebuildAutoTopicRootCandidatesFromClaims(now);
|
|
1406
|
+
if (!this.consumeTopicRootCandidateClaimVerifyBudget(outerPeerHash)) {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
this.seedNativeWireVerification(claim, rawClaim);
|
|
1410
|
+
let verified = false;
|
|
1411
|
+
try {
|
|
1412
|
+
verified = await claim.verify(true);
|
|
1413
|
+
} catch {
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
if (!verified) return false;
|
|
1417
|
+
if (
|
|
1418
|
+
lifecycleRevision !== this.topicControlPlaneLifecycleRevision ||
|
|
1419
|
+
!this.isTopicRootCandidateClaimTimeValid(
|
|
1420
|
+
timestamp,
|
|
1421
|
+
expires,
|
|
1422
|
+
BigInt(Date.now()),
|
|
1423
|
+
) ||
|
|
1424
|
+
performance.now() >= acceptanceDeadline ||
|
|
1425
|
+
!this.autoTopicRootCandidates ||
|
|
1426
|
+
!this.started ||
|
|
1427
|
+
this.stopping ||
|
|
1428
|
+
this.topicControlPlaneStopping
|
|
1429
|
+
) {
|
|
1430
|
+
return false;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
if (
|
|
1434
|
+
!this.retainSignedTopicRootCandidateClaim(origin, {
|
|
1435
|
+
bytes: rawClaim.slice(),
|
|
1436
|
+
timestamp,
|
|
1437
|
+
expires,
|
|
1438
|
+
acceptUntil: acceptanceDeadline,
|
|
1439
|
+
})
|
|
1440
|
+
) {
|
|
1441
|
+
return false;
|
|
1442
|
+
}
|
|
1443
|
+
this.scheduleTopicRootCandidateClaimMaintenance();
|
|
1444
|
+
return true;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
private async sendSignedTopicRootCandidateClaims(
|
|
1448
|
+
claims: Uint8Array[],
|
|
1449
|
+
targets?: PeerStreams[],
|
|
1450
|
+
signal?: AbortSignal,
|
|
1451
|
+
) {
|
|
1452
|
+
if (claims.length === 0) return;
|
|
1453
|
+
const streams = (targets ?? [...this.peers.values()]).filter(
|
|
1454
|
+
(stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
1455
|
+
);
|
|
1456
|
+
if (streams.length === 0) return;
|
|
1457
|
+
for (
|
|
1458
|
+
let offset = 0;
|
|
1459
|
+
offset < claims.length;
|
|
1460
|
+
offset += TOPIC_ROOT_CANDIDATE_CLAIMS_MAX
|
|
1461
|
+
) {
|
|
1462
|
+
throwIfAborted(signal);
|
|
1463
|
+
const message = new TopicRootCandidateClaims({
|
|
1464
|
+
claims: claims.slice(offset, offset + TOPIC_ROOT_CANDIDATE_CLAIMS_MAX),
|
|
1465
|
+
});
|
|
1466
|
+
const embedded = await withAbort(
|
|
1467
|
+
this.createMessage(this.encodePubSubMessage(message), {
|
|
1468
|
+
mode: new AnyWhere(),
|
|
1469
|
+
priority: 1,
|
|
1470
|
+
skipRecipientValidation: true,
|
|
1471
|
+
} as any),
|
|
1472
|
+
signal,
|
|
1473
|
+
);
|
|
1474
|
+
throwIfAborted(signal);
|
|
1475
|
+
await this.publishMessageMaybe(
|
|
1476
|
+
this.publicKey,
|
|
1477
|
+
embedded,
|
|
1478
|
+
streams,
|
|
1479
|
+
undefined,
|
|
1480
|
+
signal,
|
|
1481
|
+
);
|
|
1482
|
+
}
|
|
885
1483
|
}
|
|
886
1484
|
|
|
887
|
-
private
|
|
888
|
-
|
|
1485
|
+
private async sendAutoTopicRootCandidates(streams: PeerStreams[]) {
|
|
1486
|
+
if (!this.started) throw new NotStartedError();
|
|
1487
|
+
const signedStreams = streams.filter(
|
|
1488
|
+
(stream) => stream.protocol === TOPIC_CONTROL_PLANE_PROTOCOL_V2_1,
|
|
1489
|
+
);
|
|
1490
|
+
if (signedStreams.length === 0) return;
|
|
1491
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
1492
|
+
if (!this.localSignedTopicRootCandidateClaim) {
|
|
1493
|
+
try {
|
|
1494
|
+
await this.refreshLocalTopicRootCandidateClaim();
|
|
1495
|
+
} catch (error: any) {
|
|
1496
|
+
// Failure to refresh the nested local claim must not suppress
|
|
1497
|
+
// authenticated remote claims; outer-envelope signing is still tried.
|
|
1498
|
+
logErrorIfStarted(error);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
const claims = [...this.signedTopicRootCandidateClaims.entries()]
|
|
1502
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
1503
|
+
.map(([, claim]) => claim.bytes);
|
|
1504
|
+
await this.sendSignedTopicRootCandidateClaims(claims, signedStreams);
|
|
1505
|
+
const localClaim = this.localSignedTopicRootCandidateClaim;
|
|
1506
|
+
if (
|
|
1507
|
+
localClaim &&
|
|
1508
|
+
!this.signedTopicRootCandidateClaims.has(this.publicKeyHash)
|
|
1509
|
+
) {
|
|
1510
|
+
await this.sendSignedTopicRootCandidateClaims(
|
|
1511
|
+
[localClaim.bytes],
|
|
1512
|
+
signedStreams,
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
private setAutoTopicRootCandidateSnapshot(
|
|
1518
|
+
next: string[],
|
|
1519
|
+
options?: { immediate?: boolean },
|
|
889
1520
|
): boolean {
|
|
890
1521
|
if (
|
|
891
1522
|
!this.autoTopicRootCandidates ||
|
|
892
1523
|
this.stopping ||
|
|
893
1524
|
this.topicControlPlaneStopping
|
|
894
|
-
)
|
|
1525
|
+
) {
|
|
895
1526
|
return false;
|
|
896
|
-
|
|
1527
|
+
}
|
|
1528
|
+
if (this.maybeDisableAutoTopicRootCandidatesIfExternallyConfigured()) {
|
|
897
1529
|
return false;
|
|
1530
|
+
}
|
|
898
1531
|
const managed = this.autoTopicRootCandidateSet;
|
|
899
1532
|
if (!managed) return false;
|
|
900
|
-
|
|
1533
|
+
if (options?.immediate) {
|
|
1534
|
+
this.clearAutoTopicRootCandidateUpdateSchedule();
|
|
1535
|
+
const current = [...managed];
|
|
1536
|
+
if (sameCandidates(current, next)) return false;
|
|
1537
|
+
this.scheduleAutoTopicRootCandidateUpdateCooldown();
|
|
1538
|
+
this.applyAutoTopicRootCandidates(next);
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
901
1541
|
const before = this.pendingAutoTopicRootCandidates ?? [...managed];
|
|
902
|
-
const next = this.normalizeAutoTopicRootCandidates([
|
|
903
|
-
...before,
|
|
904
|
-
...candidates,
|
|
905
|
-
]);
|
|
906
1542
|
if (sameCandidates(before, next)) return false;
|
|
907
1543
|
|
|
908
1544
|
if (this.autoTopicRootCandidateUpdateTimer) {
|
|
@@ -921,7 +1557,6 @@ export class TopicControlPlane
|
|
|
921
1557
|
this.shardRootCache.clear();
|
|
922
1558
|
this.scheduleReconcileShardOverlays();
|
|
923
1559
|
this.scheduleHostOwnedShardRoots();
|
|
924
|
-
this.scheduleAutoTopicRootCandidatesBroadcast();
|
|
925
1560
|
}
|
|
926
1561
|
|
|
927
1562
|
private scheduleAutoTopicRootCandidateUpdateCooldown() {
|
|
@@ -1057,11 +1692,14 @@ export class TopicControlPlane
|
|
|
1057
1692
|
topics: userTopics,
|
|
1058
1693
|
requestSubscribers: true,
|
|
1059
1694
|
});
|
|
1060
|
-
const embedded = await this.createMessage(
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1695
|
+
const embedded = await this.createMessage(
|
|
1696
|
+
this.encodePubSubMessage(msg),
|
|
1697
|
+
{
|
|
1698
|
+
mode: new AnyWhere(),
|
|
1699
|
+
priority: 1,
|
|
1700
|
+
skipRecipientValidation: true,
|
|
1701
|
+
} as any,
|
|
1702
|
+
);
|
|
1065
1703
|
const st = this.fanoutChannels.get(shardTopic);
|
|
1066
1704
|
if (!st) return;
|
|
1067
1705
|
await st.channel.publish(toUint8Array(embedded.bytes()));
|
|
@@ -1277,6 +1915,11 @@ export class TopicControlPlane
|
|
|
1277
1915
|
if (bytes[0] === 4) {
|
|
1278
1916
|
assertTopicRootCandidatesFrame(bytes);
|
|
1279
1917
|
}
|
|
1918
|
+
// Signed candidate claims are low-frequency opaque host-side frames. Keep
|
|
1919
|
+
// this explicit seam ahead of the native variants 0-7 codec.
|
|
1920
|
+
if (bytes[0] === 8) {
|
|
1921
|
+
return TopicRootCandidateClaims.from(bytes);
|
|
1922
|
+
}
|
|
1280
1923
|
const native = this.nativeTopicControl;
|
|
1281
1924
|
if (native) {
|
|
1282
1925
|
const decoded = native.decodePubSubMessage(bytes);
|
|
@@ -1367,9 +2010,8 @@ export class TopicControlPlane
|
|
|
1367
2010
|
for (;;) {
|
|
1368
2011
|
for (const signal of terminalSignals) throwIfAborted(signal);
|
|
1369
2012
|
const candidateGeneration = this.getTopicRootCandidateGeneration();
|
|
1370
|
-
const candidateSignal =
|
|
1371
|
-
candidateGeneration
|
|
1372
|
-
);
|
|
2013
|
+
const candidateSignal =
|
|
2014
|
+
this.syncTopicRootCandidateResolutionSignal(candidateGeneration);
|
|
1373
2015
|
const linked = linkAbortSignals([...terminalSignals, candidateSignal]);
|
|
1374
2016
|
try {
|
|
1375
2017
|
const result = await operation({
|
|
@@ -1444,7 +2086,8 @@ export class TopicControlPlane
|
|
|
1444
2086
|
return this.normalizePeerTopicRootState(topic, resolvedThroughPeers);
|
|
1445
2087
|
}
|
|
1446
2088
|
|
|
1447
|
-
const deterministic =
|
|
2089
|
+
const deterministic =
|
|
2090
|
+
this.topicRootControlPlane.resolveDeterministicTopicRoot(topic);
|
|
1448
2091
|
if (
|
|
1449
2092
|
deterministic === this.publicKeyHash &&
|
|
1450
2093
|
this.autoTopicRootCandidates &&
|
|
@@ -1547,7 +2190,7 @@ export class TopicControlPlane
|
|
|
1547
2190
|
let requestId = this.nextTopicRootQueryId >>> 0;
|
|
1548
2191
|
do {
|
|
1549
2192
|
requestId = requestId === 0 ? 1 : requestId;
|
|
1550
|
-
this.nextTopicRootQueryId = (
|
|
2193
|
+
this.nextTopicRootQueryId = (requestId + 1) >>> 0 || 1;
|
|
1551
2194
|
if (!this.pendingTopicRootQueries.has(requestId)) {
|
|
1552
2195
|
return requestId;
|
|
1553
2196
|
}
|
|
@@ -1559,14 +2202,17 @@ export class TopicControlPlane
|
|
|
1559
2202
|
peer: PeerStreams,
|
|
1560
2203
|
pubsubMessage: PubSubMessage,
|
|
1561
2204
|
) {
|
|
1562
|
-
const embedded = await this.createMessage(
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
2205
|
+
const embedded = await this.createMessage(
|
|
2206
|
+
this.encodePubSubMessage(pubsubMessage),
|
|
2207
|
+
{
|
|
2208
|
+
mode: new SilentDelivery({
|
|
2209
|
+
to: [peer.publicKey.hashcode()],
|
|
2210
|
+
redundancy: 1,
|
|
2211
|
+
}),
|
|
2212
|
+
priority: 1,
|
|
2213
|
+
skipRecipientValidation: true,
|
|
2214
|
+
} as any,
|
|
2215
|
+
);
|
|
1570
2216
|
await this.publishMessage(this.publicKey, embedded, [peer]);
|
|
1571
2217
|
}
|
|
1572
2218
|
|
|
@@ -1700,10 +2346,12 @@ export class TopicControlPlane
|
|
|
1700
2346
|
async ({ candidateGeneration, signal }) => {
|
|
1701
2347
|
throwIfAborted(signal);
|
|
1702
2348
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1703
|
-
const root =
|
|
1704
|
-
|
|
2349
|
+
const root = await this.topicRootControlPlane.resolveCanonicalTopicRoot(
|
|
2350
|
+
topic,
|
|
2351
|
+
{
|
|
1705
2352
|
signal,
|
|
1706
|
-
}
|
|
2353
|
+
},
|
|
2354
|
+
);
|
|
1707
2355
|
this.assertTopicControlPlaneActive(lifecycleRevision);
|
|
1708
2356
|
if (root !== this.publicKeyHash) {
|
|
1709
2357
|
return root;
|
|
@@ -1764,10 +2412,7 @@ export class TopicControlPlane
|
|
|
1764
2412
|
}
|
|
1765
2413
|
|
|
1766
2414
|
if (attempt < 2) {
|
|
1767
|
-
await withAbort(
|
|
1768
|
-
delay(150 * (attempt + 1), options),
|
|
1769
|
-
options?.signal,
|
|
1770
|
-
);
|
|
2415
|
+
await withAbort(delay(150 * (attempt + 1), options), options?.signal);
|
|
1771
2416
|
}
|
|
1772
2417
|
}
|
|
1773
2418
|
return undefined;
|
|
@@ -2084,16 +2729,16 @@ export class TopicControlPlane
|
|
|
2084
2729
|
// stream is already established (especially in small test nets without
|
|
2085
2730
|
// trackers/bootstraps). Best-effort only: join can still succeed via
|
|
2086
2731
|
// trackers/other routing if this times out.
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2732
|
+
try {
|
|
2733
|
+
await this.fanout.waitFor(root, {
|
|
2734
|
+
target: "neighbor",
|
|
2735
|
+
// Best-effort pre-check only: do not block subscribe/publish setup
|
|
2736
|
+
// for long periods if the root is not yet a direct stream neighbor.
|
|
2737
|
+
timeout: 1_000,
|
|
2738
|
+
});
|
|
2739
|
+
} catch {
|
|
2740
|
+
// ignore
|
|
2741
|
+
}
|
|
2097
2742
|
const joinOpts = options?.signal
|
|
2098
2743
|
? { ...(this.fanoutJoinOptions ?? {}), signal: options.signal }
|
|
2099
2744
|
: this.fanoutJoinOptions;
|
|
@@ -2261,11 +2906,14 @@ export class TopicControlPlane
|
|
|
2261
2906
|
topics: userTopics,
|
|
2262
2907
|
requestSubscribers: true,
|
|
2263
2908
|
});
|
|
2264
|
-
const embedded = await this.createMessage(
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2909
|
+
const embedded = await this.createMessage(
|
|
2910
|
+
this.encodePubSubMessage(msg),
|
|
2911
|
+
{
|
|
2912
|
+
mode: new AnyWhere(),
|
|
2913
|
+
priority: 1,
|
|
2914
|
+
skipRecipientValidation: true,
|
|
2915
|
+
} as any,
|
|
2916
|
+
);
|
|
2269
2917
|
const st = this.fanoutChannels.get(shardTopic);
|
|
2270
2918
|
if (!st)
|
|
2271
2919
|
throw new Error(`Fanout channel missing for shard: ${shardTopic}`);
|
|
@@ -2318,11 +2966,15 @@ export class TopicControlPlane
|
|
|
2318
2966
|
}
|
|
2319
2967
|
|
|
2320
2968
|
// Best-effort: do not block callers on network I/O (can hang under teardown).
|
|
2321
|
-
void this.debounceUnsubscribeAggregator
|
|
2969
|
+
void this.debounceUnsubscribeAggregator
|
|
2970
|
+
.add({ key: topic })
|
|
2971
|
+
.catch(logErrorIfStarted);
|
|
2322
2972
|
return true;
|
|
2323
2973
|
}
|
|
2324
2974
|
|
|
2325
|
-
private async _announceUnsubscribe(
|
|
2975
|
+
private async _announceUnsubscribe(
|
|
2976
|
+
topics: { key: string; counter: number }[],
|
|
2977
|
+
) {
|
|
2326
2978
|
if (!this.started) throw new NotStartedError();
|
|
2327
2979
|
|
|
2328
2980
|
const byShard = new Map<string, string[]>();
|
|
@@ -2340,11 +2992,14 @@ export class TopicControlPlane
|
|
|
2340
2992
|
// Announce first.
|
|
2341
2993
|
try {
|
|
2342
2994
|
const msg = new Unsubscribe({ topics: userTopics });
|
|
2343
|
-
const embedded = await this.createMessage(
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2995
|
+
const embedded = await this.createMessage(
|
|
2996
|
+
this.encodePubSubMessage(msg),
|
|
2997
|
+
{
|
|
2998
|
+
mode: new AnyWhere(),
|
|
2999
|
+
priority: 1,
|
|
3000
|
+
skipRecipientValidation: true,
|
|
3001
|
+
} as any,
|
|
3002
|
+
);
|
|
2348
3003
|
const st = this.fanoutChannels.get(shardTopic);
|
|
2349
3004
|
if (st) {
|
|
2350
3005
|
// Best-effort: do not let a stuck proxy publish stall teardown.
|
|
@@ -2413,11 +3068,14 @@ export class TopicControlPlane
|
|
|
2413
3068
|
timestamp: batch.timestamp,
|
|
2414
3069
|
topics: batch.topics,
|
|
2415
3070
|
});
|
|
2416
|
-
const embedded = await this.createMessage(
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
3071
|
+
const embedded = await this.createMessage(
|
|
3072
|
+
this.encodePubSubMessage(msg),
|
|
3073
|
+
{
|
|
3074
|
+
mode: new AnyWhere(),
|
|
3075
|
+
priority: 1,
|
|
3076
|
+
skipRecipientValidation: true,
|
|
3077
|
+
} as any,
|
|
3078
|
+
);
|
|
2421
3079
|
await this.ensureFanoutChannel(shardTopic, { ephemeral: true });
|
|
2422
3080
|
const st = this.fanoutChannels.get(shardTopic);
|
|
2423
3081
|
if (st) {
|
|
@@ -2462,9 +3120,10 @@ export class TopicControlPlane
|
|
|
2462
3120
|
private onFanoutPeerUnreachable(
|
|
2463
3121
|
ev: CustomEvent<{ topic: string; root: string; publicKeyHash: string }>,
|
|
2464
3122
|
) {
|
|
2465
|
-
void this
|
|
2466
|
-
|
|
2467
|
-
.
|
|
3123
|
+
void this.announcePeerUnavailableOnShard(
|
|
3124
|
+
ev.detail.publicKeyHash,
|
|
3125
|
+
ev.detail.topic,
|
|
3126
|
+
).catch(logErrorIfStarted);
|
|
2468
3127
|
}
|
|
2469
3128
|
|
|
2470
3129
|
getSubscribers(topic: string): PublicSignKey[] | undefined {
|
|
@@ -2544,11 +3203,14 @@ export class TopicControlPlane
|
|
|
2544
3203
|
const persistent = (this.shardRefCounts.get(shardTopic) ?? 0) > 0;
|
|
2545
3204
|
await this.ensureFanoutChannel(shardTopic, { ephemeral: !persistent });
|
|
2546
3205
|
|
|
2547
|
-
const embedded = await this.createMessage(
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
3206
|
+
const embedded = await this.createMessage(
|
|
3207
|
+
this.encodePubSubMessage(msg),
|
|
3208
|
+
{
|
|
3209
|
+
mode: new AnyWhere(),
|
|
3210
|
+
priority: 1,
|
|
3211
|
+
skipRecipientValidation: true,
|
|
3212
|
+
} as any,
|
|
3213
|
+
);
|
|
2552
3214
|
const payload = toUint8Array(embedded.bytes());
|
|
2553
3215
|
|
|
2554
3216
|
const st = this.fanoutChannels.get(shardTopic);
|
|
@@ -2810,9 +3472,9 @@ export class TopicControlPlane
|
|
|
2810
3472
|
}))
|
|
2811
3473
|
.filter((batch) => batch.topics.length > 0);
|
|
2812
3474
|
if (batches.length > 0) {
|
|
2813
|
-
void this
|
|
2814
|
-
|
|
2815
|
-
|
|
3475
|
+
void this.announcePeerUnavailable(publicKeyHash, batches).catch(
|
|
3476
|
+
logErrorIfStarted,
|
|
3477
|
+
);
|
|
2816
3478
|
}
|
|
2817
3479
|
}
|
|
2818
3480
|
|
|
@@ -2822,7 +3484,10 @@ export class TopicControlPlane
|
|
|
2822
3484
|
return [];
|
|
2823
3485
|
}
|
|
2824
3486
|
|
|
2825
|
-
const grouped = new Map<
|
|
3487
|
+
const grouped = new Map<
|
|
3488
|
+
string,
|
|
3489
|
+
{ session: bigint; timestamp: bigint; topics: string[] }
|
|
3490
|
+
>();
|
|
2826
3491
|
for (const topic of peerTopics) {
|
|
2827
3492
|
const existing = this.topics.get(topic)?.get(peerHash);
|
|
2828
3493
|
if (!existing) {
|
|
@@ -2959,12 +3624,10 @@ export class TopicControlPlane
|
|
|
2959
3624
|
if (!this.lastSubscriptionMessages.has(subscriberKey)) {
|
|
2960
3625
|
this.lastSubscriptionMessages.set(subscriberKey, new Map());
|
|
2961
3626
|
}
|
|
2962
|
-
this.lastSubscriptionMessages
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
timestamp,
|
|
2967
|
-
});
|
|
3627
|
+
this.lastSubscriptionMessages.get(subscriberKey)!.set(topic, {
|
|
3628
|
+
session,
|
|
3629
|
+
timestamp,
|
|
3630
|
+
});
|
|
2968
3631
|
}
|
|
2969
3632
|
return true;
|
|
2970
3633
|
}
|
|
@@ -3100,7 +3763,7 @@ export class TopicControlPlane
|
|
|
3100
3763
|
} catch {
|
|
3101
3764
|
// ignore and fall back
|
|
3102
3765
|
}
|
|
3103
|
-
|
|
3766
|
+
await st.channel.publishMaybe(payload);
|
|
3104
3767
|
}
|
|
3105
3768
|
|
|
3106
3769
|
private async processDirectPubSubMessage(input: {
|
|
@@ -3112,6 +3775,7 @@ export class TopicControlPlane
|
|
|
3112
3775
|
const { pubsubMessage, message, from, stream } = input;
|
|
3113
3776
|
const isDirectRootControl =
|
|
3114
3777
|
pubsubMessage instanceof TopicRootCandidates ||
|
|
3778
|
+
pubsubMessage instanceof TopicRootCandidateClaims ||
|
|
3115
3779
|
pubsubMessage instanceof TopicRootQuery ||
|
|
3116
3780
|
pubsubMessage instanceof TopicRootQueryResponse;
|
|
3117
3781
|
const directRootSigner = isDirectRootControl
|
|
@@ -3125,8 +3789,41 @@ export class TopicControlPlane
|
|
|
3125
3789
|
}
|
|
3126
3790
|
|
|
3127
3791
|
if (pubsubMessage instanceof TopicRootCandidates) {
|
|
3128
|
-
|
|
3129
|
-
|
|
3792
|
+
if (stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_0) {
|
|
3793
|
+
return;
|
|
3794
|
+
}
|
|
3795
|
+
// `addPeer()` already admitted the authenticated direct stream identity.
|
|
3796
|
+
// The advertised list is intentionally ignored.
|
|
3797
|
+
return;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
if (pubsubMessage instanceof TopicRootCandidateClaims) {
|
|
3801
|
+
if (
|
|
3802
|
+
stream.protocol !== TOPIC_CONTROL_PLANE_PROTOCOL_V2_1 ||
|
|
3803
|
+
!this.autoTopicRootCandidates
|
|
3804
|
+
) {
|
|
3805
|
+
return;
|
|
3806
|
+
}
|
|
3807
|
+
const imported: Uint8Array[] = [];
|
|
3808
|
+
const outerPeerHash = stream.publicKey.hashcode();
|
|
3809
|
+
for (const claim of pubsubMessage.claims) {
|
|
3810
|
+
if (
|
|
3811
|
+
await this.importSignedTopicRootCandidateClaim(claim, outerPeerHash)
|
|
3812
|
+
) {
|
|
3813
|
+
imported.push(claim);
|
|
3814
|
+
}
|
|
3815
|
+
}
|
|
3816
|
+
if (imported.length > 0) {
|
|
3817
|
+
this.rebuildAutoTopicRootCandidatesFromClaims();
|
|
3818
|
+
// Relay the exact nested claims; no relay signature is substituted for
|
|
3819
|
+
// their origin signatures.
|
|
3820
|
+
void this.sendSignedTopicRootCandidateClaims(
|
|
3821
|
+
imported,
|
|
3822
|
+
[...this.peers.values()].filter(
|
|
3823
|
+
(peer) => peer.publicKey.hashcode() !== outerPeerHash,
|
|
3824
|
+
),
|
|
3825
|
+
).catch(() => {});
|
|
3826
|
+
}
|
|
3130
3827
|
return;
|
|
3131
3828
|
}
|
|
3132
3829
|
|
|
@@ -3465,6 +4162,7 @@ export class TopicControlPlane
|
|
|
3465
4162
|
if (
|
|
3466
4163
|
!(pubsubMessage instanceof PubSubData) &&
|
|
3467
4164
|
!(pubsubMessage instanceof TopicRootCandidates) &&
|
|
4165
|
+
!(pubsubMessage instanceof TopicRootCandidateClaims) &&
|
|
3468
4166
|
!(pubsubMessage instanceof TopicRootQuery) &&
|
|
3469
4167
|
!(pubsubMessage instanceof GetSubscribers) &&
|
|
3470
4168
|
!(pubsubMessage instanceof Subscribe) &&
|
|
@@ -3485,8 +4183,8 @@ export class TopicControlPlane
|
|
|
3485
4183
|
}
|
|
3486
4184
|
|
|
3487
4185
|
if (pubsubMessage instanceof PubSubData) {
|
|
3488
|
-
const wantsTopic = pubsubMessage.topics.some(
|
|
3489
|
-
this.subscriptions.has(t) || this.pendingSubscriptions.has(t),
|
|
4186
|
+
const wantsTopic = pubsubMessage.topics.some(
|
|
4187
|
+
(t) => this.subscriptions.has(t) || this.pendingSubscriptions.has(t),
|
|
3490
4188
|
);
|
|
3491
4189
|
isForMe = pubsubMessage.strict ? isForMe && wantsTopic : wantsTopic;
|
|
3492
4190
|
}
|