Files
Cockatrice/tests/single_instance_manager_test.cpp
BruebachL add65caae5
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
[Client] Hand off single-instance payloads outside the readyRead signal (#7373)
* [Client] Hand off single-instance payloads outside the readyRead signal

Opening a cockatrice:// link from outside a running client showed the
"Join game ...?" confirmation and then crashed on both answers. The
join reached the server first, so the other players saw the user join
and immediately disconnect.

SingleInstanceManager::handleNewConnection() emitted filesReceived from
inside the forwarding socket's readyRead lambda and only disconnected
afterwards. A joingame link makes the handler show a modal QMessageBox,
which runs a nested event loop. The sending instance exits as soon as
it has the acknowledgment, so the socket was disconnected and
deleteLater'd inside that loop: it died in the middle of its own
readyRead signal, and both this lambda and Qt's signal dispatch kept
using the freed memory.

Disconnect right after the acknowledgment and emit filesReceived
through a queued call, so no handler and no nested event loop ever runs
inside the socket's signal. Guarding the disconnect call alone is not
enough, since Qt still touches the socket after the lambda returns.

Add a regression test that reproduces the nested-event-loop hand-off.

* [Tests] Apply cmake-format to the single-instance test wiring

* [Client] Defer the single-instance socket teardown past the readyRead frame

The forwarded payload is emitted synchronously from inside the forwarding
socket's readyRead, which is what lets the modal "Join game?" confirmation
appear. A handler can spin a nested event loop, and the sending instance
exits as soon as it has the ACK, so the socket is disconnected and
deleteLater'd while that loop runs. Control then returns to this frame with
the socket already destroyed, and the inline disconnectFromServer() touched
freed memory.

Queue the teardown instead and guard it with a QPointer taken before the
handlers run -- building the guard afterwards is itself a use-after-free,
since QPointer dereferences the pointer it is constructed from. The payload
still reaches the handlers synchronously, so the confirmation is unchanged.

The regression test covers both halves: the handlers must run while the
socket is still connected, and the socket must not be torn down until the
readyRead frame has returned.

* Lint.

* [Client] Only delete the single-instance socket outside the readyRead frame

The auto-delete on `disconnected` was the remaining use-after-free. A
handler may spin a nested event loop (the modal "Join game?" box), the
sending instance exits as soon as it has the ACK, and so the peer closes
while that loop runs. deleteLater() issued from inside such a loop is not
deferred to the outer loop: Qt frees the object as soon as the nested loop
unwinds, which is still inside this readyRead emission, so Qt's own signal
dispatch and the handler lambda both keep using a freed socket. When the
notification happens to arrive late the bug hides, which is why this only
reproduced on some platforms.

Drop the auto-delete and let the queued teardown own the socket's lifetime:
it disconnects and then deletes, once the frame has unwound. The QPointer
guard was necessary but not sufficient -- it stopped us dereferencing a
dead pointer, not the socket being freed underneath the frame.

The test waits on the socket's destroyed signal instead of polling, so the
teardown assertion does not depend on platform event timing.

* [Client] Do not queue the single-instance socket teardown

The previous commit moved the deleteLater() off the disconnected signal
and into a queued invocation, believing that "queued" meant "after the
readyRead frame returns". It does not. A queued call is delivered by
whichever event loop is running, and the modal "Join game?" box runs a
nested one -- so the teardown was still executed inside the readyRead
emission, which is exactly the use-after-free it claimed to prevent.

Queueing was never necessary. Because nothing connects deleted/close to
the socket's disconnected signal any more, no nested loop can free it
while the handlers run, and emit filesReceived() is synchronous: once it
returns, the frame has unwound and the socket can be closed and deleted
inline. The QPointer stays, covering the one case that remains -- the
primary shutting down while the confirmation box is open, since
QLocalServer destroys its pending connections.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-02 21:19:02 +02:00

135 lines
5.5 KiB
C++

#include "single_instance_manager.h"
#include <QCoreApplication>
#include <QDataStream>
#include <QEventLoop>
#include <QLocalServer>
#include <QLocalSocket>
#include <QTimer>
#include <gtest/gtest.h>
namespace
{
constexpr int HANDSHAKE_TIMEOUT_MS = 5000;
constexpr int MODAL_DIALOG_MS = 50;
const QStringList PAYLOAD{QStringLiteral("cockatrice://joingame?hostname=127.0.0.1&port=4747&roomid=0&gameid=1")};
/// Serializes a payload the way SingleInstanceManager::forwardToPrimary() does.
QByteArray encodePayload(const QStringList &files)
{
QByteArray payload;
QDataStream out(&payload, QIODevice::WriteOnly);
out << files;
QByteArray message;
QDataStream msgStream(&message, QIODevice::WriteOnly);
msgStream << static_cast<quint32>(payload.size());
message.append(payload);
return message;
}
} // namespace
// Regression test for the use-after-free in SingleInstanceManager::handleNewConnection().
//
// The forwarded payload is handed to the handlers from inside the forwarding
// socket's readyRead emission, which is what lets the "Join game?" confirmation
// appear at all. A handler can spin a nested event loop (a modal QMessageBox),
// and the sending instance exits as soon as it has the acknowledgment, so the
// peer closes *while that loop runs*. deleteLater() called from inside such a
// nested loop does not wait for the outer loop: Qt frees the object as soon as
// the nested loop unwinds, still inside the readyRead emission, so Qt's own
// signal dispatch and the handler lambda both end up holding a freed socket.
// Nothing may therefore delete the socket from the disconnected signal, and the
// teardown is queued until that frame has unwound.
TEST(SingleInstanceManagerTest, HandlersRunSynchronouslyAndSocketIsNotTouchedAfterTheyReturn)
{
// Scope the hand-off socket to this test process so a Cockatrice the
// developer happens to be running cannot claim it.
qputenv("USER", QByteArrayLiteral("cockatrice-test-") + QByteArray::number(QCoreApplication::applicationPid()));
SingleInstanceManager primary;
ASSERT_TRUE(primary.tryRun(QStringList())) << "could not become the primary instance";
auto *server = primary.findChild<QLocalServer *>();
ASSERT_NE(nullptr, server) << "the primary instance is not listening";
QLocalSocket sender;
QStringList handled;
QByteArray acknowledgment;
int handlerCalls = 0;
bool socketConnectedWhenHandled = false;
bool socketReleased = false;
// Waited on after the handlers return: the deferred teardown deletes the
// forwarding socket once the readyRead frame has unwound.
QEventLoop teardownLoop;
QEventLoop loop;
QObject::connect(&primary, &SingleInstanceManager::filesReceived, &primary, [&](const QStringList &files) {
++handlerCalls;
// The payload must reach the handlers while the forwarding socket is
// still connected: deferring the emit would suppress the modal "Join
// game?" confirmation this hand-off exists to show.
auto *connection = server->findChild<QLocalSocket *>();
socketConnectedWhenHandled = connection != nullptr && connection->state() == QLocalSocket::ConnectedState;
// Observe the socket's destruction rather than polling for it. `destroyed`
// is emitted just before the object goes away, so this stays correct
// however the teardown ends up deleting it, and `&primary` keeps the
// observer alive even once the socket itself is gone.
if (connection != nullptr) {
QObject::connect(connection, &QObject::destroyed, &primary, [&] {
socketReleased = true;
teardownLoop.quit();
});
}
// The primary acknowledges the payload before handling it, so that a
// sender waiting on the acknowledgment never mistakes a busy primary
// for a dead one.
sender.waitForReadyRead(HANDSHAKE_TIMEOUT_MS);
acknowledgment = sender.readAll();
// Stand in for the modal "Join game?" confirmation: the sending instance
// goes away while this nested loop is running, which is what used to
// destroy the socket underneath this readyRead frame.
sender.abort();
QEventLoop modalDialog;
QTimer::singleShot(MODAL_DIALOG_MS, &modalDialog, &QEventLoop::quit);
modalDialog.exec();
handled = files;
loop.quit();
});
sender.connectToServer(server->serverName());
ASSERT_TRUE(sender.waitForConnected(HANDSHAKE_TIMEOUT_MS));
sender.write(encodePayload(PAYLOAD));
sender.flush();
QTimer::singleShot(HANDSHAKE_TIMEOUT_MS, &loop, &QEventLoop::quit);
loop.exec();
EXPECT_EQ(1, handlerCalls) << "the payload must be handed to the handlers exactly once";
EXPECT_EQ(PAYLOAD, handled);
EXPECT_TRUE(socketConnectedWhenHandled) << "the payload was not handled from inside the readyRead emission";
EXPECT_FALSE(acknowledgment.isEmpty()) << "the primary never acknowledged the payload";
// The deferred teardown must not be skipped, and must not delete the socket
// from inside the nested loop above: either would show up here as a crash or
// as a socket that outlives the hand-off and leaks a connection per payload.
QTimer::singleShot(HANDSHAKE_TIMEOUT_MS, &teardownLoop, &QEventLoop::quit);
teardownLoop.exec();
EXPECT_TRUE(socketReleased) << "the forwarding socket was never released after the hand-off";
}
int main(int argc, char **argv)
{
QCoreApplication app(argc, argv);
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}