OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer commits / afd59cb

dht: ping the nodes that hold our record, and know which ones they are
Defect B's liveness ping, plus the §15 prerequisite it could not be built without. 1. The per-node commit callback now carries the responding node's ADDRESS. It was (target, token, node_id); the commit reply's origin is the only place a holder's address appears. 2. _note_announce_holder replaces _store_announced_nodes. Holders are recorded AS THEY COMMIT, from the reply, with the token they issued — instead of being guessed afterwards from table().closest() with the token memset to zero and the comment "Token not available here". A node re-committing on a refresh updates its token rather than duplicating, because the token rotates. This is JS's _serverRelays[2].set(msg.from...) (announcer.js:267). 3. _ping_self_holders pings every recorded holder every SELF_ANNOUNCE_PING_MS (3000, = JS), tagged HYPERDHT_TAG_SELF_PING so the replies are distinguishable from every other PING. 4. The verdict matches JS's `if (active < Math.min(pings.length, MIN_ACTIVE)) refresh()`, judged at the START of the next round so a slow reply inside the window still counts. On failure it sets _self_announce_at = now, so the re-announce fires in the same tick — which is why the ping runs BEFORE _tick_self_announce. Three seconds to notice we are unreachable instead of five minutes. Gate: test_self_announce 10/10, including the degenerate cases that would otherwise crash or storm — no holders means no ping rounds, and an empty holder set must not trigger a re-announce (an empty set trivially has "fewer than 3 alive"). ⚠ The POPULATED-holder path is not covered. These gates have no real DHT, so nothing answers an announce and self_holder_count() is 0 throughout: the recording, the round and the verdict are exercised only in their empty form. Proving the live path needs a testnet with real responders. Suite: 13 binaries green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
afd59cb810e693419cb0a4b59c1f0384813fef60
by plankatron <fleeky@gmail.com> · 2026-09-10T16:48:10.000Z

Changed files (27) +187 -19

