OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer commits / 6fb11ed

swarmd: gates D5 and D7 — one passes, one found a hole three layers deep
D7 passes, in both directions. STATUS must not claim health it does not have — #72's panel said Connected while the daemon was gone — so test_client asserts a daemon pointed at a dead bootstrap reports neither `bootstrapped` nor dht_state 2, and swarmd-probe now prints the STATUS line so the e2e gates can check the positive half: dht_state=2 bootstrapped=1 against a live testnet. 30/30. D5 is RED and stays red, because what it found is real: **nothing detects a silently dead peer on an idle stream.** Kill one of two meshed daemons with SIGKILL and the survivor reports peers=1 for the whole window — measured at 10s, 45s, 60s and 90s. Three layers each look like they handle it: 1. DhtConnector's DHT_CONN_CONNECTED keepalive branch is UNREACHABLE. _finalize_connection frees the connection the instant it reaches CONNECTED, with the comment "UDX stream takes over with its own keepalive". Its two constants describe liveness that happens nowhere. 2. libudx's keepalive was never switched on — nothing called udx_stream_set_keepalive. It is called now, which keeps an idle stream's NAT mapping open. 3. ⛔ And that still is not liveness. send_probe writes a bare UDX_HEADER_HEARTBEAT with uv_udp_try_send, outside the packet and RTO machinery, and libudx's own TODO at that line says the packet that would fix it is not written. No retransmit, no UDX_MAX_RTO_TIMEOUTS, no close_stream(UV_ETIMEDOUT) — which is the only route to _on_stream_close, on_mux_gone and PEER_DOWN. ⚠ Two fixes were tried and neither worked, which is why this is written down rather than attempted a third time. Propagating DhtConnector::on_failed to disconnect_peer was dead code — the connector never reaches on_failed for an established peer — and is reverted. What it needs is an application-level ping at our layer with its own timeout and teardown, driving the close path that already exists. That is a component, not an edit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6fb11edd528b26bc70f5a6b443f0f203ceeda075
by plankatron <fleeky@gmail.com> · 2026-09-10T22:31:32.000Z

Changed files (9) +269 -1

