refactor(websockets): Balance IPv4 and IPv6 attempts (#6804)

Reviewed-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Nerixyz
2026-02-15 11:07:06 +00:00
committed by GitHub
parent 4cc04e4a71
commit 09a6c3fd7a
8 changed files with 414 additions and 19 deletions
+1
View File
@@ -113,6 +113,7 @@
- Dev: Updated `pajlada-settings` to v0.5.0. (#6797)
- Dev: Updated `pajlada-serialize` to v0.2.1. (#6797)
- Dev: Updated `pajlada-signals` to v0.1.1. (#6797)
- Dev: Balance IPv4 and IPv6 connection attempts. (#6804)
## 2.5.4
+2
View File
@@ -61,6 +61,8 @@ set(SOURCE_FILES
common/websockets/WebSocketPool.cpp
common/websockets/WebSocketPool.hpp
common/websockets/detail/BalancedResolverResults.cpp
common/websockets/detail/BalancedResolverResults.hpp
common/websockets/detail/WebSocketConnection.cpp
common/websockets/detail/WebSocketConnection.hpp
common/websockets/detail/WebSocketConnectionImpl.cpp
@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#include "common/websockets/detail/BalancedResolverResults.hpp"
namespace chatterino::ws::detail {
BalancedResolverResults::BalancedResolverResults(
const Protocol::resolver::results_type &results)
: entries(results.begin(), results.end())
{
}
BalancedResolverResults::BalancedResolverResults(std::vector<Entry> entries)
: entries(std::move(entries))
{
}
std::optional<BalancedResolverResults::Entry>
BalancedResolverResults::advanceEntry()
{
if (this->nextIsIPv6)
{
// advance v6 first
if (this->advanceIPv6() || this->advanceIPv4())
{
return this->currentEntry();
}
}
else
{
// advance v4 first
if (this->advanceIPv4() || this->advanceIPv6())
{
return this->currentEntry();
}
}
return std::nullopt;
}
std::optional<BalancedResolverResults::Entry>
BalancedResolverResults::currentEntry() const
{
if (this->currentIdx < this->entries.size())
{
return this->entries[this->currentIdx];
}
return std::nullopt;
}
void BalancedResolverResults::reset()
{
this->nextIPv4Idx = 0;
this->nextIPv6Idx = 0;
this->currentIdx = std::numeric_limits<size_t>::max();
this->nextIsIPv6 = true;
}
bool BalancedResolverResults::advanceIPv4()
{
for (; this->nextIPv4Idx < this->entries.size(); this->nextIPv4Idx++)
{
if (this->entries[this->nextIPv4Idx].endpoint().address().is_v4())
{
this->currentIdx = this->nextIPv4Idx++;
this->nextIsIPv6 = true;
return true;
}
}
return false;
}
bool BalancedResolverResults::advanceIPv6()
{
for (; this->nextIPv6Idx < this->entries.size(); this->nextIPv6Idx++)
{
if (this->entries[this->nextIPv6Idx].endpoint().address().is_v6())
{
this->currentIdx = this->nextIPv6Idx++;
this->nextIsIPv6 = false;
return true;
}
}
return false;
}
} // namespace chatterino::ws::detail
@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#pragma once
#include <boost/asio/ip/basic_resolver_results.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <optional>
#include <vector>
namespace chatterino::ws::detail {
/// An iterator over resolver results ensuring IPv4 and IPv6 entries are
/// balanced when iterating where possible.
///
/// If enough records are available, an IPv4 record is followed by an IPv6 one
/// and vice versa. The resolver starts with IPv4 entries.
class BalancedResolverResults
{
public:
using Protocol = boost::asio::ip::tcp;
using Entry = boost::asio::ip::basic_resolver_entry<Protocol>;
BalancedResolverResults() = default;
explicit BalancedResolverResults(
const Protocol::resolver::results_type &results);
explicit BalancedResolverResults(std::vector<Entry> entries);
/// Advance to the next entry and return that one.
///
/// Once the end is reached, `std::nullopt` is returned.
std::optional<Entry> advanceEntry();
/// Get the current entry.
///
/// This only returns an entry after `advanceEntry` has been called.
std::optional<Entry> currentEntry() const;
/// Reset the iteration state.
void reset();
private:
/// Advance the IPv4 iterator.
/// Returns true if `currentIdx` now points to an IPv4 entry.
bool advanceIPv4();
/// Advance the IPv6 iterator.
/// Returns true if `currentIdx` now points to an IPv6 entry.
bool advanceIPv6();
size_t nextIPv4Idx = 0;
size_t nextIPv6Idx = 0;
bool nextIsIPv6 = true;
size_t currentIdx = std::numeric_limits<size_t>::max();
std::vector<Entry> entries;
};
} // namespace chatterino::ws::detail
@@ -99,23 +99,22 @@ void WebSocketConnectionHelper<Derived, Inner>::onResolve(
return;
}
this->resolvedEndpoints = results;
this->resolvedEndpoints = BalancedResolverResults(results);
this->tryConnect(this->resolvedEndpoints.begin());
this->tryConnect(this->resolvedEndpoints.advanceEntry());
}
template <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::tryConnect(
boost::asio::ip::tcp::resolver::results_type::const_iterator
endpointIterator)
std::optional<BalancedResolverResults::Entry> entry)
{
if (endpointIterator == this->resolvedEndpoints.end())
if (!entry)
{
this->fail("Ran out of resolved endpoints"sv, u"connect");
return;
}
const auto &endpoint = endpointIterator->endpoint();
auto endpoint = entry->endpoint();
qCDebug(chatterinoWebsocket)
<< *this << "connect to" << endpoint.address().to_string();
@@ -127,16 +126,14 @@ void WebSocketConnectionHelper<Derived, Inner>::tryConnect(
.async_connect(endpoint,
beast::bind_front_handler(
&WebSocketConnectionHelper::onTcpHandshake,
this->shared_from_this(), endpointIterator));
this->shared_from_this(), *std::move(entry)));
}
template <typename Derived, typename Inner>
void WebSocketConnectionHelper<Derived, Inner>::onTcpHandshake(
boost::asio::ip::tcp::resolver::results_type::const_iterator
endpointIterator,
boost::system::error_code ec)
const BalancedResolverResults::Entry &entry, boost::system::error_code ec)
{
const auto &ep = endpointIterator->endpoint();
const auto &ep = entry.endpoint();
if (ec)
{
@@ -151,7 +148,7 @@ void WebSocketConnectionHelper<Derived, Inner>::onTcpHandshake(
<< *this << "closing websocket after error" << ec.message();
}
this->tryConnect(++endpointIterator);
this->tryConnect(this->resolvedEndpoints.advanceEntry());
return;
}
@@ -4,6 +4,7 @@
#pragma once
#include "common/websockets/detail/BalancedResolverResults.hpp"
#include "common/websockets/detail/WebSocketConnection.hpp"
#include <boost/asio/ssl/context.hpp>
@@ -69,12 +70,9 @@ private:
///
/// If the iterator is invalid, we have run out of endpoints to try, and deem this
/// connection a failure.
void tryConnect(boost::asio::ip::tcp::resolver::results_type::const_iterator
endpointIterator);
void onTcpHandshake(
boost::asio::ip::tcp::resolver::results_type::const_iterator
endpointIterator,
boost::system::error_code ec);
void tryConnect(std::optional<BalancedResolverResults::Entry> entry);
void onTcpHandshake(const BalancedResolverResults::Entry &entry,
boost::system::error_code ec);
void onWsHandshake(boost::system::error_code ec);
void onReadDone(boost::system::error_code ec, size_t bytesRead);
@@ -86,7 +84,7 @@ private:
///
/// When we successfully resolve the host, we try to connect by
/// iterating over these results.
boost::asio::ip::tcp::resolver::results_type resolvedEndpoints;
BalancedResolverResults resolvedEndpoints;
};
/// A WebSocket connection over TLS (wss://).
+1
View File
@@ -61,6 +61,7 @@ set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/TwitchUserColor.cpp
${CMAKE_CURRENT_LIST_DIR}/src/FunctionRef.cpp
${CMAKE_CURRENT_LIST_DIR}/src/InputHighlighter.cpp
${CMAKE_CURRENT_LIST_DIR}/src/BalancedResolverResults.cpp
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.cpp
${CMAKE_CURRENT_LIST_DIR}/src/lib/Snapshot.hpp
+246
View File
@@ -0,0 +1,246 @@
// SPDX-FileCopyrightText: 2026 Contributors to Chatterino <https://chatterino.com>
//
// SPDX-License-Identifier: MIT
#include "common/websockets/detail/BalancedResolverResults.hpp"
#include "Test.hpp"
#include <boost/asio/ip/tcp.hpp>
using namespace chatterino::ws::detail;
namespace {
using Entry = BalancedResolverResults::Entry;
void runCheck(BalancedResolverResults results,
const std::vector<Entry> &expectedOrder)
{
ASSERT_FALSE(expectedOrder.empty());
for (size_t j = 0; j < 4; j++)
{
EXPECT_EQ(results.currentEntry(), std::nullopt);
for (size_t i = 0; i < expectedOrder.size(); i++)
{
EXPECT_EQ(results.advanceEntry()->endpoint(),
expectedOrder[i].endpoint())
<< i;
EXPECT_EQ(results.currentEntry()->endpoint(),
expectedOrder[i].endpoint())
<< i;
}
EXPECT_EQ(results.advanceEntry(), std::nullopt);
EXPECT_EQ(results.currentEntry()->endpoint(),
expectedOrder.back().endpoint());
results.reset();
}
}
Entry makeEntry(const boost::asio::ip::address &addr)
{
return {
boost::asio::ip::tcp::endpoint{addr, 443},
"host.example.com",
"service",
};
}
Entry makeV4(const char *spec)
{
return makeEntry(boost::asio::ip::make_address_v4(spec));
}
Entry makeV6(const char *spec)
{
return makeEntry(boost::asio::ip::make_address_v6(spec));
}
} // namespace
TEST(BalancedResolverResults, empty)
{
BalancedResolverResults results;
ASSERT_EQ(results.currentEntry(), std::nullopt);
ASSERT_EQ(results.advanceEntry(), std::nullopt);
ASSERT_EQ(results.currentEntry(), std::nullopt);
ASSERT_EQ(results.advanceEntry(), std::nullopt);
ASSERT_EQ(results.currentEntry(), std::nullopt);
ASSERT_EQ(results.advanceEntry(), std::nullopt);
ASSERT_EQ(results.currentEntry(), std::nullopt);
ASSERT_EQ(results.advanceEntry(), std::nullopt);
results.reset();
ASSERT_EQ(results.currentEntry(), std::nullopt);
ASSERT_EQ(results.advanceEntry(), std::nullopt);
}
TEST(BalancedResolverResults, singleV4)
{
std::vector<Entry> entries{makeV4("10.10.10.1")};
runCheck(BalancedResolverResults(entries), entries);
}
TEST(BalancedResolverResults, singleV6)
{
std::vector<Entry> entries{makeV6("1234:5678:9abc:def::1")};
runCheck(BalancedResolverResults(entries), entries);
}
TEST(BalancedResolverResults, singleV4AndV6)
{
std::vector<Entry> entries{
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
};
runCheck(BalancedResolverResults(entries), entries);
}
TEST(BalancedResolverResults, doubleV4)
{
runCheck(BalancedResolverResults({
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
}),
{
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
});
}
TEST(BalancedResolverResults, doubleV6)
{
runCheck(BalancedResolverResults({
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
});
}
TEST(BalancedResolverResults, doubleV4SingleV6)
{
runCheck(BalancedResolverResults({
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
});
}
TEST(BalancedResolverResults, singleV4DoubleV6)
{
runCheck(BalancedResolverResults({
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
makeV4("10.10.10.1"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
makeV6("1234:5678:9abc:def::2"),
});
}
TEST(BalancedResolverResults, doubleV4AndV6)
{
runCheck(BalancedResolverResults({
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
makeV6("1234:5678:9abc:def::2"),
makeV4("10.10.10.2"),
});
}
TEST(BalancedResolverResults, tripleV6)
{
runCheck(BalancedResolverResults({
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
makeV6("1234:5678:9abc:def::3"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
makeV6("1234:5678:9abc:def::3"),
});
}
TEST(BalancedResolverResults, tripleV4)
{
runCheck(BalancedResolverResults({
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
makeV4("10.10.10.3"),
}),
{
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
makeV4("10.10.10.3"),
});
}
TEST(BalancedResolverResults, tripleV4SingleV6)
{
runCheck(BalancedResolverResults({
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.3"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
makeV4("10.10.10.3"),
});
}
TEST(BalancedResolverResults, singleV4TripleV6)
{
runCheck(BalancedResolverResults({
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
makeV4("10.10.10.1"),
makeV6("1234:5678:9abc:def::3"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
makeV6("1234:5678:9abc:def::2"),
makeV6("1234:5678:9abc:def::3"),
});
}
TEST(BalancedResolverResults, tripleV4AndV6)
{
runCheck(BalancedResolverResults({
makeV4("10.10.10.1"),
makeV4("10.10.10.2"),
makeV4("10.10.10.3"),
makeV6("1234:5678:9abc:def::1"),
makeV6("1234:5678:9abc:def::2"),
makeV6("1234:5678:9abc:def::3"),
}),
{
makeV6("1234:5678:9abc:def::1"),
makeV4("10.10.10.1"),
makeV6("1234:5678:9abc:def::2"),
makeV4("10.10.10.2"),
makeV6("1234:5678:9abc:def::3"),
makeV4("10.10.10.3"),
});
}