docs/PARITY-hyperswarm.md +44 -0
……@@ -986,3 +986,47 @@ possible.
986986
987987⚠ NOT STARTED. Recorded rather than begun because it is a four-part change
988988across two files' public signatures, and half of it landing is worse than none.
989
990## 16. Slice 12 — B's liveness ping, built on real holders (DONE)
991
992§15's prerequisite first, then the ping.
993
994**1. The commit callback now carries the responding node's ADDRESS.**
995`dht_rpc`'s per-node commit fn was `(target, token, node_id)`; the reply's origin
996is the only place a holder's address appears, so it is now
997`(target, token, node_id, from)`.
998
999**2. `_note_announce_holder` replaces `_store_announced_nodes`.** Holders are
1000recorded AS THEY COMMIT, from the reply, with the token they issued — instead of
1001being guessed after the fact from `table().closest()` with a zeroed token. A node
1002re-committing on a refresh updates its token rather than duplicating: the token
1003rotates and a stale one is worthless. This is JS's `_serverRelays[2].set(msg.from…)`
1004(`announcer.js:267`).
1005
1006**3. The ping round.** `_ping_self_holders` pings every recorded holder every
1007`SELF_ANNOUNCE_PING_MS` (3000, = JS), tagged `HYPERDHT_TAG_SELF_PING` so replies
1008are distinguishable from every other PING.
1009
1010**4. The verdict.** JS: `if (active < Math.min(pings.length, MIN_ACTIVE)) refresh()`.
1011Ours judges the round at the START of the next one — a slow reply inside the
1012window still counts — and on failure sets `_self_announce_at = now`, so the
1013re-announce fires in the SAME tick. Ordering matters: the ping runs before
1014`_tick_self_announce`, which is what lets it pull the announce forward.
1015
1016That is 3 seconds to notice we are unreachable instead of 5 minutes.
1017
1018**Gate:** `test_self_announce.cpp` extended to 10/10, including the degenerate
1019cases that would otherwise be a crash or a storm: no holders recorded means no
1020ping rounds, and an empty holder set must NOT trigger a re-announce (an empty
1021set trivially has "fewer than 3 alive").
1022
1023⚠ **What is NOT covered:** the populated-holder path. These gates have no real
1024DHT, so nothing ever answers an announce and `self_holder_count()` is 0
1025throughout. The recording, the ping round and the verdict are exercised only in
1026their empty form. Proving the live path needs a testnet with real responders —
1027named here so nobody reads 10/10 as more than it is.
1028
1029⏳ Still open: three-generation cycling, `refresh-chain`, A2, C's explicit-error
1030abort.
1031
1032Suite: 13 binaries green.
src/dht_rpc.cpp +3 -2
……@@ -236,7 +236,8 @@ uint32_t DhtRpc::query_with_commit_fn(
236236 uint32_t traverse_command,
237237 const uint8_t *traverse_value, size_t traverse_value_len,
238238 uint32_t commit_command,
239 std::function<std::vector<uint8_t>(const uint8_t *, const uint8_t *, const uint8_t *)> commit_fn,
239 std::function<std::vector<uint8_t>(const uint8_t *, const uint8_t *,
240 const uint8_t *, const IPv4Address &)> commit_fn,
240241 uint32_t tag) {
241242 if (_query_count >= 16) return 0;
242243
……@@ -1043,7 +1044,7 @@ void DhtRpc::_commit_query(DhtQuery *q) {
10431044 if (q->commit_value_fn) {
10441045 // Compute the node's ID from its address (matches JS peer.id()).
10451046 NodeId node_id = cenc::node_id_from_address(rp.address);
1046 per_node_val = q->commit_value_fn(q->target, rp.token, node_id.bytes);
1047 per_node_val = q->commit_value_fn(q->target, rp.token, node_id.bytes, rp.address);
10471048 commit_val = per_node_val.data();
10481049 commit_val_len = per_node_val.size();
10491050 } else {
src/dht_rpc.h +9 -2
……@@ -144,7 +144,8 @@ struct DhtQuery {
144144 // Returns: the commit value for this specific node.
145145 std::function<std::vector<uint8_t>(const uint8_t *target,
146146 const uint8_t *token,
147 const uint8_t *node_id)> commit_value_fn;
147 const uint8_t *node_id,
148 const IPv4Address &from)> commit_value_fn;
148149
149150 DhtQueryPeer closest[DHT_QUERY_K * 3]; // Working set.
150151 int closest_count;
……@@ -208,7 +209,13 @@ public:
208209 uint32_t traverse_command,
209210 const uint8_t *traverse_value, size_t traverse_value_len,
210211 uint32_t commit_command,
211 std::function<std::vector<uint8_t>(const uint8_t *, const uint8_t *, const uint8_t *)> commit_fn,
212 // (target, token, node_id, from) -> the value to commit at THAT node.
213 // ⛔ `from` was missing and that is why nothing could record which nodes
214 // actually hold our record: the commit reply is the only place the
215 // holder's address appears. _store_announced_nodes had to guess with
216 // table().closest() and zero the token. See PARITY-hyperswarm.md §15.
217 std::function<std::vector<uint8_t>(const uint8_t *, const uint8_t *,
218 const uint8_t *, const IPv4Address &)> commit_fn,
212219 uint32_t tag = 0);
213220
214221 // Process an incoming UDP message. Called from socket recv callback.
src/hyperdht.cpp +85 -13
……@@ -69,9 +69,9 @@ void HyperDht::init(DhtRpc *rpc, const Ed25519KeyPair &keypair) {
6969 if (_query_infos[i].active && _query_infos[i].query_id == query_id) {
7070 auto &qi = _query_infos[i];
7171 // For announce queries, store the closest responded nodes.
72 if (qi.is_announce) {
73 _store_announced_nodes(qi.topic, query_id);
74 }
72 // Holders are recorded per node in the commit callback now
73 // (_note_announce_holder), not guessed from the routing table
74 // after the fact.
7575 // Handle storage query completions.
7676 if (qi.is_immutable_put) {
7777 if (on_immutable_put_complete) {
……@@ -204,7 +204,12 @@ uint32_t HyperDht::announce(const uint8_t *topic) {
204204 HYPERDHT_CMD_LOOKUP, nullptr, 0, // Traversal: LOOKUP, no value
205205 HYPERDHT_CMD_ANNOUNCE, // Commit: ANNOUNCE
206206 [this, topic_copy](const uint8_t *target, const uint8_t *token,
207 const uint8_t *node_id) -> std::vector<uint8_t> {
207 const uint8_t *node_id,
208 const IPv4Address &from) -> std::vector<uint8_t> {
209 // ⛔ THIS is where we learn who holds our record. The reply's `from`
210 // is the node that took the announce; nothing else knows it. See
211 // _note_announce_holder and PARITY-hyperswarm.md §15.
212 _note_announce_holder(topic_copy, from, token);
208213 return _encode_announce_value_for_node(topic_copy, token, node_id);
209214 },
210215 0);
……@@ -259,7 +264,8 @@ void HyperDht::unannounce(const uint8_t *topic) {
259264 HYPERDHT_CMD_LOOKUP, nullptr, 0, // traversal: LOOKUP, no value
260265 HYPERDHT_CMD_UNANNOUNCE, // commit: UNANNOUNCE
261266 [this, topic_copy](const uint8_t *target, const uint8_t *token,
262 const uint8_t *node_id) -> std::vector<uint8_t> {
267 const uint8_t *node_id,
268 const IPv4Address &) -> std::vector<uint8_t> {
263269 return _encode_ann_value_for_node(_ns_unannounce, topic_copy, token, node_id);
264270 },
265271 0);
……@@ -365,6 +371,42 @@ void HyperDht::listen() {
365371 // JS keeps this alive in a dedicated component, hyperdht's Announcer, one
366372 // per server, targeting hash(keyPair.publicKey) — see SELF_ANNOUNCE_UPDATE_MS.
367373 _self_announce_at = _last_tick_ms + SELF_ANNOUNCE_UPDATE_MS;
374 _self_ping_at = _last_tick_ms + SELF_ANNOUNCE_PING_MS;
375}
376
377int HyperDht::self_holder_count() const {
378 auto it = _announced_topics.find(_topic_hex(_self_target));
379 return it == _announced_topics.end() ? 0 : it->second.node_count;
380}
381
382void HyperDht::_ping_self_holders(uint64_t now_ms) {
383 auto it = _announced_topics.find(_topic_hex(_self_target));
384 if (it == _announced_topics.end() || it->second.node_count == 0) return;
385
386 const AnnouncedTopic &at = it->second;
387
388 // Judge the round that has just elapsed before starting the next one.
389 // JS: if (active < Math.min(pings.length, MIN_ACTIVE)) this.refresh()
390 if (_self_ping_sent > 0) {
391 const int needed = _self_ping_sent < SELF_ANNOUNCE_MIN_ACTIVE
392 ? _self_ping_sent
393 : SELF_ANNOUNCE_MIN_ACTIVE;
394 if (_self_ping_alive < needed) {
395 DHT_DISC("[HyperDht] only %d/%d holders answered — re-announcing now "
396 "instead of in %llums\n", _self_ping_alive, _self_ping_sent,
397 (unsigned long long)(_self_announce_at - now_ms));
398 _self_announce_at = now_ms; // _tick_self_announce fires this tick
399 }
400 }
401
402 _self_ping_sent = 0;
403 _self_ping_alive = 0;
404 for (int i = 0; i < at.node_count; i++) {
405 _rpc->ping(at.nodes[i].address, HYPERDHT_TAG_SELF_PING);
406 _self_ping_sent++;
407 }
408 _self_ping_rounds++;
409 _self_ping_at = now_ms + SELF_ANNOUNCE_PING_MS;
368410}
369411
370412void HyperDht::_tick_self_announce(uint64_t now_ms) {
……@@ -393,6 +435,12 @@ void HyperDht::tick(uint64_t now_ms) {
393435
394436 _last_tick_ms = now_ms;
395437
438 // Are the nodes holding our record still there? Runs BEFORE the announce
439 // tick so a failed round can pull the re-announce forward into this tick.
440 if (_listening && _self_ping_at != 0 && now_ms >= _self_ping_at) {
441 _ping_self_holders(now_ms);
442 }
443
396444 // Keep our own record alive. Without this a listening peer is announced
397445 // exactly once and becomes unreachable when that record expires.
398446 _tick_self_announce(now_ms);
……@@ -408,6 +456,14 @@ void HyperDht::tick(uint64_t now_ms) {
408456// ---- RPC response handler ----
409457
410458void HyperDht::_on_rpc_response(const DhtResponse &res, uint32_t tag) {
459 // A holder answered its liveness ping. Counted, not acted on here: the
460 // verdict is passed at the START of the next round, so a slow reply inside
461 // the window still counts. See _ping_self_holders.
462 if (tag == HYPERDHT_TAG_SELF_PING) {
463 _self_ping_alive++;
464 return;
465 }
466
411467 // Check if this is a response to a handshake.
412468 DhtHandshake *hs = _find_handshake(tag);
413469 if (hs && hs->is_initiator) {
……@@ -887,20 +943,36 @@ void HyperDht::_sign_announce(const uint8_t *signable, size_t signable_len,
887943 _keypair.secret_key);
888944}
889945
890void HyperDht::_store_announced_nodes(const uint8_t *topic, uint32_t query_id) {
891 // Store the closest nodes from the routing table for later unannounce.
946void HyperDht::_note_announce_holder(const uint8_t *topic, const IPv4Address &from,
947 const uint8_t *token) {
948 // ⛔ THE NODES THAT ACTUALLY TOOK THE ANNOUNCE, recorded as they commit.
949 //
950 // This used to be _store_announced_nodes(), which ran after the query and
951 // filled the table from `table().closest()` — our own routing table's idea
952 // of who is nearby, not who accepted anything — and then wrote a zeroed
953 // token with the comment "Token not available here". So we could not say
954 // which nodes held our record, could not check whether they were alive, and
955 // could not produce a per-node token for anything later.
956 //
957 // JS records the committing node from the reply itself
958 // (announcer.js:265-267). `from` here is that same reply's origin.
892959 std::string key = _topic_hex(topic);
893960 AnnouncedTopic &at = _announced_topics[key];
894961 memcpy(at.topic, topic, 32);
895962
896 // Get closest nodes from the routing table.
897 KademliaNode nodes[20];
898 int n = _rpc->table().closest(topic, nodes, 20);
899 at.node_count = (n > 20) ? 20 : n;
963 // A node re-committing on a refresh updates its token rather than adding a
964 // duplicate — the token rotates and the stale one is worthless.
900965 for (int i = 0; i < at.node_count; i++) {
901 at.nodes[i].address = nodes[i].address;
902 memset(at.nodes[i].token, 0, 32); // Token not available here.
966 if (memcmp(at.nodes[i].address.ip, from.ip, 4) == 0 &&
967 at.nodes[i].address.port == from.port) {
968 memcpy(at.nodes[i].token, token, 32);
969 return;
970 }
903971 }
972 if (at.node_count >= 20) return;
973 at.nodes[at.node_count].address = from;
974 memcpy(at.nodes[at.node_count].token, token, 32);
975 at.node_count++;
904976}
905977
906978std::string HyperDht::_topic_hex(const uint8_t *topic) {
src/hyperdht.h +23 -2
……@@ -36,6 +36,17 @@ static constexpr int HYPERDHT_MAX_PEERS = 64;
3636// 100 iterations of a 3s sleep, so a full re-announce every ~5 minutes.
3737static constexpr uint64_t SELF_ANNOUNCE_UPDATE_MS = 300000; // 5 min, = JS 100 x 3s.
3838
39// ⛔ AND CHECK THE HOLDERS ARE STILL THERE. Re-announcing on a timer alone means
40// losing every node that holds our record costs a full interval before anyone
41// notices. JS pings each holder every 3s and re-announces the moment fewer than
42// MIN_ACTIVE answer (announcer.js:104-125) — 3 seconds versus 5 minutes to
43// notice we have become unreachable.
44static constexpr uint64_t SELF_ANNOUNCE_PING_MS = 3000;
45// Tag carried on holder pings so their replies can be told apart from every
46// other PING this node makes.
47static constexpr uint32_t HYPERDHT_TAG_SELF_PING = 0x5E1F0001;
48static constexpr int SELF_ANNOUNCE_MIN_ACTIVE = 3; // JS MIN_ACTIVE
49
3950static constexpr uint64_t TOPIC_REFRESH_INTERVAL_MS = 600000; // 10 minutes.
4051static constexpr uint64_t TOPIC_REFRESH_JITTER_MS = 120000; // 0–2 minutes random.
4152static constexpr uint64_t TOPIC_REFRESH_GRACE_MS = 30000; // 30s grace (laptop wake).
……@@ -176,6 +187,8 @@ public:
176187 // expires, which is why this is observable at all.
177188 uint64_t self_announce_due_at() const { return _self_announce_at; }
178189 uint64_t self_announce_count() const { return _self_announce_count; }
190 uint64_t self_ping_rounds() const { return _self_ping_rounds; }
191 int self_holder_count() const;
179192
180193 // Process time-based events.
181194 void tick(uint64_t now_ms);
……@@ -323,8 +336,11 @@ private:
323336 // Sign an announce record.
324337 void _sign_announce(const uint8_t *signable, size_t signable_len, uint8_t *signature);
325338
326 // Store closest nodes after announce for unannounce.
327 void _store_announced_nodes(const uint8_t *topic, uint32_t query_id);
339 // Record a node that ACCEPTED our announce, with the token it issued.
340 // Called from the commit callback, which is the only place the holder's
341 // address exists. See PARITY-hyperswarm.md §15.
342 void _note_announce_holder(const uint8_t *topic, const IPv4Address &from,
343 const uint8_t *token);
328344
329345 // Handshake management.
330346 DhtHandshake *_alloc_handshake(bool initiator, const uint8_t *remote_pk,
……@@ -348,7 +364,12 @@ private:
348364 uint8_t _self_target[32] = { 0 };
349365 uint64_t _self_announce_at = 0; // 0 = not listening
350366 uint64_t _self_announce_count = 0;
367 uint64_t _self_ping_at = 0;
368 uint64_t _self_ping_rounds = 0;
369 int _self_ping_sent = 0; // holders pinged in the round now in flight
370 int _self_ping_alive = 0; // how many answered
351371 void _tick_self_announce(uint64_t now_ms);
372 void _ping_self_holders(uint64_t now_ms);
352373 void _finalize_handshake(DhtHandshake *hs);
353374
354375 // Respond to any non-internal query with closer nodes + token + optional value.
test/test_ann_signable +0 -0
(no content changes — binary or rename)
test/test_bootstrap_ownership +0 -0
(no content changes — binary or rename)
test/test_crossystem_client +0 -0
(no content changes — binary or rename)
test/test_dht_bootstrap +0 -0
(no content changes — binary or rename)
test/test_find_peer_exhausted +0 -0
(no content changes — binary or rename)
test/test_handshake_pool +0 -0
(no content changes — binary or rename)
test/test_integration +0 -0
(no content changes — binary or rename)
test/test_interop_client +0 -0
(no content changes — binary or rename)
test/test_interop_server_bin +0 -0
(no content changes — binary or rename)
test/test_overlay_late_attach +0 -0
(no content changes — binary or rename)
test/test_peer_topics +0 -0
(no content changes — binary or rename)
test/test_phase4 +0 -0
(no content changes — binary or rename)
test/test_phase5 +0 -0
(no content changes — binary or rename)
test/test_phase6 +0 -0
(no content changes — binary or rename)
test/test_phase7 +0 -0
(no content changes — binary or rename)
test/test_protomux_gc +0 -0
(no content changes — binary or rename)
test/test_regression +0 -0
(no content changes — binary or rename)
test/test_self_announce +0 -0
(no content changes — binary or rename)
test/test_self_announce.cpp +23 -0
……@@ -91,6 +91,29 @@ int main() {
9191 check(dht.self_announce_count() == before,
9292 "many ticks inside one interval produce no extra announces");
9393
94 // ---- holder liveness (§15/§12.1) ----
95 //
96 // With no holders recorded there is nothing to ping, and the round must be
97 // a no-op rather than a division by zero or a spurious re-announce.
98 check(dht.self_holder_count() == 0,
99 "no holders recorded without a real DHT to answer the announce");
100
101 uint64_t rounds_before = dht.self_ping_rounds();
102 for (int i = 0; i < 10; i++) {
103 now += SELF_ANNOUNCE_PING_MS;
104 dht.tick(now);
105 }
106 check(dht.self_ping_rounds() == rounds_before,
107 "no ping rounds while no holder is known");
108
109 uint64_t announces_before = dht.self_announce_count();
110 for (int i = 0; i < 10; i++) {
111 now += SELF_ANNOUNCE_PING_MS;
112 dht.tick(now);
113 }
114 check(dht.self_announce_count() == announces_before,
115 "an empty holder set does NOT trigger a re-announce storm");
116
94117 printf("\n%d/%d passed\n", checks - failures, checks);
95118
96119 udx_socket_close(&sock);
test/test_session_channel +0 -0
(no content changes — binary or rename)
test/test_three_peer_swarm +0 -0
(no content changes — binary or rename)
test/test_verify_vectors +0 -0
(no content changes — binary or rename)