// Gate: an unreadable holepunch reply fails the attempt NOW, not in 30 seconds.
//
// docs/PARITY-hyperswarm.md §11.1. JS wires the relay client's error straight
// to onabort (connect.js:814), which destroys the session immediately. Our three
// unreadable-reply branches logged and returned, so the attempt sat in
// HOLEPUNCHING until DHT_CONNECT_TIMEOUT_MS — half a minute of an outbound slot
// spent on an answer we had already received and could not use. With
// SWARM_MAX_PARALLEL = 3 that is a third of our outbound capacity per dead peer.
//
// ⛔ IT ALSO GATES THE GUARD. The id in a holepunch tag is reusable, so a late
// reply must NOT abort whatever holds that id now. That guard existed before
// this change and had no test — and this repo has been bitten twice by a stale
// gate hiding an untested guard, where deleting the guard looks like a fix
// (§8.3, §8.4). The negative cases below are half of this file on purpose.
#include <cstdio>
#include <cstring>
#include <vector>
#include <sodium.h>
#include "dht_connector.h"
#include "hyperdht.h"
extern "C" {
#include <udx.h>
#include <uv.h>
}
static int checks = 0, failures = 0;
static void check(bool cond, const char *what) {
checks++;
if (cond) { printf(" PASS: %s\n", what); return; }
failures++;
printf(" FAIL: %s\n", what);
}
int main() {
if (sodium_init() < 0) { printf("sodium_init failed\n"); return 1; }
uv_loop_t loop;
uv_loop_init(&loop);
udx_t udx;
udx_init(&loop, &udx, nullptr);
udx_socket_t sock;
udx_socket_init(&udx, &sock, nullptr);
struct sockaddr_in addr;
uv_ip4_addr("127.0.0.1", 0, &addr);
udx_socket_bind(&sock, (struct sockaddr *)&addr, 0);
DhtRpc rpc;
rpc.init(&sock, &loop);
Ed25519KeyPair kp;
crypto_sign_keypair(kp.public_key, kp.secret_key);
HyperDht dht;
dht.init(&rpc, kp);
DhtConnector connector;
connector.init(&dht, &sock, &loop);
// ⛔ COUNT FAILURES FOR THE ATTEMPT UNDER TEST, NOT ALL OF THEM.
//
// Blocks below deliberately leave connections ALIVE — that is what several
// of them assert — and tick() advances every active one. A bare counter
// therefore attributes an older block's timeout to the current block's
// assertion, which is how the first version of the staged-timeout case
// reported "failed one second before its deadline" for a failure that
// belonged to a connection created two blocks earlier.
uint32_t watch_id = 0;
int failed_count = 0;
std::string last_error;
connector.on_failed = [&](uint32_t id, const uint8_t *, const char *err) {
printf(" (on_failed conn=%u: %s)\n", id, err ? err : "?");
if (watch_id != 0 && id != watch_id) return;
failed_count++;
last_error = err ? err : "";
};
IPv4Address relay;
relay.ip[0] = 127; relay.ip[1] = 0; relay.ip[2] = 0; relay.ip[3] = 1;
relay.port = 49737;
uint8_t remote_pk[32];
randombytes_buf(remote_pk, 32);
// ---- 1. garbage, for an attempt that IS waiting on a relay ----
{
const uint32_t id = connector.connect(remote_pk);
watch_id = id;
check(id != 0, "connect() started an attempt");
check(connector.set_state_for_gate(id, DHT_CONN_HOLEPUNCHING),
"the attempt is waiting on its relay");
const uint8_t garbage[8] = { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff };
connector.on_holepunch_response(0x10000 | id, garbage, sizeof(garbage), relay);
check(failed_count == 1, "an unreadable reply fails the attempt immediately");
check(connector.find_connection(id) == nullptr, "and the slot is given back");
// It must not have needed the wall clock, and must not fire twice.
connector.tick(DHT_CONNECT_TIMEOUT_MS + 1000);
check(failed_count == 1, "no second failure when the old timeout passes");
}
// ---- 2. an EMPTY reply is not an error ----
//
// "The relay could not reach the peer" is what JS covers with its 15s
// relayTimeout, not with onabort. Failing here would be MORE aggressive than
// the reference, which is its own kind of divergence.
{
failed_count = 0;
randombytes_buf(remote_pk, 32);
const uint32_t id = connector.connect(remote_pk);
watch_id = id;
connector.set_state_for_gate(id, DHT_CONN_HOLEPUNCHING);
connector.on_holepunch_response(0x10000 | id, nullptr, 0, relay);
check(failed_count == 0, "an EMPTY reply does not abort — that is the relay's timeout, not an error");
check(connector.find_connection(id) != nullptr, "and the attempt is still alive");
}
// ---- 3. the guard: a reply for an attempt that is NOT waiting on a relay ----
{
failed_count = 0;
randombytes_buf(remote_pk, 32);
const uint32_t id = connector.connect(remote_pk); // still FINDING
watch_id = id;
const uint8_t garbage[8] = { 1, 2, 3, 4, 5, 6, 7, 8 };
connector.on_holepunch_response(0x10000 | id, garbage, sizeof(garbage), relay);
check(failed_count == 0,
"a holepunch reply for an attempt still in FINDING is IGNORED");
check(connector.find_connection(id) != nullptr,
"and that attempt is untouched — ids are reusable, so a late reply "
"must not kill whoever holds one now");
}
// ---- 4. the guard again: an id nobody holds ----
{
failed_count = 0;
const uint8_t garbage[8] = { 9, 9, 9, 9, 9, 9, 9, 9 };
connector.on_holepunch_response(0x10000 | 0xBEEF, garbage, sizeof(garbage), relay);
check(failed_count == 0, "a reply for an unknown id fails nothing");
}
// ---- 5. a tag that is not a holepunch tag at all ----
{
failed_count = 0;
const uint8_t garbage[4] = { 0, 0, 0, 0 };
check(!connector.on_holepunch_response(0x20000, garbage, sizeof(garbage), relay),
"a tag outside the holepunch range is not claimed");
check(failed_count == 0, "and fails nothing");
}
// ---- 6. the relay stage has JS's deadline, not the 30s backstop ----
//
// PARITY §11.1: hyperdht arms relayTimeout at 15s (connect.js:812) when it
// starts talking to a relay. Ours held every stage to the same 30s wall
// clock, so an unresponsive peer occupied one of three outbound slots for
// twice as long as the reference.
{
failed_count = 0;
randombytes_buf(remote_pk, 32);
const uint32_t id = connector.connect(remote_pk);
watch_id = id;
connector.tick(1000); // the connector's clock starts here
check(connector.set_state_for_gate(id, DHT_CONN_HOLEPUNCHING),
"an attempt reached the relay stage");
connector.tick(1000 + DHT_RELAY_TIMEOUT_MS - 1000);
check(failed_count == 0 && connector.find_connection(id) != nullptr,
"it is still alive one second before the relay deadline");
connector.tick(1000 + DHT_RELAY_TIMEOUT_MS + 1000);
check(failed_count == 1, "and fails one second after it");
check(last_error == "relay timeout",
"and says the relay timed out, not that the connection did");
check(connector.find_connection(id) == nullptr, "and the slot is given back");
}
// ---- 7. and the OTHER stages keep the backstop ----
//
// find_peer is released when the query exhausts (§11.2), so its 30s is a
// backstop rather than the deadline. Shortening it here would abandon
// attempts the DHT has not finished answering for.
{
failed_count = 0;
randombytes_buf(remote_pk, 32);
const uint32_t id = connector.connect(remote_pk); // stays in FINDING
watch_id = id;
connector.tick(1000);
connector.tick(1000 + DHT_RELAY_TIMEOUT_MS + 5000);
check(failed_count == 0 && connector.find_connection(id) != nullptr,
"an attempt still in FINDING outlives the RELAY deadline");
connector.tick(1000 + DHT_CONNECT_TIMEOUT_MS + 1000);
check(failed_count == 1, "and is only cut off by the 30s backstop");
check(last_error == "connection timeout", "which names itself differently");
}
printf("\n%d/%d passed\n", checks - failures, checks);
udx_socket_close(&sock);
uv_run(&loop, UV_RUN_NOWAIT);
uv_loop_close(&loop);
return failures ? 1 : 0;
}