From 13268feefeff336966c0d3bd36d548d9ce75cec9 Mon Sep 17 00:00:00 2001 From: RealKindOne Date: Mon, 20 Jul 2026 04:25:50 -0400 Subject: [PATCH 1/4] Revert "Revert "Fix JOIN command not joining detached channels on other clients."" The revert was made by mistake due to misreading of 'znc -D'. This reverts commit 6d470db2415035f54919cdce96807707e5d5cf20. --- src/Chan.cpp | 25 +++++++++++++++------- test/integration/tests/core.cpp | 38 +++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/Chan.cpp b/src/Chan.cpp index b1cd707f..23f949a8 100644 --- a/src/Chan.cpp +++ b/src/Chan.cpp @@ -149,21 +149,30 @@ void CChan::JoinUser(const CString& sKey) { } void CChan::AttachUser(CClient* pClient) { + + // Sending '/znc attach #channel' applies for all clients connected. + // Sending '/join #channel' only applies for the client that sent + // the command. This causes a de-sync with IRC events like QUIT + // displaying in the other clients server/status window. + + // Make '/join #channel' work the same as '/znc attach #channel' + CClient* pTarget = IsDetached() ? nullptr : pClient; + m_pNetwork->PutUser( ":" + m_pNetwork->GetIRCNick().GetNickMask() + " JOIN :" + GetName(), - pClient); + pTarget); if (!GetTopic().empty()) { m_pNetwork->PutUser(":" + m_pNetwork->GetIRCServer() + " 332 " + m_pNetwork->GetIRCNick().GetNick() + " " + GetName() + " :" + GetTopic(), - pClient); + pTarget); if (!GetTopicOwner().empty()) { m_pNetwork->PutUser(":" + m_pNetwork->GetIRCServer() + " 333 " + m_pNetwork->GetIRCNick().GetNick() + " " + GetName() + " " + GetTopicOwner() + " " + CString(GetTopicDate()), - pClient); + pTarget); } } @@ -176,10 +185,10 @@ void CChan::AttachUser(CClient* pClient) { const vector& vpClients = m_pNetwork->GetClients(); for (CClient* pEachClient : vpClients) { CClient* pThisClient; - if (!pClient) + if (!pTarget) pThisClient = pEachClient; else - pThisClient = pClient; + pThisClient = pTarget; for (map::iterator a = m_msNicks.begin(); a != m_msNicks.end(); ++a) { @@ -210,18 +219,18 @@ void CChan::AttachUser(CClient* pClient) { } } - if (pClient) // We only want to do this for one client + if (pTarget) break; } m_pNetwork->PutUser(":" + m_pNetwork->GetIRCServer() + " 366 " + m_pNetwork->GetIRCNick().GetNick() + " " + GetName() + " :End of /NAMES list.", - pClient); + pTarget); m_bDetached = false; // Send Buffer - SendBuffer(pClient); + SendBuffer(pTarget); } void CChan::DetachUser() { diff --git a/test/integration/tests/core.cpp b/test/integration/tests/core.cpp index d020ca3c..6038126a 100644 --- a/test/integration/tests/core.cpp +++ b/test/integration/tests/core.cpp @@ -1278,5 +1278,43 @@ TEST_F(ZNCTest, CAPDetached) { << "Client saw chghost even though all channels are detached"; } +// Test for CChan::AttachUser() +TEST_F(ZNCTest, JoinDetachedChannelMultiClient) { + auto znc = Run(); + auto ircd = ConnectIRCd(); + + auto client1 = LoginClient(); + ircd.Write(":server 001 nick :Hello"); + client1.Write("JOIN #test"); + ircd.ReadUntil("JOIN #test"); + ircd.Write(":nick JOIN :#test"); + ircd.Write(":server 353 nick #test :nick"); + ircd.Write(":server 366 nick #test :End of /NAMES list"); + client1.ReadUntil("End of /NAMES"); + + client1.Write("DETACH #test"); + client1.ReadUntil("Detached 1 channel"); + + auto client2 = LoginClient(); + client2.ReadUntil("001"); + + // Both client1 and client2 should be in #test + client2.Write("JOIN #test"); + + client1.ReadUntil(":nick JOIN :#test"); + client2.ReadUntil(":nick JOIN :#test"); + + client1.ReadUntil("353"); + client2.ReadUntil("353"); + + ircd.Write(":other!user@host JOIN :#test"); + client1.ReadUntil(":other!user@host JOIN :#test"); + client2.ReadUntil(":other!user@host JOIN :#test"); + + ircd.Write(":other!user@host QUIT :quit message"); + client1.ReadUntil(":other!user@host QUIT :quit message"); + client2.ReadUntil(":other!user@host QUIT :quit message"); +} + } // namespace } // namespace znc_inttest From 3ceae3ad26d9406f8e905b602e090de8631db3e9 Mon Sep 17 00:00:00 2001 From: RealKindOne Date: Mon, 20 Jul 2026 04:33:04 -0400 Subject: [PATCH 2/4] Fix part/join not working when a client sends '/cycle' or '/hop' command. --- src/Client.cpp | 1 + test/integration/tests/core.cpp | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Client.cpp b/src/Client.cpp index de5311a1..f51e8371 100644 --- a/src/Client.cpp +++ b/src/Client.cpp @@ -1592,6 +1592,7 @@ bool CClient::OnPartMessage(CPartMessage& Message) { PutStatusNotice(t_f("Removing channel {1}")(sChan)); m_pNetwork->DelChan(sChan); } else { + if (pChan) pChan->SetIsOn(false); sChans += (sChans.empty()) ? sChan : CString("," + sChan); } } diff --git a/test/integration/tests/core.cpp b/test/integration/tests/core.cpp index 6038126a..4169b687 100644 --- a/test/integration/tests/core.cpp +++ b/test/integration/tests/core.cpp @@ -1316,5 +1316,28 @@ TEST_F(ZNCTest, JoinDetachedChannelMultiClient) { client2.ReadUntil(":other!user@host QUIT :quit message"); } +// Commit ad7bd6d7eed84648638e1b6fd69546b9fe496576 +// prevented rejoining when a client cycles a channel. +TEST_F(ZNCTest, ClientCycleChannels) { + auto znc = Run(); + auto ircd = ConnectIRCd(); + auto client = LoginClient(); + + ircd.Write(":server 001 nick :Hello"); + client.Write("JOIN #test"); + ircd.ReadUntil("JOIN #test"); + + ircd.Write(":nick JOIN :#test"); + ircd.Write(":server 353 nick #test :nick"); + ircd.Write(":server 366 nick #test :End of /NAMES list"); + client.ReadUntil("End of /NAMES"); + + // Clients have '/hop' or '/cycle' command that sends + // 'PART #channel' and 'JOIN #channel'. Verify ZNC rejoins.. + client.Write(":nick PART #test"); + client.Write(":nick JOIN #test"); + ircd.ReadUntil("JOIN #test"); +} + } // namespace } // namespace znc_inttest From a0d58c8fed3b71e5302b0560133c7e3ae4e82f0a Mon Sep 17 00:00:00 2001 From: RealKindOne Date: Tue, 21 Jul 2026 03:24:38 -0400 Subject: [PATCH 3/4] Add IsParting() flag to track PART commands waiting IRCd response. Fixes potential desync where ZNC marks channel as parted before server confirms the PART, which could cause issues if the server never replies. Add unittests and integration tests. --- include/znc/Chan.h | 3 ++ src/Chan.cpp | 4 +- src/Client.cpp | 4 +- src/IRCSock.cpp | 16 ++++++ test/IRCSockTest.cpp | 41 +++++++++++++++ test/integration/tests/core.cpp | 91 ++++++++++++++++++++++++++++++++- 6 files changed, 154 insertions(+), 5 deletions(-) diff --git a/include/znc/Chan.h b/include/znc/Chan.h index 31fa8e34..831d50ae 100644 --- a/include/znc/Chan.h +++ b/include/znc/Chan.h @@ -168,6 +168,7 @@ class CChan : private CCoreTranslationMixin { void Enable(); void IncJoinTries() { m_uJoinTries++; } void ResetJoinTries() { m_uJoinTries = 0; } + void SetParting(bool b) { m_bParting = b; } // !Setters // Getters @@ -199,6 +200,7 @@ class CChan : private CCoreTranslationMixin { bool HasAutoClearChanBufferSet() const { return m_bHasAutoClearChanBufferSet; } + bool IsParting() const { return m_bParting; } // !Getters private: protected: @@ -223,6 +225,7 @@ class CChan : private CCoreTranslationMixin { CBuffer m_Buffer; bool m_bModeKnown; + bool m_bParting; std::map m_mcsModes; }; diff --git a/src/Chan.cpp b/src/Chan.cpp index 23f949a8..fd370f2a 100644 --- a/src/Chan.cpp +++ b/src/Chan.cpp @@ -30,6 +30,7 @@ CChan::CChan(const CString& sName, CIRCNetwork* pNetwork, bool bInConfig, CConfig* pConfig) : m_bDetached(false), m_bIsOn(false), + m_bParting(false), m_bAutoClearChanBuffer(pNetwork->GetUser()->AutoClearChanBuffer()), m_bInConfig(bInConfig), m_bDisabled(false), @@ -84,6 +85,7 @@ CChan::~CChan() { ClearNicks(); } void CChan::Reset() { m_bIsOn = false; + m_bParting = false; m_bModeKnown = false; m_mcsModes.clear(); m_sTopic = ""; @@ -143,7 +145,7 @@ void CChan::JoinUser(const CString& sKey) { if (!IsOn() && !sKey.empty()) { SetKey(sKey); } - if (m_pNetwork->IsIRCConnected() && !IsOn()) { + if (m_pNetwork->IsIRCConnected() && (!IsOn() || IsParting())) { m_pNetwork->PutIRC("JOIN " + GetName() + " " + GetKey()); } } diff --git a/src/Client.cpp b/src/Client.cpp index f51e8371..a5020dd8 100644 --- a/src/Client.cpp +++ b/src/Client.cpp @@ -1469,7 +1469,7 @@ bool CClient::OnJoinMessage(CJoinMessage& Message) { if (pChan) { if (pChan->IsDetached()) pChan->AttachUser(this); - else + else if (!pChan->IsOn() || pChan->IsParting()) pChan->JoinUser(sKey); continue; } else if (!sChannel.empty()) { @@ -1592,7 +1592,7 @@ bool CClient::OnPartMessage(CPartMessage& Message) { PutStatusNotice(t_f("Removing channel {1}")(sChan)); m_pNetwork->DelChan(sChan); } else { - if (pChan) pChan->SetIsOn(false); + if (pChan) pChan->SetParting(true); sChans += (sChans.empty()) ? sChan : CString("," + sChan); } } diff --git a/src/IRCSock.cpp b/src/IRCSock.cpp index 6695b240..82a9fc3e 100644 --- a/src/IRCSock.cpp +++ b/src/IRCSock.cpp @@ -692,6 +692,7 @@ bool CIRCSock::OnJoinMessage(CJoinMessage& Message) { if (pChan) { pChan->Enable(); pChan->SetIsOn(true); + pChan->SetParting(false); PutIRC("MODE " + sChan); } } else { @@ -727,6 +728,7 @@ bool CIRCSock::OnKickMessage(CKickMessage& Message) { if (GetNick().Equals(sKickedNick) && pChan) { pChan->SetIsOn(false); + pChan->SetParting(false); // Don't try to rejoin! pChan->Disable(); @@ -1119,6 +1121,19 @@ bool CIRCSock::OnNumericMessage(CNumericMessage& Message) { m_pNetwork->AddMotdBuffer(BufferMessage(Message)); } break; + case 403: // ERR_NOSUCHCHANNEL + case 442: // ERR_NOTONCHANNEL + { + CString sChan = Message.GetParam(1); + CChan* pChan = m_pNetwork->FindChan(sChan); + if (pChan && pChan->IsParting()) { + pChan->SetIsOn(false); + pChan->SetParting(false); + m_pNetwork->PutStatus( + t_f("PART failed for channel {1}")(sChan)); + } + } + break; case 437: // :irc.server.net 437 * badnick :Nick/channel is temporarily unavailable // :irc.server.net 437 mynick badnick :Nick/channel is temporarily unavailable @@ -1189,6 +1204,7 @@ bool CIRCSock::OnPartMessage(CPartMessage& Message) { } if (Nick.NickEquals(GetNick())) { + if (pChan) pChan->SetParting(false); m_pNetwork->DelChan(sChan); } diff --git a/test/IRCSockTest.cpp b/test/IRCSockTest.cpp index f6d38134..97b6314b 100644 --- a/test/IRCSockTest.cpp +++ b/test/IRCSockTest.cpp @@ -558,3 +558,44 @@ TEST_F(IRCSockTest, ChanMode) { ":are supported by this server"); m_pTestSock->ReadLine(":irc.znc.in 324 me #chan +ntf "); } + +TEST_F(IRCSockTest, PartingFlag) { + // Set channel as joined + m_pTestChan->SetIsOn(true); + + CMessage clientPart("PART #chan"); + m_pTestClient->ReadLine(clientPart.ToString()); + + EXPECT_THAT(m_pTestSock->vsLines, ElementsAre("PART #chan")); + EXPECT_TRUE(m_pTestChan->IsParting()); + + m_pTestSock->Reset(); + + CMessage serverPart(":me PART #chan"); + m_pTestSock->ReadLine(serverPart.ToString()); + + // IsParting() should be free + EXPECT_FALSE(m_pTestChan->IsParting()); + + // Verify channel was deleted + EXPECT_EQ(m_pTestNetwork->FindChan("#chan"), nullptr); +} + +TEST_F(IRCSockTest, PartingFlagOnError) { + // Set channel as joined + m_pTestChan->SetIsOn(true); + + CMessage clientPart("PART #chan"); + m_pTestClient->ReadLine(clientPart.ToString()); + + m_pTestSock->Reset(); + + CMessage errorMsg(":server 442 me #chan :You're not on that channel"); + m_pTestSock->ReadLine(errorMsg.ToString()); + + // IsParting() should be free + EXPECT_FALSE(m_pTestChan->IsParting()); + + // Verify channel was deleted + EXPECT_NE(m_pTestNetwork->FindChan("#chan"), nullptr); +} \ No newline at end of file diff --git a/test/integration/tests/core.cpp b/test/integration/tests/core.cpp index 4169b687..3d8f5a51 100644 --- a/test/integration/tests/core.cpp +++ b/test/integration/tests/core.cpp @@ -1318,7 +1318,7 @@ TEST_F(ZNCTest, JoinDetachedChannelMultiClient) { // Commit ad7bd6d7eed84648638e1b6fd69546b9fe496576 // prevented rejoining when a client cycles a channel. -TEST_F(ZNCTest, ClientCycleChannels) { +TEST_F(ZNCTest, ClientCycleChannel) { auto znc = Run(); auto ircd = ConnectIRCd(); auto client = LoginClient(); @@ -1333,11 +1333,98 @@ TEST_F(ZNCTest, ClientCycleChannels) { client.ReadUntil("End of /NAMES"); // Clients have '/hop' or '/cycle' command that sends - // 'PART #channel' and 'JOIN #channel'. Verify ZNC rejoins.. + // 'PART #channel' and 'JOIN #channel'. Verify ZNC rejoins. client.Write(":nick PART #test"); + + // Verify PART is forwarded to server + QByteArray partMsg; + ircd.ReadUntilAndGet("PART", partMsg); + EXPECT_THAT(partMsg.toStdString(), HasSubstr("PART #test")); + + ircd.Write(":nick PART #test"); + client.ReadUntil(":nick PART #test"); + client.Write(":nick JOIN #test"); ircd.ReadUntil("JOIN #test"); } +TEST_F(ZNCTest, PartWithError403) { + auto znc = Run(); + auto ircd = ConnectIRCd(); + auto client = LoginClient(); + + // Join a channel first + client.Write("JOIN #test"); + client.Close(); + + ircd.Write(":server 001 nick :Hello"); + ircd.ReadUntil("JOIN #test"); + ircd.Write(":nick JOIN :#test"); + ircd.Write(":server 353 nick #test :nick"); + ircd.Write(":server 366 nick #test :End of /NAMES list"); + + // Reconnect client and send PART + client = LoginClient(); + client.ReadUntil(":nick JOIN :#test"); + client.Write("PART #test"); + + QByteArray partMsg; + ircd.ReadUntilAndGet("PART", partMsg); + EXPECT_THAT(partMsg.toStdString(), HasSubstr("PART #test")); + + // Server returns 403 error (ERR_NOSUCHCHANNEL) instead of confirming + ircd.Write(":server 403 nick #test :No such channel"); + + // Verify client receives the status message about PART failure + client.ReadUntil("PART failed for channel #test"); + + // Verify channel still exists by trying to rejoin + client.Write("JOIN #test"); + ircd.ReadUntil("JOIN #test"); + ircd.Write(":nick JOIN :#test"); + ircd.Write(":server 353 nick #test :nick"); + ircd.Write(":server 366 nick #test :End of /NAMES list"); + client.ReadUntil(":nick JOIN :#test"); +} + +TEST_F(ZNCTest, PartWithError442) { + auto znc = Run(); + auto ircd = ConnectIRCd(); + auto client = LoginClient(); + + // Join a channel first + client.Write("JOIN #test"); + client.Close(); + + ircd.Write(":server 001 nick :Hello"); + ircd.ReadUntil("JOIN #test"); + ircd.Write(":nick JOIN :#test"); + ircd.Write(":server 353 nick #test :nick"); + ircd.Write(":server 366 nick #test :End of /NAMES list"); + + // Reconnect client and send PART + client = LoginClient(); + client.ReadUntil(":nick JOIN :#test"); + client.Write("PART #test :leaving"); + + QByteArray partMsg; + ircd.ReadUntilAndGet("PART", partMsg); + EXPECT_THAT(partMsg.toStdString(), HasSubstr("PART #test")); + + // Server returns 442 error (ERR_NOTONCHANNEL) instead of confirming + ircd.Write(":server 442 nick #test :You're not on that channel"); + + // Verify client receives the status message about PART failure + client.ReadUntil("PART failed for channel #test"); + + // Verify channel still exists by trying to rejoin + client.Write("JOIN #test"); + ircd.ReadUntil("JOIN #test"); + ircd.Write(":nick JOIN :#test"); + ircd.Write(":server 353 nick #test :nick"); + ircd.Write(":server 366 nick #test :End of /NAMES list"); + client.ReadUntil(":nick JOIN :#test"); +} + } // namespace } // namespace znc_inttest From d2fc5d550a12a5d16548597f76b8a27c5ab0f517 Mon Sep 17 00:00:00 2001 From: RealKindOne Date: Wed, 22 Jul 2026 01:58:01 -0400 Subject: [PATCH 4/4] Apply fixes for IsParting() pull request. --- src/IRCSock.cpp | 1 - test/IRCSockTest.cpp | 5 +---- test/integration/tests/core.cpp | 4 +--- 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/IRCSock.cpp b/src/IRCSock.cpp index 82a9fc3e..6b1c24c0 100644 --- a/src/IRCSock.cpp +++ b/src/IRCSock.cpp @@ -1204,7 +1204,6 @@ bool CIRCSock::OnPartMessage(CPartMessage& Message) { } if (Nick.NickEquals(GetNick())) { - if (pChan) pChan->SetParting(false); m_pNetwork->DelChan(sChan); } diff --git a/test/IRCSockTest.cpp b/test/IRCSockTest.cpp index 97b6314b..6bebe35e 100644 --- a/test/IRCSockTest.cpp +++ b/test/IRCSockTest.cpp @@ -574,9 +574,6 @@ TEST_F(IRCSockTest, PartingFlag) { CMessage serverPart(":me PART #chan"); m_pTestSock->ReadLine(serverPart.ToString()); - // IsParting() should be free - EXPECT_FALSE(m_pTestChan->IsParting()); - // Verify channel was deleted EXPECT_EQ(m_pTestNetwork->FindChan("#chan"), nullptr); } @@ -593,7 +590,7 @@ TEST_F(IRCSockTest, PartingFlagOnError) { CMessage errorMsg(":server 442 me #chan :You're not on that channel"); m_pTestSock->ReadLine(errorMsg.ToString()); - // IsParting() should be free + // IsParting() should be cleared EXPECT_FALSE(m_pTestChan->IsParting()); // Verify channel was deleted diff --git a/test/integration/tests/core.cpp b/test/integration/tests/core.cpp index 3d8f5a51..aab1cc8d 100644 --- a/test/integration/tests/core.cpp +++ b/test/integration/tests/core.cpp @@ -1368,9 +1368,7 @@ TEST_F(ZNCTest, PartWithError403) { client.ReadUntil(":nick JOIN :#test"); client.Write("PART #test"); - QByteArray partMsg; - ircd.ReadUntilAndGet("PART", partMsg); - EXPECT_THAT(partMsg.toStdString(), HasSubstr("PART #test")); + ircd.ReadUntil("PART #test"); // Server returns 403 error (ERR_NOSUCHCHANNEL) instead of confirming ircd.Write(":server 403 nick #test :No such channel");