OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / test / test_relay_error_abort.cpp

208 lines · cpp ref:
// 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;
}