docs/SWARMD-DESIGN.md +44 -1
……@@ -131,7 +131,7 @@ content directory, which is also what makes a fetch resumable across restarts.
131131| D2 | a client that stops reading is dropped; survivors' streams stay intact |
132132| D3 | `SEND` past the high-water mark returns `writable=false`, `DRAIN` follows |
133133| D4 | two channels on one connection, neither starves the other |
134| D5 | `PEER_DOWN` on channel close, not on timeout |
134| D5 | `PEER_DOWN` on channel close, not on timeout — **written, RED, see below** |
135135| D6 | a 100 MB file crosses by hash and no frame exceeds the cap |
136136| D7 | `STATUS` reports `dht=down` when the DHT is down |
137137
……@@ -139,6 +139,49 @@ D1 and D7 are the gates #68 proved the suite lacked: every existing
139139cross-machine test passes `--bootstrap` at a local testnet, the one configuration
140140the products never use.
141141
142**D1, D2, D3, D4, D6 and D7 pass.** `test/test_peer_down.py` is **D5, and it is
143RED** — deliberately kept that way, because what it found is a real hole and not
144a gate bug.
145
146### 8.1 D5: nothing detects a silently dead peer on an idle stream
147
148Kill one of two meshed daemons with SIGKILL and the survivor reports `peers=1`
149for the whole window. Measured at 10s, 45s, 60s and 90s deadlines: never.
150
151Three layers each look like they handle it and none does.
152
1531. **DhtConnector's keepalive is unreachable.** `_tick_connection` has a
154 `DHT_CONN_CONNECTED` branch that probes every `DHT_KEEPALIVE_INTERVAL_MS`
155 (10s) and fails the connection after `DHT_KEEPALIVE_TIMEOUT_MS` (30s). But
156 `_finalize_connection` calls `_free_connection(conn)` the instant the
157 connection reaches CONNECTED — "UDX stream takes over with its own
158 keepalive". So that branch cannot run, and its two constants describe
159 liveness that happens nowhere.
160
1612. **libudx's keepalive was never switched on.** Nothing called
162 `udx_stream_set_keepalive`, so `keepalive_timeout_ms` was 0 and an idle
163 stream said nothing at all. It is called now.
164
1653. **⛔ AND IT STILL IS NOT LIVENESS.** `send_probe` writes a bare
166 `UDX_HEADER_HEARTBEAT` with `uv_udp_try_send`, outside the packet and RTO
167 machinery. libudx says so at that line: `// todo: send a data packet with
168 seq=remote_acked-1 instead`. An unanswered heartbeat therefore produces no
169 retransmit, never reaches `UDX_MAX_RTO_TIMEOUTS` (6), and never calls
170 `close_stream(UV_ETIMEDOUT)` — which is the only thing that would reach
171 `_on_stream_close` → `on_mux_gone` → `PEER_DOWN`.
172
173Only a stream with real data outstanding dies. An idle one is immortal.
174
175⚠ Two fixes were tried and neither worked, which is why this is written down
176instead of a third: propagating `DhtConnector::on_failed` to `disconnect_peer`
177(dead code — the connector never reaches `on_failed` for an established peer),
178and switching the libudx keepalive on (real, useful for NAT, and not liveness).
179
180**What it needs:** an application-level ping at our layer — a small periodic
181message on a reserved channel, a timeout, and `disconnect_peer` when it lapses —
182so the close path that already exists gets driven. That is a component, not an
183edit, and it should be designed rather than patched in.
184
142185## 9. Order
143186
1441871. ✅ `libcompact` replaces `src/compact_encoding.h` — useful alone, no schema needed
src/udx_transport.cpp +22 -0
……@@ -1027,6 +1027,28 @@ PeerConnection *UdxTransport::_add_peer(int32_t peer_id, uint32_t local_sid,
10271027 udx_socket_t *use_socket = socket_override ? socket_override : &_socket;
10281028 udx_stream_connect(&peer->stream, use_socket, remote_sid, addr);
10291029
1030 // Keep an idle stream's NAT mapping open. Nothing called this before, and
1031 // DhtConnector's comment at the point it frees a CONNECTED connection —
1032 // "UDX stream takes over with its own keepalive" — described something that
1033 // was therefore not happening.
1034 //
1035 // ⛔ IT IS NOT LIVENESS, AND IT CANNOT BE. libudx's send_probe writes a bare
1036 // UDX_HEADER_HEARTBEAT with uv_udp_try_send; it never enters the packet or
1037 // RTO machinery, and libudx says so itself at that line:
1038 //
1039 // // todo: send a data packet with seq=remote_acked-1 instead
1040 //
1041 // So an unanswered heartbeat produces no retransmit, never reaches
1042 // UDX_MAX_RTO_TIMEOUTS, and never calls close_stream(UV_ETIMEDOUT). Only a
1043 // stream with real data outstanding dies that way. An IDLE stream to a
1044 // process that has been killed stays "connected" for ever — measured, in
1045 // swarmd's gate D5: kill one of two meshed daemons and the survivor still
1046 // reports peers=1 ninety seconds later.
1047 //
1048 // Detecting that is OURS to build, above this layer, and it is not built.
1049 // See docs/SWARMD-DESIGN.md §8 (D5) and PARITY-hyperswarm.md §17.
1050 udx_stream_set_keepalive(&peer->stream, (uint32_t)DHT_KEEPALIVE_INTERVAL_MS);
1051
10301052 // Enable keepalive probes every 10s to detect dead connections.
10311053 udx_stream_set_keepalive(&peer->stream, 10000);
10321054
swarmd/test/test_client.cpp +8 -0
……@@ -114,10 +114,18 @@ int main() {
114114 "send() on a channel no peer has open is REFUSED, not silently dropped");
115115 check(!writable, "and the channel is not reported writable");
116116
117 // ⛔ GATE D7, the half that can be asserted without a network: STATUS must
118 // say the DHT is NOT up when it is not. #72's panel said Connected while
119 // the daemon was gone, and a status that only ever reports health is
120 // worse than no status — it is a reason to stop looking. This daemon is
121 // pointed at a bootstrap that answers nothing, so `up` here would be a
122 // lie with nothing behind it.
117123 swarmd::Status st;
118124 check(c.status(st), "status() answered");
119125 check(st.local_clients >= 1, "and counts this client");
120126 check(st.peers_connected == 0, "and reports no peers, which is the truth here");
127 check(!st.bootstrapped, "D7: it does NOT claim to be bootstrapped against a dead bootstrap");
128 check(st.dht_state != 2, "D7: and does not report the DHT up");
121129
122130 // ---- content ----
123131 uint8_t hash[32], hash2[32];
swarmd/test/test_peer_down.py +168 -0
……@@ -0,0 +1,168 @@
1#!/usr/bin/env python3
2"""Gate D5: a peer that goes away is reported on CLOSE, not on a timeout.
3
4⛔ THIS GATE IS RED, AND THE DEFECT IT FOUND IS REAL. Nothing detects a silently
5dead peer on an IDLE stream. Traced 2026-09-10:
6
7 * DhtConnector has a DHT_CONN_CONNECTED keepalive branch with a 30s timeout —
8 unreachable, because _finalize_connection frees the connection the instant it
9 reaches CONNECTED, "UDX stream takes over with its own keepalive".
10 * libudx's keepalive was never switched on. It is now, and it still is not
11 liveness: send_probe writes a bare UDX_HEADER_HEARTBEAT with
12 uv_udp_try_send, outside the packet and RTO machinery — libudx's own TODO at
13 that line says the packet that would fix this is not written. So no
14 retransmit, no UDX_MAX_RTO_TIMEOUTS, no close_stream(UV_ETIMEDOUT).
15 * Only a stream with real data outstanding dies. An idle one never does.
16
17Measured: kill one of two meshed daemons and the survivor reports peers=1 for
18the whole window — 10s, 45s, 60s, 90s, all the same. The fix is an
19application-level liveness ping with its own timeout and teardown, above the
20transport. It is not built. See docs/SWARMD-DESIGN.md §8.
21
22
23 python3 -B test/test_peer_down.py [--bootstrap host:port] [--seconds N]
24
25Two daemons meet on a topic; one is killed; the survivor's client must be told.
26
27⛔ THE NUMBER THAT MAKES THIS A GATE IS THE DEADLINE, and it is not arbitrary.
28DHT_KEEPALIVE_TIMEOUT_MS is 30 seconds (src/dht_connector.h): if PEER_DOWN only
29ever arrived because a keepalive lapsed, it would still arrive — thirty seconds
30late, with the application meanwhile addressing a peer that is not there. So the
31assertion is that it lands WELL inside that, which a timeout-driven
32implementation cannot do.
33
34⛔ SIGKILL, NOT SIGTERM. A clean shutdown is the easy case and not the one that
35hurts: a process that was killed never gets to say goodbye, and the stream close
36is the only signal there is.
37
38⛔ IT ASSERTS ITS OWN BASELINE. If the two daemons never meet, "no PEER-DOWN"
39means nothing, so a swarm that never meshed exits INCONCLUSIVE rather than FAIL.
40"""
41
42import os, re, signal, subprocess, sys, tempfile, time
43
44HERE = os.path.dirname(os.path.realpath(__file__))
45ROOT = os.path.dirname(HERE)
46SWARMD = os.path.join(ROOT, "swarmd")
47PROBE = os.path.join(ROOT, "tools", "swarmd-probe")
48EXIT_PASS, EXIT_FAIL, EXIT_INCONCLUSIVE = 0, 1, 2
49
50TOPIC = "d5" * 32
51PROTOCOL = "swarmd-peerdown/v1"
52
53# Comfortably inside DHT_KEEPALIVE_TIMEOUT_MS (30s). A timeout-driven report
54# cannot land here; a close-driven one lands almost immediately.
55DOWN_DEADLINE_S = int(os.environ.get("DOWN_DEADLINE_S", "10"))
56
57
58def arg(name, default=None):
59 return sys.argv[sys.argv.index(name) + 1] if name in sys.argv else default
60
61
62def read(path):
63 try:
64 with open(path, "rb") as fh:
65 return fh.read().decode("utf-8", "replace")
66 except OSError:
67 return ""
68
69
70def wait_for(path, needle, timeout, proc=None):
71 end = time.time() + timeout
72 while time.time() < end:
73 if proc is not None and proc.poll() is not None:
74 return False
75 if needle in read(path):
76 return True
77 time.sleep(0.2)
78 return False
79
80
81def main():
82 bootstrap = arg("--bootstrap")
83 window = int(arg("--seconds", "90" if bootstrap else "240"))
84
85 for path in (SWARMD, PROBE):
86 if not os.path.isfile(path):
87 print("missing %s — run ./build.sh" % path)
88 return EXIT_INCONCLUSIVE
89
90 tmp = tempfile.mkdtemp(prefix="swarmd-peerdown-")
91 procs, logs = [], {}
92
93 def spawn(argv, tag):
94 logs[tag] = os.path.join(tmp, tag + ".log")
95 fh = open(logs[tag], "wb")
96 p = subprocess.Popen(argv, stdout=fh, stderr=subprocess.STDOUT)
97 procs.append(p)
98 return p
99
100 try:
101 daemons = {}
102 for tag in ("a", "b"):
103 argv = [SWARMD, "--socket", os.path.join(tmp, tag + ".sock")]
104 if bootstrap:
105 argv += ["--bootstrap", bootstrap]
106 daemons[tag] = spawn(argv, tag)
107
108 for tag in ("a", "b"):
109 if not wait_for(logs[tag], "dht=up", 90, daemons[tag]):
110 print("\nINCONCLUSIVE: daemon %s never reached dht=up." % tag)
111 return EXIT_INCONCLUSIVE
112 print(" both daemons are on the DHT")
113
114 watchers = {}
115 for tag in ("a", "b"):
116 watchers[tag] = spawn([PROBE, "--socket", os.path.join(tmp, tag + ".sock"),
117 "--topic-hex", TOPIC, "--protocol", PROTOCOL,
118 "--watch-peers", "--seconds", str(window)],
119 "watch-" + tag)
120
121 # ---- baseline: they have to meet before a departure means anything ----
122 if not wait_for(logs["watch-a"], "PEER-UP", window, watchers["a"]):
123 print("\nINCONCLUSIVE: daemon A never saw a peer, so its silence "
124 "afterwards says nothing.")
125 return EXIT_INCONCLUSIVE
126 print(" A saw the peer arrive")
127
128 # D7's other half, read from a client rather than from a log line.
129 status = re.search(r"STATUS dht_state=(\d+) bootstrapped=(\d+)", read(logs["watch-a"]))
130 if status:
131 print(" A's STATUS: dht_state=%s bootstrapped=%s"
132 % (status.group(1), status.group(2)))
133
134 # ---- the departure ----
135 killed_at = time.time()
136 daemons["b"].send_signal(signal.SIGKILL)
137 daemons["b"].wait(timeout=10)
138 print(" B killed with SIGKILL")
139
140 seen = wait_for(logs["watch-a"], "PEER-DOWN", DOWN_DEADLINE_S, watchers["a"])
141 elapsed = time.time() - killed_at
142
143 if not seen:
144 print("\nFAIL: A was not told its peer had gone within %ds. "
145 "DHT_KEEPALIVE_TIMEOUT_MS is 30s, so a report driven by a "
146 "timeout rather than by the close would look exactly like this."
147 % DOWN_DEADLINE_S)
148 print("--- A ---\n%s" % read(logs["watch-a"])[-1500:])
149 return EXIT_FAIL
150
151 print("\nPASS: PEER-DOWN reached the client %.1fs after the peer was "
152 "killed (deadline %ds, keepalive timeout 30s)." % (elapsed, DOWN_DEADLINE_S))
153 if status and status.group(1) != "2":
154 print(" ⚠ but STATUS reported dht_state=%s while the daemon's own log "
155 "said dht=up — those disagree." % status.group(1))
156 return EXIT_FAIL
157 return EXIT_PASS
158 finally:
159 for p in procs:
160 if p.poll() is None:
161 p.terminate()
162 try: p.wait(timeout=5)
163 except subprocess.TimeoutExpired: p.kill()
164 print("(logs: %s)" % tmp)
165
166
167if __name__ == "__main__":
168 sys.exit(main())
swarmd/tools/swarmd_probe.cpp +27 -0
……@@ -46,6 +46,7 @@ int main(int argc, char **argv) {
4646 const char *sock = nullptr, *topic_hex = nullptr, *proto = "swarmd-probe/v1";
4747 const char *send_text = nullptr, *serve_file = nullptr;
4848 const char *fetch_hex = nullptr, *dest = nullptr;
49 bool watch_peers = false;
4950 int seconds = 30;
5051 for (int i = 1; i < argc; i++) {
5152 if (!strcmp(argv[i], "--socket") && i + 1 < argc) sock = argv[++i];
……@@ -55,6 +56,7 @@ int main(int argc, char **argv) {
5556 else if (!strcmp(argv[i], "--serve-file") && i + 1 < argc) serve_file = argv[++i];
5657 else if (!strcmp(argv[i], "--fetch-hex") && i + 1 < argc) fetch_hex = argv[++i];
5758 else if (!strcmp(argv[i], "--dest") && i + 1 < argc) dest = argv[++i];
59 else if (!strcmp(argv[i], "--watch-peers")) watch_peers = true;
5860 else if (!strcmp(argv[i], "--seconds") && i + 1 < argc) seconds = atoi(argv[++i]);
5961 }
6062 uint8_t topic[32];
……@@ -83,6 +85,18 @@ int main(int argc, char **argv) {
8385 fail_reason = why;
8486 failed = true;
8587 };
88 // Gate D5 reads these: a peer going away must be reported when the channel
89 // closes, not when something times out.
90 if (watch_peers) {
91 c.on.on_peer_up = [](uint64_t peer, const uint8_t *) {
92 printf("PEER-UP %llu\n", (unsigned long long)peer);
93 fflush(stdout);
94 };
95 c.on.on_peer_down = [](uint64_t peer) {
96 printf("PEER-DOWN %llu\n", (unsigned long long)peer);
97 fflush(stdout);
98 };
99 }
86100 c.on.on_disconnect = [&](const std::string &why) {
87101 fprintf(stderr, "swarmd went away: %s\n", why.c_str());
88102 };
……@@ -96,6 +110,16 @@ int main(int argc, char **argv) {
96110 const uint64_t ch = c.open_channel(proto, {}, 2);
97111 if (ch == 0) { fprintf(stderr, "channel failed: %s\n", c.last_error().c_str()); return 2; }
98112 printf("joined topic, opened %s\n", proto);
113 {
114 // D7's other half: what the daemon says about itself, from a client
115 // rather than from its own log line.
116 swarmd::Status st;
117 if (c.status(st)) {
118 printf("STATUS dht_state=%llu bootstrapped=%d peers=%llu\n",
119 (unsigned long long)st.dht_state, st.bootstrapped ? 1 : 0,
120 (unsigned long long)st.peers_connected);
121 }
122 }
99123 fflush(stdout);
100124
101125 // ---- content ----
……@@ -135,6 +159,9 @@ int main(int argc, char **argv) {
135159 time_t last_try = 0;
136160
137161 while (time(nullptr) < deadline && !received && !fetched && !failed) {
162 if (watch_peers && !send_text && !fetch_hex) {
163 // A watcher has nothing of its own to finish; it runs the window out.
164 }
138165 if (send_text && !sent && time(nullptr) != last_try) {
139166 last_try = time(nullptr);
140167 attempts++;
test/test_bootstrap_ownership +0 -0
(no content changes — binary or rename)
test/test_regression +0 -0
(no content changes — binary or rename)
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)