OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer commits / fb3a85c

test: A2 is not reproducible — the measurement, and the code it sent back
hyper-games #73 NEXT item 5. A2 said our commit-phase unannounce withdraws from fewer nodes than JS's during-traversal one. I built it: a reply_hook on DhtRpc firing for every traversal reply, and an unannounce that sends a per-node UNANNOUNCE to each node whose LOOKUP reply carries our key — JS's map(), in our machinery. Then I measured it, and reverted it. testnet with the hook commit-only 8 nodes before 7, after 0 before 7, after 0 60 nodes before 20, after 0 before 20, after 0 120 nodes — before 20, after 0 40 grown to 120 mid-run before 20, after 0 before 20, after 0 (x2 each) Commit-only already reaches every holder, everywhere. The reason is structural and I should have predicted it: a record is stored at the K nodes CLOSEST to the topic, and the commit phase targets the K closest that answered — the same set. The last row exists because that argument has one hole, a topology that MOVES between the announce and the withdrawal, and the harness grows the network threefold in exactly that window to open it. It stayed shut. So the change was twenty extra packets per unannounce and no observable difference. Shipping it would have been an unexercised path justified by a reading of the reference instead of a measurement of ourselves. ⚠ It does not prove parity on the public DHT, where the topology is larger and churns continuously. It proves no testnet up to 120 nodes can tell the two apart, which is every instrument available here. The gate stays, because "unannounce withdraws from EVERY node, on a real network" is worth holding whatever the answer to A2 was. Its baseline is load-bearing: at 40 nodes with 8-second phases it reports 0 holders before AND 0 after, and without the baseline assertion that reads as a pass. PHASE_MS, NODES and GROW are knobs for that reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fb3a85c8be22edde1bbf777a3a09fe135439ae2b
by plankatron <fleeky@gmail.com> · 2026-09-10T19:51:19.000Z

Changed files (5) +350 -1

