| … | … | @@ -131,7 +131,7 @@ content directory, which is also what makes a fetch resumable across restarts. |
| 131 | 131 | | D2 | a client that stops reading is dropped; survivors' streams stay intact | |
| 132 | 132 | | D3 | `SEND` past the high-water mark returns `writable=false`, `DRAIN` follows | |
| 133 | 133 | | D4 | two channels on one connection, neither starves the other | |
| 134 | | D5 | `PEER_DOWN` on channel close, not on timeout — **written, RED, see below** | | |
| 134 | | D5 | `PEER_DOWN` when a peer dies — **PASS**, 18.0s on the public DHT | | |
| 135 | 135 | | D6 | a 100 MB file crosses by hash and no frame exceeds the cap | |
| 136 | 136 | | D7 | `STATUS` reports `dht=down` when the DHT is down | |
| 137 | 137 | |
| … | … | @@ -139,11 +139,10 @@ D1 and D7 are the gates #68 proved the suite lacked: every existing |
| 139 | 139 | cross-machine test passes `--bootstrap` at a local testnet, the one configuration |
| 140 | 140 | the products never use. |
| 141 | 141 | |
| 142 | **D1, D2, D3, D4, D6 and D7 pass.** `test/test_peer_down.py` is **D5, and it is | |
| 143 | RED** — deliberately kept that way, because what it found is a real hole and not | |
| 144 | a gate bug. | |
| 142 | **All seven pass.** D5 was red for a day and what it found was a real hole; the | |
| 143 | trace below is kept because the shape recurs. | |
| 145 | 144 | |
| 146 | ### 8.1 D5: nothing detects a silently dead peer on an idle stream | |
| 145 | ### 8.1 D5: nothing detected a silently dead peer on an idle stream (FIXED) | |
| 147 | 146 | |
| 148 | 147 | Kill one of two meshed daemons with SIGKILL and the survivor reports `peers=1` |
| 149 | 148 | for the whole window. Measured at 10s, 45s, 60s and 90s deadlines: never. |
| … | … | @@ -177,10 +176,31 @@ instead of a third: propagating `DhtConnector::on_failed` to `disconnect_peer` |
| 177 | 176 | (dead code — the connector never reaches `on_failed` for an established peer), |
| 178 | 177 | and switching the libudx keepalive on (real, useful for NAT, and not liveness). |
| 179 | 178 | |
| 180 | **What it needs:** an application-level ping at our layer — a small periodic | |
| 181 | message on a reserved channel, a timeout, and `disconnect_peer` when it lapses — | |
| 182 | so the close path that already exists gets driven. That is a component, not an | |
| 183 | edit, and it should be designed rather than patched in. | |
| 179 | **The fix**, in `UdxTransport::_sweep_keepalive`, at our layer because libudx's | |
| 180 | cannot be made to detect death without changing libudx: | |
| 181 | ||
| 182 | * **send** — an idle peer gets a ZERO-LENGTH ENCRYPTED FRAME every | |
| 183 | `PEER_KEEPALIVE_INTERVAL_MS` (5s, hyperdht's `connectionKeepAlive`). That is | |
| 184 | the reference's wire form exactly: `@hyperswarm/secret-stream`'s | |
| 185 | `sendKeepAlive` writes `this.alloc(0)`, and its receiver eats the result | |
| 186 | (`if (plain.byteLength === 0 && this.keepAlive !== 0) return`). A write of any | |
| 187 | kind counts, so a busy peer never sends one. | |
| 188 | * **kill** — a peer silent for `PEER_DEAD_MS` (20s, four missed) is | |
| 189 | disconnected through the existing CMD_DISCONNECT path, which destroys the | |
| 190 | stream and therefore drives `_on_stream_close` → `on_mux_gone` → `PEER_DOWN`. | |
| 191 | ||
| 192 | ⛔ **The kill only arms once the peer has sent a keepalive itself.** A build that | |
| 193 | predates this sends nothing while idle, and killing it after twenty seconds would | |
| 194 | make an idle connection a flag day. Until a peer proves it speaks keepalives, its | |
| 195 | silence means what it always meant. | |
| 196 | ||
| 197 | ⛔ It also required `NoiseStream::decrypt` to grow an answer it could not give: | |
| 198 | `{}` meant BOTH an authentication failure and a legitimately empty plaintext, so | |
| 199 | a keepalive was indistinguishable from an attack. | |
| 200 | ||
| 201 | fixed PEER-DOWN 17.8s on a testnet, 18.0s on the PUBLIC DHT | |
| 202 | mutated never (the keepalive send removed — and note the kill correctly | |
| 203 | stays disarmed, which is the compatibility guarantee working) | |
| 184 | 204 | |
| 185 | 205 | ## 9. Order |
| 186 | 206 |
| … | … | @@ -40,6 +40,25 @@ std::vector<uint8_t> NoiseStream::encrypt(const uint8_t *data, size_t len) { |
| 40 | 40 | return ct; |
| 41 | 41 | } |
| 42 | 42 | |
| 43 | bool NoiseStream::decrypt(const uint8_t *data, size_t len, std::vector<uint8_t> &out) { | |
| 44 | out.clear(); | |
| 45 | if (len < TAG_BYTES) return false; | |
| 46 | ||
| 47 | std::vector<uint8_t> pt(len - TAG_BYTES); | |
| 48 | unsigned long long pt_len; | |
| 49 | unsigned char tag; | |
| 50 | int ret = crypto_secretstream_xchacha20poly1305_pull( | |
| 51 | &_rx_state, | |
| 52 | pt.data(), &pt_len, &tag, | |
| 53 | data, len, | |
| 54 | nullptr, 0 // no additional data | |
| 55 | ); | |
| 56 | if (ret != 0) return false; // Authentication failure. | |
| 57 | pt.resize(static_cast<size_t>(pt_len)); | |
| 58 | out.swap(pt); | |
| 59 | return true; | |
| 60 | } | |
| 61 | ||
| 43 | 62 | std::vector<uint8_t> NoiseStream::decrypt(const uint8_t *data, size_t len) { |
| 44 | 63 | if (len < TAG_BYTES) return {}; |
| 45 | 64 |
| … | … | @@ -34,6 +34,18 @@ public: |
| 34 | 34 | // Decrypt ciphertext → plaintext. Returns empty on auth failure. |
| 35 | 35 | std::vector<uint8_t> decrypt(const uint8_t *data, size_t len); |
| 36 | 36 | |
| 37 | // ⛔ THE SAME CALL, WITH THE ONE ANSWER THE OTHER CANNOT GIVE. | |
| 38 | // | |
| 39 | // decrypt() returns {} for an authentication failure AND for a frame whose | |
| 40 | // plaintext is legitimately empty, and those must be told apart: an EMPTY | |
| 41 | // authenticated frame is the keepalive @hyperswarm/secret-stream sends | |
| 42 | // (`sendKeepAlive` writes `this.alloc(0)`), and the reference eats it on | |
| 43 | // receipt rather than treating it as an error. Conflating them means a | |
| 44 | // keepalive is indistinguishable from an attack. | |
| 45 | // | |
| 46 | // Returns false only on authentication failure. | |
| 47 | bool decrypt(const uint8_t *data, size_t len, std::vector<uint8_t> &out); | |
| 48 | ||
| 37 | 49 | // Get the TX header (24 bytes, send to remote for their RX init). |
| 38 | 50 | const uint8_t *tx_header() const { return _tx_header; } |
| 39 | 51 |
| … | … | @@ -367,6 +367,7 @@ static void _async_cb(uv_async_t *handle) { |
| 367 | 367 | static void _tick_cb(uv_timer_t *handle) { |
| 368 | 368 | UdxTransport *transport = static_cast<UdxTransport *>(handle->data); |
| 369 | 369 | transport->_process_commands(); |
| 370 | transport->_sweep_keepalive(uv_now(handle->loop)); | |
| 370 | 371 | } |
| 371 | 372 | |
| 372 | 373 | // ⛔ THE ONLY WAY TO WAKE THE LOOP, because the guard was previously written at |
| … | … | @@ -778,11 +779,26 @@ void UdxTransport::_dispatch_incoming(PeerConnection *peer, |
| 778 | 779 | // After noise_ready, ALL data is encrypted data — never check byte prefix. |
| 779 | 780 | if (peer->encrypted && peer->noise_ready) { |
| 780 | 781 | // Setup complete — decrypt data. No byte-prefix check (matches JS _setup=false). |
| 781 | auto pt = peer->noise->decrypt(buf_data, static_cast<size_t>(buf_len)); | |
| 782 | if (pt.empty()) { | |
| 782 | std::vector<uint8_t> pt; | |
| 783 | if (!peer->noise->decrypt(buf_data, static_cast<size_t>(buf_len), pt)) { | |
| 783 | 784 | return; // Auth failure — drop. |
| 784 | 785 | } |
| 785 | 786 | |
| 787 | // ⛔ ANY AUTHENTICATED FRAME IS EVIDENCE THIS PEER IS ALIVE, whatever it | |
| 788 | // carries. That is the signal _sweep_keepalive waits on, and it is | |
| 789 | // stamped here rather than in the protomux handler so a frame for a | |
| 790 | // channel nobody opened still counts. | |
| 791 | peer->last_recv_ms = _loop_now_ms; | |
| 792 | ||
| 793 | if (pt.empty()) { | |
| 794 | // The keepalive. @hyperswarm/secret-stream eats these on receipt | |
| 795 | // ("assume the other side has it enabled also") and so do we — | |
| 796 | // forwarding an empty frame to protomux would decode a channel id of | |
| 797 | // 0 and a type of 0 out of nothing at all. | |
| 798 | peer->peer_keepalive = true; | |
| 799 | return; | |
| 800 | } | |
| 801 | ||
| 786 | 802 | // Route through protomux if available. |
| 787 | 803 | if (peer->mux) { |
| 788 | 804 | DHT_LOG("[UdxTransport] dispatch: routing %zu decrypted bytes to protomux for peer %d\n", |
| … | … | @@ -1088,6 +1104,61 @@ int32_t UdxTransport::_next_peer_id() { |
| 1088 | 1104 | |
| 1089 | 1105 | // ---- Protomux helpers (UDX thread) ---- |
| 1090 | 1106 | |
| 1107 | // ⛔ THE LIVENESS THAT NOTHING ELSE PROVIDES. hyper-games #75 PART 3 traced the | |
| 1108 | // hole through three layers; this is the fix, and it is deliberately at OUR | |
| 1109 | // layer rather than libudx's, because libudx's heartbeat cannot be made to | |
| 1110 | // detect death without changing libudx. | |
| 1111 | // | |
| 1112 | // TWO HALVES, and the second is conditional on purpose: | |
| 1113 | // | |
| 1114 | // send an idle peer gets a ZERO-LENGTH encrypted frame every | |
| 1115 | // PEER_KEEPALIVE_INTERVAL_MS. That is the reference's wire form | |
| 1116 | // exactly — @hyperswarm/secret-stream's sendKeepAlive writes | |
| 1117 | // `this.alloc(0)` — so a JS peer eats ours and we eat theirs. A write | |
| 1118 | // of any kind counts, so a busy peer never sends one. | |
| 1119 | // | |
| 1120 | // kill a peer that has sent us nothing for PEER_DEAD_MS is disconnected, | |
| 1121 | // which drives the close path that already exists: _on_stream_close -> | |
| 1122 | // on_mux_gone -> PEER_DOWN. | |
| 1123 | // | |
| 1124 | // ⛔ THE KILL ONLY ARMS ONCE THE PEER HAS SENT A KEEPALIVE ITSELF. A build that | |
| 1125 | // predates this sends nothing while idle, and killing it after twenty seconds | |
| 1126 | // would make an idle connection a flag day — the one thing the swarmd-session | |
| 1127 | // work went out of its way to avoid. Until a peer proves it speaks keepalives, | |
| 1128 | // its silence means what it meant before: nothing. | |
| 1129 | void UdxTransport::_sweep_keepalive(uint64_t now_ms) { | |
| 1130 | _loop_now_ms = now_ms; | |
| 1131 | ||
| 1132 | std::vector<int32_t> dead; | |
| 1133 | for (auto &kv : _peers) { | |
| 1134 | PeerConnection *peer = kv.second; | |
| 1135 | if (!peer || !peer->connected || !peer->noise_ready) continue; | |
| 1136 | ||
| 1137 | // A peer that has never been heard from starts its clock now rather than | |
| 1138 | // at zero, or it is declared dead the moment it connects. | |
| 1139 | if (peer->last_recv_ms == 0) peer->last_recv_ms = now_ms; | |
| 1140 | if (peer->last_send_ms == 0) peer->last_send_ms = now_ms; | |
| 1141 | ||
| 1142 | if (peer->peer_keepalive && now_ms - peer->last_recv_ms > PEER_DEAD_MS) { | |
| 1143 | DHT_LOG("[UdxTransport] peer %d silent for %llums — disconnecting\n", | |
| 1144 | peer->godot_peer_id, | |
| 1145 | (unsigned long long)(now_ms - peer->last_recv_ms)); | |
| 1146 | dead.push_back(kv.first); | |
| 1147 | continue; | |
| 1148 | } | |
| 1149 | ||
| 1150 | if (now_ms - peer->last_send_ms >= PEER_KEEPALIVE_INTERVAL_MS) { | |
| 1151 | _send_encrypted(peer, nullptr, 0); // stamps last_send_ms | |
| 1152 | } | |
| 1153 | } | |
| 1154 | ||
| 1155 | // Queued rather than done here: CMD_DISCONNECT already destroys the stream, | |
| 1156 | // pushes the peer event and removes the peer — a tested path — and doing it | |
| 1157 | // inline would mutate _peers while this loop walks it. It runs on the next | |
| 1158 | // tick, one millisecond later. | |
| 1159 | for (int32_t id : dead) disconnect_peer(id, true); | |
| 1160 | } | |
| 1161 | ||
| 1091 | 1162 | void UdxTransport::_send_encrypted(PeerConnection *peer, const uint8_t *data, size_t len) { |
| 1092 | 1163 | DHT_LOG("[UdxTransport] _send_encrypted: %zu bytes for peer %d (noise=%p noise_ready=%d)\n", |
| 1093 | 1164 | len, peer->godot_peer_id, (void *)peer->noise, peer->noise_ready); |
| … | … | @@ -1109,6 +1180,7 @@ void UdxTransport::_send_encrypted(PeerConnection *peer, const uint8_t *data, si |
| 1109 | 1180 | // stream from one it was burying, so a bulk sender (the overlay's chunk pump) |
| 1110 | 1181 | // had nothing to throttle against. |
| 1111 | 1182 | const int rc = udx_stream_write(req, &peer->stream, &buf, 1, _on_write_ack); |
| 1183 | peer->last_send_ms = _loop_now_ms; // a write IS a keepalive; see _sweep_keepalive | |
| 1112 | 1184 | peer->writable = rc > 0; |
| 1113 | 1185 | if (rc == 0) { |
| 1114 | 1186 | // Arm the drain edge exactly once per peer. udx calls on_drain when the |
| … | … | @@ -45,6 +45,21 @@ static inline int32_t pk_to_peer_id(const uint8_t *pk) { |
| 45 | 45 | } |
| 46 | 46 | |
| 47 | 47 | // Per-peer connection state managed by UdxTransport. |
| 48 | // ⛔ LIVENESS, because nothing else provides it. hyper-games #74/#75 traced this | |
| 49 | // through three layers: DhtConnector's keepalive branch is unreachable (the | |
| 50 | // connection is freed the instant it reaches CONNECTED), and libudx's keepalive | |
| 51 | // sends a bare heartbeat OUTSIDE the packet and RTO machinery — its own TODO | |
| 52 | // says so — so an unanswered one never reaches close_stream(UV_ETIMEDOUT). An | |
| 53 | // idle stream to a killed process stayed "connected" for ever. | |
| 54 | // | |
| 55 | // The wire form is the reference's: @hyperswarm/secret-stream's `sendKeepAlive` | |
| 56 | // writes a ZERO-LENGTH encrypted frame, and the receiver eats it | |
| 57 | // (`if (plain.byteLength === 0 && this.keepAlive !== 0) return`). | |
| 58 | static constexpr uint64_t PEER_KEEPALIVE_INTERVAL_MS = 5000; // hyperdht connectionKeepAlive | |
| 59 | // Four consecutive keepalives missed. Long enough that a stalled loop or a | |
| 60 | // hiccup does not cost a healthy peer, short enough to be useful. | |
| 61 | static constexpr uint64_t PEER_DEAD_MS = 20000; | |
| 62 | ||
| 48 | 63 | struct PeerConnection { |
| 49 | 64 | udx_stream_t stream; |
| 50 | 65 | uint32_t local_stream_id; |
| … | … | @@ -72,6 +87,16 @@ struct PeerConnection { |
| 72 | 87 | bool writable = true; |
| 73 | 88 | bool drain_armed = false; |
| 74 | 89 | |
| 90 | // ---- liveness (see UdxTransport::_sweep_keepalive) ---- | |
| 91 | // When we last received an AUTHENTICATED frame from this peer, and when we | |
| 92 | // last wrote to it. Both in the UDX loop's clock. | |
| 93 | uint64_t last_recv_ms = 0; | |
| 94 | uint64_t last_send_ms = 0; | |
| 95 | // True once this peer has sent an empty keepalive frame. Until it does, its | |
| 96 | // silence is not evidence of death — it may simply be a build that predates | |
| 97 | // keepalives, and killing it would be a flag day for idle connections. | |
| 98 | bool peer_keepalive = false; | |
| 99 | ||
| 75 | 100 | // Protomux multiplexer (created after Noise handshake completes). |
| 76 | 101 | Protomux *mux = nullptr; |
| 77 | 102 | // True once the godot-rpc channel has OPENED — the same instant on_mux_ready |
| … | … | @@ -173,6 +198,11 @@ public: |
| 173 | 198 | // Public because it's called from the libuv timer callback. |
| 174 | 199 | void _process_commands(); |
| 175 | 200 | |
| 201 | // UDX THREAD. Send a keepalive to any idle peer, and disconnect any peer that | |
| 202 | // has stopped answering. Public for the same reason _process_commands is: the | |
| 203 | // loop's timer callback is a free function. | |
| 204 | void _sweep_keepalive(uint64_t now_ms); | |
| 205 | ||
| 176 | 206 | public: |
| 177 | 207 | // ---- UDX-thread execution seam ----------------------------------------- |
| 178 | 208 | // |
| … | … | @@ -219,6 +249,10 @@ public: |
| 219 | 249 | // happened, because a consumer polls from frame one while the thread is still |
| 220 | 250 | // starting. Tasks queued early are safe; they are drained by the tick timer |
| 221 | 251 | // once the loop is up. |
| 252 | // The UDX loop's clock, sampled each tick. Everything on that thread that | |
| 253 | // needs "now" reads this rather than calling a clock of its own. | |
| 254 | uint64_t _loop_now_ms = 0; | |
| 255 | ||
| 222 | 256 | std::atomic<bool> _async_ready{ false }; |
| 223 | 257 | |
| 224 | 258 | // Signal the loop. The ONLY way — see the comment on the definition. |
| … | … | @@ -378,6 +412,8 @@ private: |
| 378 | 412 | // Encrypt and send via UDX stream (used as Protomux write callback). |
| 379 | 413 | void _send_encrypted(PeerConnection *peer, const uint8_t *data, size_t len); |
| 380 | 414 | |
| 415 | ||
| 416 | ||
| 381 | 417 | // Route decrypted payload to Godot PacketQueue (used by godot-rpc channel). |
| 382 | 418 | void _deliver_godot_packet(PeerConnection *peer, const uint8_t *data, size_t len); |
| 383 | 419 |
| … | … | @@ -94,10 +94,9 @@ a hundred million, and the gate fails at 256 KB. |
| 94 | 94 | `--storage <dir>` turns the content plane on. Without it the daemon refuses |
| 95 | 95 | content commands rather than accepting a fetch it has nowhere to put. |
| 96 | 96 | |
| 97 | D7 passes in both directions; **D5 is written and RED**, and what it found is a | |
| 98 | real hole rather than a gate bug — nothing detects a silently dead peer on an | |
| 99 | idle stream. `../docs/SWARMD-DESIGN.md` §8.1 has the trace through all three | |
| 100 | layers. Run it with: | |
| 97 | All seven gates pass. D5 was red for a day and what it found was real — nothing | |
| 98 | detected a silently dead peer on an idle stream; `../docs/SWARMD-DESIGN.md` §8.1 | |
| 99 | keeps the trace through all three layers and the fix. Run it with: | |
| 101 | 100 | |
| 102 | 101 | python3 -B test/test_peer_down.py --bootstrap host:port |
| 103 | 102 |
| … | … | @@ -1,42 +1,31 @@ |
| 1 | 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 | |
| 5 | dead 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 | ||
| 17 | Measured: kill one of two meshed daemons and the survivor reports peers=1 for | |
| 18 | the whole window — 10s, 45s, 60s, 90s, all the same. The fix is an | |
| 19 | application-level liveness ping with its own timeout and teardown, above the | |
| 20 | transport. It is not built. See docs/SWARMD-DESIGN.md §8. | |
| 21 | ||
| 2 | """Gate D5: a peer that goes away is reported, and not on a wall clock. | |
| 22 | 3 | |
| 23 | 4 | python3 -B test/test_peer_down.py [--bootstrap host:port] [--seconds N] |
| 24 | 5 | |
| 25 | Two daemons meet on a topic; one is killed; the survivor's client must be told. | |
| 6 | Two daemons meet on a topic; one is KILLED; the survivor's client must be told. | |
| 26 | 7 | |
| 27 | ⛔ THE NUMBER THAT MAKES THIS A GATE IS THE DEADLINE, and it is not arbitrary. | |
| 28 | DHT_KEEPALIVE_TIMEOUT_MS is 30 seconds (src/dht_connector.h): if PEER_DOWN only | |
| 29 | ever arrived because a keepalive lapsed, it would still arrive — thirty seconds | |
| 30 | late, with the application meanwhile addressing a peer that is not there. So the | |
| 31 | assertion is that it lands WELL inside that, which a timeout-driven | |
| 32 | implementation cannot do. | |
| 8 | ⛔ THE DEADLINE IS DERIVED FROM THE CONSTANT THAT GOVERNS IT, not picked. | |
| 9 | UdxTransport sends a zero-length encrypted keepalive to an idle peer every | |
| 10 | PEER_KEEPALIVE_INTERVAL_MS (5s) and disconnects one that has said nothing for | |
| 11 | PEER_DEAD_MS (20s) — so a correct implementation reports inside ~20 seconds and | |
| 12 | this allows 30. Set DOWN_DEADLINE_S to probe a different bound. | |
| 33 | 13 | |
| 34 | 14 | ⛔ SIGKILL, NOT SIGTERM. A clean shutdown is the easy case and not the one that |
| 35 | hurts: a process that was killed never gets to say goodbye, and the stream close | |
| 36 | is the only signal there is. | |
| 15 | hurts: a process that was killed never gets to say goodbye. | |
| 37 | 16 | |
| 38 | 17 | ⛔ IT ASSERTS ITS OWN BASELINE. If the two daemons never meet, "no PEER-DOWN" |
| 39 | 18 | means nothing, so a swarm that never meshed exits INCONCLUSIVE rather than FAIL. |
| 19 | ||
| 20 | ⚠ WHAT THIS GATE COST, recorded because the shape recurs (hyper-games #75): | |
| 21 | nothing detected a dead peer at all, and three layers each looked like they did. | |
| 22 | DhtConnector's DHT_CONN_CONNECTED keepalive branch is unreachable — the | |
| 23 | connection is freed the instant it reaches CONNECTED. libudx's keepalive was | |
| 24 | never switched on, and switching it on is not liveness either: send_probe writes | |
| 25 | a bare heartbeat OUTSIDE the packet and RTO machinery, and libudx's own TODO at | |
| 26 | that line says the packet that would fix it is not written. The fix had to be at | |
| 27 | our layer, and its wire form is the reference's — @hyperswarm/secret-stream | |
| 28 | sends an empty encrypted frame and eats the ones it receives. | |
| 40 | 29 | """ |
| 41 | 30 | |
| 42 | 31 | import os, re, signal, subprocess, sys, tempfile, time |
| … | … | @@ -50,9 +39,9 @@ EXIT_PASS, EXIT_FAIL, EXIT_INCONCLUSIVE = 0, 1, 2 |
| 50 | 39 | TOPIC = "d5" * 32 |
| 51 | 40 | PROTOCOL = "swarmd-peerdown/v1" |
| 52 | 41 | |
| 53 | # Comfortably inside DHT_KEEPALIVE_TIMEOUT_MS (30s). A timeout-driven report | |
| 54 | # cannot land here; a close-driven one lands almost immediately. | |
| 55 | DOWN_DEADLINE_S = int(os.environ.get("DOWN_DEADLINE_S", "10")) | |
| 42 | # PEER_DEAD_MS is 20s (src/udx_transport.h), so a working implementation reports | |
| 43 | # inside that. Thirty allows for the keepalive phase and a slow testnet. | |
| 44 | DOWN_DEADLINE_S = int(os.environ.get("DOWN_DEADLINE_S", "30")) | |
| 56 | 45 | |
| 57 | 46 | |
| 58 | 47 | def arg(name, default=None): |
| … | … | @@ -141,15 +130,15 @@ def main(): |
| 141 | 130 | elapsed = time.time() - killed_at |
| 142 | 131 | |
| 143 | 132 | 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." | |
| 133 | print("\nFAIL: A was not told its peer had gone within %ds, and " | |
| 134 | "PEER_DEAD_MS is 20s — so either the keepalive is not being " | |
| 135 | "sent, or the sweep is not disconnecting on it." | |
| 147 | 136 | % DOWN_DEADLINE_S) |
| 148 | 137 | print("--- A ---\n%s" % read(logs["watch-a"])[-1500:]) |
| 149 | 138 | return EXIT_FAIL |
| 150 | 139 | |
| 151 | 140 | print("\nPASS: PEER-DOWN reached the client %.1fs after the peer was " |
| 152 | "killed (deadline %ds, keepalive timeout 30s)." % (elapsed, DOWN_DEADLINE_S)) | |
| 141 | "killed (PEER_DEAD_MS 20s, deadline %ds)." % (elapsed, DOWN_DEADLINE_S)) | |
| 153 | 142 | if status and status.group(1) != "2": |
| 154 | 143 | print(" ⚠ but STATUS reported dht_state=%s while the daemon's own log " |
| 155 | 144 | "said dht=up — those disagree." % status.group(1)) |