Skip to content
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,12 @@ Tests use Google Test but also feed every result into **PostMan** (`vendor/PostM
errno removal (`RobustnessTest.AbruptDisconnectViaRST` guards it now).
- **No flush-on-disconnect**: `disconnectClient()` closes the fd without a
final best-effort `send()` of whatever is still queued in `_out` — two unpolled best-effort send()s were removed (T3): the flush in disconnectClient() and the MAX_CLIENTS "Server full" rejection in acceptClient(). The kernel now has no send() outside the EPOLLOUT-gated path in handleClientOutput(). Known accepted regressions: ERR_PASSWDMISMATCH (464) / the 001-005 burst on immediate post-registration QUIT, and the "Server full" ERROR when at MAX_CLIENTS, no longer reach the client (socket closes with zero bytes). Compliant recovery = deferred teardown (T4).
- **SIGPIPE in the test harness**: `tests/` does NOT link `main.cpp`, so the
`signal(SIGPIPE, SIG_IGN)` that `ircserv` installs never ran in
`test_runner` — the test process had a different signal disposition than
the shipped binary. A server-side `send()` to a socket a test had already
`close()`d, while a large SendQ was still pending for it, killed the whole
process with SIGPIPE (exit 141). Now installed in `tests/test_main.cpp`.
Keep it there; it is a property of the process, not of any one fixture.
- Autodeterminded flood; overlap is structural, not a race; don't return it as fixed FLOOD_LINES.

5 changes: 4 additions & 1 deletion tests/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ $(PM_OBJ): $(PM_SRC)
@mkdir -p $(OBJDIR)
$(CXX) $(CXXFLAGS) $(INC_PM) -c $< -o $@

build: $(NAME)
@echo "\n══════ Built $(NAME) (not run) ══════\n"

clean:
rm -rf $(OBJDIR)

Expand All @@ -135,4 +138,4 @@ fclean: clean

re: fclean all

.PHONY: all clean fclean re
.PHONY: all build clean fclean re
9 changes: 9 additions & 0 deletions tests/test_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ class PostManListener : public ::testing::EmptyTestEventListener

int main(int argc, char **argv)
{
/* test_runner does not link main.cpp, so the signal(SIGPIPE, SIG_IGN)
* that ircserv installs in production never runs here. Without it, a
* server-side send() to a socket the test has already close()d — while
* the server still has a large SendQ pending for it, as in the T6
* frozen-reader tests — kills the whole process with SIGPIPE instead of
* returning EPIPE. This aligns the test process's signal disposition
* with the shipped binary's. */
signal(SIGPIPE, SIG_IGN);

::testing::InitGoogleTest(&argc, argv);

/* Attach PostMan listener (gtest owns the pointer) */
Expand Down
272 changes: 272 additions & 0 deletions tests/test_robustness.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <atomic>

/* ──────────────────────────────────────────────────────────────────
* Helpers
Expand Down Expand Up @@ -466,3 +467,274 @@ TEST_F(RobustnessTest, NoLeakAfterClientChurn)

ASSERT_NO_LEAKS("client churn should not leak memory");
}

/* ════════════════════════════════════════════════════════════════════════
* Suite: Robustness — Frozen reader + channel flood (T6)
*
* A client that stops reading its socket ("frozen reader") while a channel
* it's in gets flooded by another member must not affect a third,
* unrelated client, and the server must not crash. See
* .claude/workflow/tasks/T6-frozen-reader-flood/01-audit.md for the
* source/behavioral audit backing these three tests — none of them assert
* an exact byte or line cutoff, since the SendQ-disconnect boundary is an
* OS socket-buffer artifact, not a value ft_irc controls.
* ════════════════════════════════════════════════════════════════════ */

TEST_F(RobustnessTest, ThirdClientUnaffectedByFrozenReaderFlood)
{
/* A: the frozen reader — registers, joins, then never recv()s again */
int fdA = quickConnect(serverPort);
ASSERT_GE(fdA, 0);
sendLine(fdA, "PASS robpass");
sendLine(fdA, "NICK frozenA");
sendLine(fdA, "USER frozenA 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
recvBuf(fdA);
sendLine(fdA, "JOIN #flood1");
std::this_thread::sleep_for(std::chrono::milliseconds(100));
recvBuf(fdA);
/* fdA is never read again below this point */

/* B: the flooder */
int fdB = quickConnect(serverPort);
ASSERT_GE(fdB, 0);
sendLine(fdB, "PASS robpass");
sendLine(fdB, "NICK frozenB");
sendLine(fdB, "USER frozenB 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
recvBuf(fdB);
sendLine(fdB, "JOIN #flood1");
std::this_thread::sleep_for(std::chrono::milliseconds(100));
recvBuf(fdB);

/* C: the control client whose responsiveness is actually asserted */
int fdC = quickConnect(serverPort);
ASSERT_GE(fdC, 0);
sendLine(fdC, "PASS robpass");
sendLine(fdC, "NICK frozenC");
sendLine(fdC, "USER frozenC 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
recvBuf(fdC);

/* The flood runs until the probe loop says stop, not for a fixed line
* count. A fixed volume makes the overlap a race: the flood has to
* happen to last longer than the probes on every machine — exactly the
* assumption that passed locally and failed in the CI container for
* ServerSurvivesFloodAgainstFrozenReader. Here the flood is
* self-terminating, so the probe window is STRUCTURALLY inside it.
*
* FLOOD_CAP only bounds the test if the probes never complete; it is a
* safety net, not a tuned value. */
const int FLOOD_CAP = 2000000;
std::atomic<bool> stopFlood(false);
std::thread floodThread([fdB, &stopFlood]() {
for (int i = 0; i < FLOOD_CAP && !stopFlood; ++i)
sendLine(fdB, "PRIVMSG #flood1 :msg-" + std::to_string(i));
});

/* C must stay responsive for the WHOLE probe window while A is a frozen
* reader and B floods the shared channel. The bar is "never goes silent
* for a stretch" (maxStreak), not "zero late replies" — a single missed
* round is jitter above recvBuf's 50ms timeout, whereas a server
* blocking on the frozen reader would miss every round for a long run. */
const int PROBE_ROUNDS = 10;
int pongs = 0;
int maxStreak = 0;
int streak = 0;
std::string lastReply;
for (int i = 0; i < PROBE_ROUNDS; ++i)
{
sendLine(fdC, "PING :isolation-check");
lastReply = recvBuf(fdC, 50);
if (lastReply.find("PONG") != std::string::npos)
{
++pongs;
streak = 0;
}
else
{
++streak;
if (streak > maxStreak)
maxStreak = streak;
}
}

stopFlood = true;
floodThread.join();

close(fdA);
close(fdB);
close(fdC);

EXPECT_GT(pongs, 0)
<< "Client C got no PONG at all during the flood";

EXPECT_LE(maxStreak, 2)
<< "Client C went " << maxStreak << " consecutive probe rounds "
"without a PONG while A is a frozen reader and B floods the "
"shared channel — that is loss of service, not jitter (" << pongs
<< "/" << PROBE_ROUNDS << " answered); last reply: '" << lastReply
<< "'";
}

TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader)
{
int fdA = quickConnect(serverPort);
ASSERT_GE(fdA, 0);
sendLine(fdA, "PASS robpass");
sendLine(fdA, "NICK survA");
sendLine(fdA, "USER survA 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
recvBuf(fdA);
sendLine(fdA, "JOIN #flood2");
std::this_thread::sleep_for(std::chrono::milliseconds(100));
recvBuf(fdA);
/* fdA is never read again below this point */

int fdB = quickConnect(serverPort);
ASSERT_GE(fdB, 0);
sendLine(fdB, "PASS robpass");
sendLine(fdB, "NICK survB");
sendLine(fdB, "USER survB 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
recvBuf(fdB);
sendLine(fdB, "JOIN #flood2");
std::this_thread::sleep_for(std::chrono::milliseconds(100));
recvBuf(fdB);

/* The flood runs until the test tells it to stop, rather than for a
* fixed line count that has to be tuned to outrace D's registration on
* every machine. A fixed volume is a race by construction: it passed
* locally (~200k lines ≈ 0.5s) and failed in the CI container, where
* the same flood drained before D got its 001. Letting the flood run
* until D registers makes the overlap STRUCTURAL — D cannot possibly
* register outside an in-flight flood — instead of probabilistic.
*
* FLOOD_CAP only bounds the test if D never registers at all; it is a
* safety net, not a tuned value. */
const int FLOOD_CAP = 2000000;
std::atomic<bool> stopFlood(false);
std::atomic<bool> flooding(true);
std::thread floodThread([fdB, &stopFlood, &flooding]() {
for (int i = 0; i < FLOOD_CAP && !stopFlood; ++i)
sendLine(fdB, "PRIVMSG #flood2 :msg-" + std::to_string(i));
flooding = false;
});

/* Bounded-retry registration of a brand-new client D while B floods.
* EXPECT_GE (not ASSERT_GE) inside the loop: an ASSERT would return
* from the test with floodThread still joinable -> std::terminate. */
const int MAX_ATTEMPTS = 15;
bool registered = false;
bool stillFloodingAtRegistration = false;
std::string lastReply;
for (int attempt = 0; attempt < MAX_ATTEMPTS && !registered; ++attempt)
{
int fdD = quickConnect(serverPort);
EXPECT_GE(fdD, 0);
if (fdD < 0) continue;

sendLine(fdD, "PASS robpass");
sendLine(fdD, "NICK survD");
sendLine(fdD, "USER survD 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(100));
lastReply = recvBuf(fdD, 100);
registered = lastReply.find(" 001 ") != std::string::npos;
if (registered)
stillFloodingAtRegistration = flooding; /* sampled BEFORE stopFlood */
close(fdD);
}

stopFlood = true;
floodThread.join();

close(fdA);
close(fdB);

EXPECT_TRUE(registered)
<< "A new client should still register (001) while a flood is in "
"flight against a frozen reader; last reply: '" << lastReply
<< "'";

/* Guard against a vacuous pass, sampled at the moment D actually got
* its 001. With a self-terminating flood this can only fire if the
* FLOOD_CAP was exhausted before D ever registered — i.e. a genuine
* failure, not a machine-speed difference. Never weaken this guard to
* make the test green. */
EXPECT_TRUE(stillFloodingAtRegistration)
<< "D completed registration only after the flood had already "
"drained (FLOOD_CAP=" << FLOOD_CAP << " exhausted), so "
"registration under backpressure was never tested";
}

TEST_F(RobustnessTest, FrozenReaderEventuallyDisconnectedOnSendQ)
{
/* Heaviest test in the suite: sends ~12 MB to push a frozen reader past
* both this machine's real OS socket-buffer ceiling and the 64 KiB
* MAX_SENDQ latch. The exact byte count at which the disconnect fires
* is environment-dependent (see
* .claude/workflow/tasks/T6-frozen-reader-flood/01-audit.md) — this
* flood volume carries a wide safety margin (~6x) over the ~1.88 MB
* ceiling measured on the audit machine, not tuned to it. */
int fdA = quickConnect(serverPort);
ASSERT_GE(fdA, 0);
sendLine(fdA, "PASS robpass");
sendLine(fdA, "NICK cutA");
sendLine(fdA, "USER cutA 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
recvBuf(fdA);
sendLine(fdA, "JOIN #flood3");
std::this_thread::sleep_for(std::chrono::milliseconds(100));
recvBuf(fdA);
/* Unlike Tests 1 and 2, fdA IS read below — but only after the server
* has already closed it: the wait loop's first recv() returns a full
* 4096B buffer (~1.88MB still queued by the OS), and drains to EOF in
* <1ms. It never competes with the live flood, so the disconnect it
* observes is a real SendQ cut, not an artifact of the test draining A. */

int fdB = quickConnect(serverPort);
ASSERT_GE(fdB, 0);
sendLine(fdB, "PASS robpass");
sendLine(fdB, "NICK cutB");
sendLine(fdB, "USER cutB 0 * :Test");
std::this_thread::sleep_for(std::chrono::milliseconds(200));
recvBuf(fdB);
sendLine(fdB, "JOIN #flood3");
std::this_thread::sleep_for(std::chrono::milliseconds(100));
recvBuf(fdB);

const int FLOOD_LINES = 30000;
const std::string payload(400, 'A'); /* line ~419B, under the 512B input cap */
for (int i = 0; i < FLOOD_LINES; ++i)
sendLine(fdB, "PRIVMSG #flood3 :" + payload);

/* Bounded wait for A's socket to be closed server-side: keep recv()ing
* (draining whatever backlog already arrived) until EOF (n == 0), with
* a generous but finite safety-net deadline so a hypothetical
* environment whose OS buffers absorb the whole flood fails this test
* cleanly instead of hanging the suite. */
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 200 * 1000;
setsockopt(fdA, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));

bool closed = false;
const std::chrono::steady_clock::time_point deadline =
std::chrono::steady_clock::now() + std::chrono::seconds(20);
char buf[4096];
while (std::chrono::steady_clock::now() < deadline)
{
ssize_t n = recv(fdA, buf, sizeof(buf), 0);
if (n == 0) { closed = true; break; }
/* n < 0: timeout, keep polling. n > 0: still draining backlog. */
}

EXPECT_TRUE(closed)
<< "Frozen reader A should eventually be disconnected once a large "
"enough flood (" << FLOOD_LINES << " lines, ~"
<< (FLOOD_LINES * (payload.size() + 19)) / (1024 * 1024)
<< " MB) exceeds real OS + MAX_SENDQ backpressure";

close(fdA);
close(fdB);
}
Loading