From 6da3b162299def1923597953f52828e62203b4ce Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Wed, 8 Jul 2026 16:24:56 +0200 Subject: [PATCH 1/9] wip: T6 tests --- tests/test_robustness.cpp | 203 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/tests/test_robustness.cpp b/tests/test_robustness.cpp index ab0e678..1761941 100644 --- a/tests/test_robustness.cpp +++ b/tests/test_robustness.cpp @@ -466,3 +466,206 @@ 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); + + const int FLOOD_LINES = 200000; // ~0.5s of real backpressure, overlaps C's probe + std::thread floodThread([fdB]() { + for (int i = 0; i < FLOOD_LINES; ++i) + sendLine(fdB, "PRIVMSG #flood1 :msg-" + std::to_string(i)); + }); + + /* Bounded-retry PING/PONG probe against C overlapping B's flood thread + * — keeps firing until a PONG lands or the deadline expires, so it + * isn't racing the flood's exact timing. */ + bool gotPong = false; + std::string lastReply; + const int MAX_PROBES = 30; + for (int attempt = 0; attempt < MAX_PROBES && !gotPong; ++attempt) + { + sendLine(fdC, "PING :isolation-check"); + lastReply = recvBuf(fdC, 200); + gotPong = lastReply.find("PONG") != std::string::npos; + } + + floodThread.join(); + + EXPECT_TRUE(gotPong) + << "Client C should get a timely PONG even while A is a frozen " + "reader and B is flooding the shared channel; last reply: '" + << lastReply << "'"; + + close(fdA); + close(fdB); + close(fdC); +} + +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); + + const int FLOOD_LINES = 1000; + for (int i = 0; i < FLOOD_LINES; ++i) + sendLine(fdB, "PRIVMSG #flood2 :msg-" + std::to_string(i)); + + /* Bounded-retry registration of a brand-new client D, same shape as + * AbruptDisconnectViaRST: keep trying until 001 shows up. */ + const int MAX_ATTEMPTS = 15; + bool registered = false; + std::string lastReply; + for (int attempt = 0; attempt < MAX_ATTEMPTS && !registered; ++attempt) + { + int fdD = quickConnect(serverPort); + ASSERT_GE(fdD, 0); + 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); + registered = lastReply.find(" 001 ") != std::string::npos; + close(fdD); + } + + EXPECT_TRUE(registered) + << "A new client should still be able to register (001) after a " + "flood targeting a frozen reader; last reply: '" << lastReply + << "'"; + + close(fdA); + close(fdB); +} + +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); + /* fdA is never read again below this point */ + + 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); +} From f9ea43d2da35d05106c1ba0f08b50e428d276b72 Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Wed, 8 Jul 2026 18:10:05 +0200 Subject: [PATCH 2/9] Fix: augmented flood lines for real backpressure on robustness tests --- tests/test_robustness.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_robustness.cpp b/tests/test_robustness.cpp index 1761941..9c64e8a 100644 --- a/tests/test_robustness.cpp +++ b/tests/test_robustness.cpp @@ -571,7 +571,7 @@ TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader) std::this_thread::sleep_for(std::chrono::milliseconds(100)); recvBuf(fdB); - const int FLOOD_LINES = 1000; + const int FLOOD_LINES = 200000; for (int i = 0; i < FLOOD_LINES; ++i) sendLine(fdB, "PRIVMSG #flood2 :msg-" + std::to_string(i)); From 187d0b21188d3f76f03aa4fae5b6e91aea5fc3fa Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Sun, 12 Jul 2026 13:02:49 +0200 Subject: [PATCH 3/9] tests: ignore SIGPIPE in test_runner to match ircserv's disposition --- tests/test_main.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_main.cpp b/tests/test_main.cpp index bb01ab6..ad9c279 100644 --- a/tests/test_main.cpp +++ b/tests/test_main.cpp @@ -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) */ From 0f3303fcd7dee316b99dcd146061f5ee680498f8 Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Sun, 12 Jul 2026 13:03:04 +0200 Subject: [PATCH 4/9] tests: assert frozen-reader isolation during the flood, not after --- tests/test_robustness.cpp | 99 ++++++++++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 23 deletions(-) diff --git a/tests/test_robustness.cpp b/tests/test_robustness.cpp index 9c64e8a..6eb6e81 100644 --- a/tests/test_robustness.cpp +++ b/tests/test_robustness.cpp @@ -16,6 +16,7 @@ #include #include #include +#include /* ────────────────────────────────────────────────────────────────── * Helpers @@ -515,35 +516,67 @@ TEST_F(RobustnessTest, ThirdClientUnaffectedByFrozenReaderFlood) std::this_thread::sleep_for(std::chrono::milliseconds(200)); recvBuf(fdC); - const int FLOOD_LINES = 200000; // ~0.5s of real backpressure, overlaps C's probe - std::thread floodThread([fdB]() { + const int FLOOD_LINES = 200000; // ~0.5s of real backpressure + std::atomic flooding(true); + std::thread floodThread([fdB, &flooding]() { for (int i = 0; i < FLOOD_LINES; ++i) sendLine(fdB, "PRIVMSG #flood1 :msg-" + std::to_string(i)); + flooding = false; }); - /* Bounded-retry PING/PONG probe against C overlapping B's flood thread - * — keeps firing until a PONG lands or the deadline expires, so it - * isn't racing the flood's exact timing. */ - bool gotPong = false; + /* C must stay responsive for the WHOLE duration of the flood, not just + * at its start: probe repeatedly until the flood thread reports done. + * 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. */ + int probes = 0, pongs = 0, misses = 0, maxStreak = 0, streak = 0; std::string lastReply; - const int MAX_PROBES = 30; - for (int attempt = 0; attempt < MAX_PROBES && !gotPong; ++attempt) + while (flooding) { sendLine(fdC, "PING :isolation-check"); - lastReply = recvBuf(fdC, 200); - gotPong = lastReply.find("PONG") != std::string::npos; + lastReply = recvBuf(fdC, 50); + ++probes; + if (lastReply.find("PONG") != std::string::npos) + { + ++pongs; + streak = 0; + } + else + { + ++misses; + ++streak; + if (streak > maxStreak) + maxStreak = streak; + } } - floodThread.join(); - - EXPECT_TRUE(gotPong) - << "Client C should get a timely PONG even while A is a frozen " - "reader and B is flooding the shared channel; last reply: '" - << lastReply << "'"; + floodThread.join(); /* joined before any ASSERT below can return early */ close(fdA); close(fdB); close(fdC); + + /* Guard against a vacuous pass: the original T6 defect was a flood that + * drained in ~1ms, leaving a single probe round with no overlap. Two or + * more rounds proves a real backpressure window existed. The exact count + * is environment-dependent (probe round cost is bounded by recvBuf's + * SO_RCVTIMEO, flood duration by machine speed), so this is deliberately + * a floor, not a tuned value. */ + const int MIN_PROBES = 2; + ASSERT_GE(probes, MIN_PROBES) + << "Flood drained too fast (" << probes << " probe rounds) — " + "FLOOD_LINES=" << FLOOD_LINES << " produced no measurable " + "backpressure window, so isolation was never actually tested"; + + 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 + << "/" << probes << " answered); last reply: '" << lastReply << "'"; } TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader) @@ -572,18 +605,31 @@ TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader) recvBuf(fdB); const int FLOOD_LINES = 200000; - for (int i = 0; i < FLOOD_LINES; ++i) - sendLine(fdB, "PRIVMSG #flood2 :msg-" + std::to_string(i)); + std::atomic flooding(true); + std::thread floodThread([fdB, &flooding]() { + for (int i = 0; i < FLOOD_LINES; ++i) + sendLine(fdB, "PRIVMSG #flood2 :msg-" + std::to_string(i)); + flooding = false; + }); - /* Bounded-retry registration of a brand-new client D, same shape as - * AbruptDisconnectViaRST: keep trying until 001 shows up. */ + /* Bounded-retry registration of a brand-new client D, overlapping B's + * in-flight flood — the point is that D registers WHILE the server is + * under backpressure, not after the flood has already drained. + * 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 overlapped = false; std::string lastReply; for (int attempt = 0; attempt < MAX_ATTEMPTS && !registered; ++attempt) { + if (flooding) + overlapped = true; + int fdD = quickConnect(serverPort); - ASSERT_GE(fdD, 0); + EXPECT_GE(fdD, 0); + if (fdD < 0) continue; + sendLine(fdD, "PASS robpass"); sendLine(fdD, "NICK survD"); sendLine(fdD, "USER survD 0 * :Test"); @@ -593,9 +639,16 @@ TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader) close(fdD); } + floodThread.join(); + + EXPECT_TRUE(overlapped) + << "D's registration never overlapped an in-flight flood — the " + "flood drained before the first attempt, so this test would " + "pass identically with no flood at all"; + EXPECT_TRUE(registered) - << "A new client should still be able to register (001) after a " - "flood targeting a frozen reader; last reply: '" << lastReply + << "A new client should still register (001) while a flood is in " + "flight against a frozen reader; last reply: '" << lastReply << "'"; close(fdA); From 6ba2a8d7b704a539b003bc272cec7c9dd0ba7586 Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Sun, 12 Jul 2026 13:57:56 +0200 Subject: [PATCH 5/9] tests: prove frozen-reader isolation and survival during the flood, not after --- tests/test_robustness.cpp | 104 +++++++++++++++++++++----------------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/tests/test_robustness.cpp b/tests/test_robustness.cpp index 6eb6e81..a888ce2 100644 --- a/tests/test_robustness.cpp +++ b/tests/test_robustness.cpp @@ -516,27 +516,36 @@ TEST_F(RobustnessTest, ThirdClientUnaffectedByFrozenReaderFlood) std::this_thread::sleep_for(std::chrono::milliseconds(200)); recvBuf(fdC); - const int FLOOD_LINES = 200000; // ~0.5s of real backpressure - std::atomic flooding(true); - std::thread floodThread([fdB, &flooding]() { - for (int i = 0; i < FLOOD_LINES; ++i) + /* 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 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)); - flooding = false; }); - /* C must stay responsive for the WHOLE duration of the flood, not just - * at its start: probe repeatedly until the flood thread reports done. - * 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. */ - int probes = 0, pongs = 0, misses = 0, maxStreak = 0, streak = 0; + /* 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; - while (flooding) + for (int i = 0; i < PROBE_ROUNDS; ++i) { sendLine(fdC, "PING :isolation-check"); lastReply = recvBuf(fdC, 50); - ++probes; if (lastReply.find("PONG") != std::string::npos) { ++pongs; @@ -544,31 +553,19 @@ TEST_F(RobustnessTest, ThirdClientUnaffectedByFrozenReaderFlood) } else { - ++misses; ++streak; if (streak > maxStreak) maxStreak = streak; } } - floodThread.join(); /* joined before any ASSERT below can return early */ + stopFlood = true; + floodThread.join(); close(fdA); close(fdB); close(fdC); - /* Guard against a vacuous pass: the original T6 defect was a flood that - * drained in ~1ms, leaving a single probe round with no overlap. Two or - * more rounds proves a real backpressure window existed. The exact count - * is environment-dependent (probe round cost is bounded by recvBuf's - * SO_RCVTIMEO, flood duration by machine speed), so this is deliberately - * a floor, not a tuned value. */ - const int MIN_PROBES = 2; - ASSERT_GE(probes, MIN_PROBES) - << "Flood drained too fast (" << probes << " probe rounds) — " - "FLOOD_LINES=" << FLOOD_LINES << " produced no measurable " - "backpressure window, so isolation was never actually tested"; - EXPECT_GT(pongs, 0) << "Client C got no PONG at all during the flood"; @@ -576,7 +573,8 @@ TEST_F(RobustnessTest, ThirdClientUnaffectedByFrozenReaderFlood) << "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 - << "/" << probes << " answered); last reply: '" << lastReply << "'"; + << "/" << PROBE_ROUNDS << " answered); last reply: '" << lastReply + << "'"; } TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader) @@ -604,28 +602,34 @@ TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader) std::this_thread::sleep_for(std::chrono::milliseconds(100)); recvBuf(fdB); - const int FLOOD_LINES = 200000; + /* 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 stopFlood(false); std::atomic flooding(true); - std::thread floodThread([fdB, &flooding]() { - for (int i = 0; i < FLOOD_LINES; ++i) + 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, overlapping B's - * in-flight flood — the point is that D registers WHILE the server is - * under backpressure, not after the flood has already drained. + /* 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 overlapped = false; + bool stillFloodingAtRegistration = false; std::string lastReply; for (int attempt = 0; attempt < MAX_ATTEMPTS && !registered; ++attempt) { - if (flooding) - overlapped = true; - int fdD = quickConnect(serverPort); EXPECT_GE(fdD, 0); if (fdD < 0) continue; @@ -634,25 +638,33 @@ TEST_F(RobustnessTest, ServerSurvivesFloodAgainstFrozenReader) sendLine(fdD, "NICK survD"); sendLine(fdD, "USER survD 0 * :Test"); std::this_thread::sleep_for(std::chrono::milliseconds(100)); - lastReply = recvBuf(fdD); + lastReply = recvBuf(fdD, 100); registered = lastReply.find(" 001 ") != std::string::npos; + if (registered) + stillFloodingAtRegistration = flooding; /* sampled BEFORE stopFlood */ close(fdD); } + stopFlood = true; floodThread.join(); - EXPECT_TRUE(overlapped) - << "D's registration never overlapped an in-flight flood — the " - "flood drained before the first attempt, so this test would " - "pass identically with no flood at all"; + 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 << "'"; - close(fdA); - close(fdB); + /* 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) From 35c8b8f0ce0152507083a73f322b6ae6f68b02bf Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Sun, 12 Jul 2026 13:58:04 +0200 Subject: [PATCH 6/9] tests: add build target that compiles without running the suite --- tests/Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/Makefile b/tests/Makefile index cfe2acb..613ef1d 100644 --- a/tests/Makefile +++ b/tests/Makefile @@ -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) @@ -135,4 +138,4 @@ fclean: clean re: fclean all -.PHONY: all clean fclean re +.PHONY: all build clean fclean re From be3489245db35f08e587b09c4901ab7869eea9fe Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Sun, 12 Jul 2026 13:59:09 +0200 Subject: [PATCH 7/9] docs: CLAUDE.md known traps updated --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 98b748e..cf04243 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,4 +83,17 @@ 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. +- **Backpressure tests must overlap the flood**: the T6 frozen-reader tests + assert isolation/survival *during* an in-flight flood, not after it. Both + carry a guard (`MIN_PROBES`, `stillFloodingAtRegistration`) that FAILS if + the flood drains before the condition is probed — the original versions + passed vacuously with 1000 lines (~1 ms of flood). If a guard fires, raise + FLOOD_LINES or cheapen the probe; never drop the guard. \ No newline at end of file From b69a5edff1fe3a5d3e4dfc8c17126f48d81f0244 Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Sun, 12 Jul 2026 16:50:06 +0200 Subject: [PATCH 8/9] docs: CLAUDE.md known trap updated with new experience --- CLAUDE.md | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cf04243..4ea169c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,10 +90,5 @@ Tests use Google Test but also feed every result into **PostMan** (`vendor/PostM `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. -- **Backpressure tests must overlap the flood**: the T6 frozen-reader tests - assert isolation/survival *during* an in-flight flood, not after it. Both - carry a guard (`MIN_PROBES`, `stillFloodingAtRegistration`) that FAILS if - the flood drains before the condition is probed — the original versions - passed vacuously with 1000 lines (~1 ms of flood). If a guard fires, raise - FLOOD_LINES or cheapen the probe; never drop the guard. +- Autodeterminded flood; overlap is structural, not a race; don't return it as fixed FLOOD_LINES. \ No newline at end of file From a1ee3f0c2e34d6f20b1e3d4720db0a0f7f00f660 Mon Sep 17 00:00:00 2001 From: vjan-nie Date: Sun, 12 Jul 2026 16:51:03 +0200 Subject: [PATCH 9/9] docs: test_robustness.cpp documentation lines updated with new info --- tests/test_robustness.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_robustness.cpp b/tests/test_robustness.cpp index a888ce2..f6c400e 100644 --- a/tests/test_robustness.cpp +++ b/tests/test_robustness.cpp @@ -686,7 +686,11 @@ TEST_F(RobustnessTest, FrozenReaderEventuallyDisconnectedOnSendQ) sendLine(fdA, "JOIN #flood3"); std::this_thread::sleep_for(std::chrono::milliseconds(100)); recvBuf(fdA); - /* fdA is never read again below this point */ + /* 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);