docs/PARITY-hyperswarm.md +47 -1
……@@ -266,7 +266,7 @@ reading of their code.
266266(both signables fail, with the `a1a1..` topic visible where the digest belongs);
267267reverting returns 3/3.
268268
269### 7.3 Defect A2 — OPEN: unannounce reaches fewer nodes than JS
269### 7.3 Defect A2 — CLOSED as NOT REPRODUCIBLE (see §7.5)
270270
271271`unannounce()` is now a fresh LOOKUP traversal committing per node, which is
272272where the per-node token and id come from. But JS does NOT unannounce in the
……@@ -278,6 +278,52 @@ So JS withdraws from every node it saw holding the record; we withdraw only at
278278the closest nodes the commit phase reaches. Fixing this needs a per-reply hook
279279in `DhtRpc`, which the commit-fn machinery does not currently expose.
280280
281### 7.5 A2 — CLOSED, and NOT by fixing it: it is not reproducible (2026-09-10)
282
283A2 said our commit-phase unannounce withdraws from fewer nodes than JS's
284during-traversal one. It was built, measured, and reverted.
285
286The change: a `reply_hook` on `DhtRpc` firing for every traversal reply, and an
287`unannounce` that sends a per-node UNANNOUNCE to each node whose LOOKUP reply
288contains our own key — JS's `map()` (index.js:226-247), in our machinery.
289
290**The measurement, on real JS testnets, announcing then unannouncing and then
291counting the nodes that still answer holding us:**
292
293| testnet | with the traversal hook | commit-only |
294|---|---|---|
295| 8 nodes | before 7, after 0 | before 7, after **0** |
296| 40 nodes | before 20, after 0 | — |
297| 60 nodes | before 20, after 0 | before 20, after **0** |
298| 120 nodes | — | before 20, after **0** |
299| 40 nodes, **grown to 120 between announce and unannounce** | before 20, after 0 (×2) | before 20, after **0** (×2) |
300
301Commit-only already reaches every holder, in every configuration. The reason is
302structural and should have been predicted: a record is stored at the K nodes
303CLOSEST to the topic, and the commit phase targets the K closest nodes that
304answered. Those are the same set. The last row was built specifically to break
305that — tripling the network between the announce and the withdrawal, so nodes
306closer to the topic could displace the existing holders out of the top K — and
307it did not.
308
309So the traversal hook sent twenty extra UNANNOUNCE packets per withdrawal and
310changed nothing observable. It is reverted: shipping it would be adding an
311unexercised path on the strength of a reading of the reference rather than a
312measurement of our own behaviour.
313
314⚠ What this does NOT prove: that JS and we withdraw from the same set on the
315PUBLIC DHT, where the topology is larger, churns continuously, and a record may
316have been accepted by nodes that a later traversal never visits. It proves that
317no testnet up to 120 nodes, static or growing, can tell the two apart — which is
318every instrument available here.
319
320**Gate kept:** `test/test_unannounce_live.cpp` + `run_unannounce_live.js`
321(`NODES=`, `GROW=`, `PHASE_MS=`). It asserts that unannounce withdraws from
322EVERY node, which is worth holding whatever the answer to A2 was. Its baseline —
323"at least one node held the record before" — is load-bearing: at 40 nodes with
324the original 8-second phases it reports 0 holders before AND 0 after, and
325without the baseline that reads as a pass.
326
281327### 7.4 ⚠ `test/node_modules` is TRACKED in this repo
282328
283329The hyperdht bump therefore appears as a source diff — which is a gift, not a
test/build_tests.sh +11 -0
……@@ -356,4 +356,15 @@ g++ -o test_relay_error_abort \
356356 $UDX_OBJS $LDFLAGS
357357echo " OK: test_relay_error_abort"
358358
359# ---- test_unannounce_live ----
360# Needs a real testnet: run via `node run_unannounce_live.js`, not directly.
361# docs/PARITY-hyperswarm.md A2.
362echo "Building test_unannounce_live..."
363g++ $CXXFLAGS -o $OBJ/test_unannounce_live.o test_unannounce_live.cpp
364g++ -o test_unannounce_live \
365 $OBJ/test_unannounce_live.o $OBJ/noise_ik.o $OBJ/noise_handshake.o \
366 $OBJ/noise_stream.o $OBJ/dht_rpc.o $OBJ/holepuncher.o $OBJ/hyperdht.o \
367 $UDX_OBJS $LDFLAGS
368echo " OK: test_unannounce_live"
369
359370echo "=== All test binaries built successfully ==="
test/run_unannounce_live.js +64 -0
……@@ -0,0 +1,64 @@
1#!/usr/bin/env node
2// Stands up a real JS DHT testnet and runs test_unannounce_live against it.
3//
4// node run_unannounce_live.js # 8 nodes, static
5// NODES=60 GROW=60 node run_unannounce_live.js # 60, then 60 more mid-run
6//
7// ⛔ GROW IS THE POINT, AND IT WAS ADDED AFTER A MEASUREMENT SAID SO.
8//
9// A2 claims our commit-phase unannounce reaches fewer nodes than JS's
10// during-traversal one. On a STATIC testnet that is false, measured at 8, 60 and
11// 120 nodes: the holder set and the closest-K commit set are the same set,
12// because a record is stored at the closest K and the commit targets the closest
13// K. The traversal hook sent 20 extra UNANNOUNCEs and changed nothing.
14//
15// The two sets can only differ if the topology MOVES between the announce and
16// the unannounce — new nodes arrive closer to the topic than the ones already
17// holding it, so the old holders fall out of the top K and a commit-phase
18// withdrawal never reaches them. That is not exotic; it is what a DHT does. So
19// the harness grows the network in the window between them.
20
21const { spawn } = require('child_process')
22const path = require('path')
23const createTestnet = require('hyperdht/testnet')
24const DHT = require('hyperdht')
25
26const BIN = path.join(__dirname, 'test_unannounce_live')
27const NODES = parseInt(process.env.NODES || '8', 10)
28const GROW = parseInt(process.env.GROW || '0', 10)
29
30async function main () {
31 const testnet = await createTestnet(NODES, { teardown: () => {} })
32 const bootstrap = testnet.bootstrap.map(b => `${b.host}:${b.port}`).join(',')
33 console.log(`testnet up (${testnet.nodes.length} nodes): ${bootstrap}`)
34 if (GROW) console.log(`will add ${GROW} more nodes after the announce settles\n`)
35 else console.log('')
36
37 const extra = []
38 const code = await new Promise((resolve) => {
39 const p = spawn(BIN, [bootstrap, process.env.PHASE_MS || '8000'], { stdio: ['ignore', 'pipe', 'inherit'] })
40 let grown = false
41 p.stdout.on('data', async (chunk) => {
42 process.stdout.write(chunk)
43 // Fires between the BEFORE lookup and the unannounce, which is the window
44 // where new nodes can displace the existing holders in the top K.
45 if (GROW && !grown && chunk.toString().includes('hold the record; unannouncing')) {
46 grown = true
47 for (let i = 0; i < GROW; i++) {
48 const node = new DHT({ bootstrap: testnet.bootstrap, ephemeral: false })
49 await node.ready()
50 extra.push(node)
51 }
52 console.log(` [harness] added ${GROW} nodes mid-run`)
53 }
54 })
55 p.on('exit', resolve)
56 })
57
58 for (const n of extra) await n.destroy()
59 await testnet.destroy()
60 console.log(`\ntest_unannounce_live exit=${code}`)
61 process.exit(code)
62}
63
64main().catch((err) => { console.error(err); process.exit(1) })
test/test_unannounce_live +0 -0
(no content changes — binary or rename)
test/test_unannounce_live.cpp +228 -0
……@@ -0,0 +1,228 @@
1// Gate: after unannounce, NO node on a real testnet still holds our record.
2//
3// test_unannounce_live <host:port[,host:port...]>
4// driven by test/run_unannounce_live.js, which stands up the testnet
5//
6// ⛔ THE MEASUREMENT IS "HOW MANY NODES STILL ANSWER WITH US", not "did
7// unannounce return". A LOOKUP after the withdrawal reports one FoundPeer per
8// answering node, tagged with the node that answered — so counting distinct
9// answering nodes whose peer list holds our key is the quantity that matters,
10// and nothing below it is evidence.
11//
12// It was written to settle docs/PARITY-hyperswarm.md A2, which claimed our
13// commit-phase withdrawal reaches fewer nodes than JS's during-traversal one.
14// It settled it the other way: see §7.5. The gate stays because "unannounce
15// actually withdraws, from every node, on a real network" is worth holding on
16// to whatever the answer to A2 turned out to be.
17
18#include <cstdio>
19#include <cstdlib>
20#include <cstring>
21#include <set>
22#include <string>
23#include <vector>
24
25#include <sodium.h>
26
27#include "dht_rpc.h"
28#include "hyperdht.h"
29
30extern "C" {
31#include <udx.h>
32#include <uv.h>
33}
34
35enum Phase { BOOTSTRAPPING, ANNOUNCING, LOOKUP_BEFORE, UNANNOUNCING, LOOKUP_AFTER, DONE };
36
37struct Ctx {
38 uv_loop_t loop;
39 udx_t udx;
40 udx_socket_t socket;
41 uv_timer_t timer;
42 uv_timer_t deadline;
43 DhtRpc rpc;
44 HyperDht dht;
45 uint8_t topic[32];
46 uint8_t our_pk[32];
47
48 Phase phase = BOOTSTRAPPING;
49 uint64_t phase_until = 0;
50 std::set<std::string> holders_before, holders_after;
51 std::set<std::string> *collecting = nullptr;
52 int phase_ms = 8000;
53};
54
55static std::string addr_key(const IPv4Address &a) {
56 char b[32];
57 snprintf(b, sizeof(b), "%d.%d.%d.%d:%d", a.ip[0], a.ip[1], a.ip[2], a.ip[3], a.port);
58 return b;
59}
60
61static void on_socket_recv(udx_socket_t *sock, ssize_t read_len, const uv_buf_t *buf,
62 const struct sockaddr *from) {
63 Ctx *ctx = static_cast<Ctx *>(sock->data);
64 if (!ctx || read_len <= 0) return;
65 const uint8_t *data = (const uint8_t *)buf->base;
66 if ((size_t)read_len >= 4 && (data[0] == DHT_REQUEST_ID || data[0] == DHT_RESPONSE_ID)) {
67 ctx->rpc.on_message(data, (size_t)read_len, (const struct sockaddr_in *)from);
68 }
69}
70
71static void on_tick(uv_timer_t *h) {
72 Ctx *ctx = static_cast<Ctx *>(h->data);
73 const uint64_t now = uv_now(&ctx->loop);
74 const int phase_ms = ctx->phase_ms;
75 ctx->rpc.tick(now);
76 ctx->dht.tick(now);
77
78 switch (ctx->phase) {
79 case BOOTSTRAPPING:
80 if (!ctx->rpc.is_bootstrapped()) break;
81 printf(" bootstrapped; announcing the topic\n");
82 ctx->dht.announce(ctx->topic);
83 ctx->phase = ANNOUNCING;
84 ctx->phase_until = now + phase_ms;
85 break;
86
87 case ANNOUNCING:
88 if (now < ctx->phase_until) break;
89 printf(" looking the topic up BEFORE the unannounce\n");
90 ctx->collecting = &ctx->holders_before;
91 ctx->dht.lookup(ctx->topic);
92 ctx->phase = LOOKUP_BEFORE;
93 ctx->phase_until = now + phase_ms;
94 break;
95
96 case LOOKUP_BEFORE:
97 if (now < ctx->phase_until) break;
98 printf(" %zu node(s) hold the record; unannouncing\n", ctx->holders_before.size());
99 ctx->collecting = nullptr;
100 ctx->dht.unannounce(ctx->topic);
101 ctx->phase = UNANNOUNCING;
102 ctx->phase_until = now + phase_ms + 4000;
103 break;
104
105 case UNANNOUNCING:
106 if (now < ctx->phase_until) break;
107 printf(" looking the topic up AFTER the unannounce\n");
108 ctx->collecting = &ctx->holders_after;
109 ctx->dht.lookup(ctx->topic);
110 ctx->phase = LOOKUP_AFTER;
111 ctx->phase_until = now + phase_ms + 4000;
112 break;
113
114 case LOOKUP_AFTER:
115 if (now < ctx->phase_until) break;
116 ctx->phase = DONE;
117 uv_stop(&ctx->loop);
118 break;
119
120 case DONE:
121 break;
122 }
123}
124
125static void on_deadline(uv_timer_t *h) {
126 uv_stop(&static_cast<Ctx *>(h->data)->loop);
127}
128
129int main(int argc, char **argv) {
130 if (sodium_init() < 0) return 1;
131 if (argc < 2) { printf("usage: %s <host:port,...> [phase_ms]\n", argv[0]); return 2; }
132 // A bigger testnet needs longer for an announce to settle and for a lookup
133 // to walk it. Passed in rather than guessed, because a phase that ends early
134 // reports "no holders" and that is indistinguishable from a real absence.
135 const int phase_ms = (argc > 2) ? atoi(argv[2]) : 8000;
136
137 std::vector<std::string> boots;
138 {
139 std::string all(argv[1]);
140 size_t p = 0;
141 while (p <= all.size()) {
142 size_t c = all.find(',', p);
143 if (c == std::string::npos) c = all.size();
144 if (c > p) boots.push_back(all.substr(p, c - p));
145 p = c + 1;
146 }
147 }
148 std::vector<const char *> bootv;
149 for (auto &b : boots) bootv.push_back(b.c_str());
150 printf(" bootstrap: %s\n", argv[1]);
151
152 Ctx *ctx = new Ctx();
153 uv_loop_init(&ctx->loop);
154 udx_init(&ctx->loop, &ctx->udx, nullptr);
155 udx_socket_init(&ctx->udx, &ctx->socket, nullptr);
156 ctx->socket.data = ctx;
157
158 struct sockaddr_in bind_addr;
159 uv_ip4_addr("0.0.0.0", 0, &bind_addr);
160 udx_socket_bind(&ctx->socket, (struct sockaddr *)&bind_addr, 0);
161 udx_socket_recv_start(&ctx->socket, on_socket_recv);
162
163 ctx->rpc.init(&ctx->socket, &ctx->loop);
164 ctx->rpc.set_bootstrap(bootv.data(), (int)bootv.size());
165
166 struct sockaddr_in local_addr;
167 int alen = sizeof(local_addr);
168 udx_socket_getsockname(&ctx->socket, (struct sockaddr *)&local_addr, &alen);
169 IPv4Address local;
170 memcpy(local.ip, &local_addr.sin_addr.s_addr, 4);
171 local.port = ntohs(local_addr.sin_port);
172 ctx->rpc.set_local_address(local);
173
174 Ed25519KeyPair kp;
175 crypto_sign_keypair(kp.public_key, kp.secret_key);
176 memcpy(ctx->our_pk, kp.public_key, 32);
177 ctx->dht.init(&ctx->rpc, kp);
178 ctx->phase_ms = phase_ms;
179 randombytes_buf(ctx->topic, 32);
180
181 // One FoundPeer per answering node per peer entry. We only care about the
182 // entries that are US, and about WHICH node answered with them.
183 ctx->dht.on_peer_found = [ctx](uint32_t, const FoundPeer &fp) {
184 if (!ctx->collecting) return;
185 if (memcmp(fp.public_key, ctx->our_pk, 32) != 0) return;
186 ctx->collecting->insert(addr_key(fp.relay_node));
187 };
188
189 ctx->rpc.bootstrap();
190
191 uv_timer_init(&ctx->loop, &ctx->timer);
192 ctx->timer.data = ctx;
193 uv_timer_start(&ctx->timer, on_tick, 0, 20);
194
195 uv_timer_init(&ctx->loop, &ctx->deadline);
196 ctx->deadline.data = ctx;
197 uv_timer_start(&ctx->deadline, on_deadline, (uint64_t)phase_ms * 6 + 40000, 0);
198
199 uv_run(&ctx->loop, UV_RUN_DEFAULT);
200
201 int checks = 0, failures = 0;
202 auto check = [&](bool c, const char *what) {
203 checks++;
204 if (c) { printf(" PASS: %s\n", what); return; }
205 failures++;
206 printf(" FAIL: %s\n", what);
207 };
208
209 printf("\n");
210 check(ctx->phase == DONE, "the whole sequence ran");
211 // ⛔ BASELINE. Without holders before, "no holders after" means nothing —
212 // it is the same shape as a gate reporting silence from a swarm that never
213 // meshed.
214 check(!ctx->holders_before.empty(),
215 "at least one node held the record BEFORE the unannounce");
216 check(ctx->holders_after.empty(),
217 "NO node still holds the record after the unannounce");
218
219 printf("\n holders before=%zu after=%zu\n",
220 ctx->holders_before.size(), ctx->holders_after.size());
221 if (!ctx->holders_after.empty()) {
222 printf(" still holding:");
223 for (const std::string &a : ctx->holders_after) printf(" %s", a.c_str());
224 printf("\n");
225 }
226 printf("\n%d/%d passed\n", checks - failures, checks);
227 return failures ? 1 : 0;
228}