From 0cf2c008d46ca2e935987ecfa51a3e712d40420e Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 29 Sep 2026 17:04:06 +0200 Subject: feat: add the single-instance socket A QLocalServer under the state directory. The first launch listens; a later one connects, hands over its selectors and exits. Connect-first ordering, and on Qt 6.11 the probe is the ONLY guard for a live instance: with UserAccessOption, listen() binds in a private directory and renames the socket onto the path, which replaces whatever is there, a stale file and a live socket alike. Measured: with the probe disabled, a second launch takes the first one's socket and both handover tests fail. The same rename is what reclaims a stale file after a crash; the removeServer() retry on AddressInUse is kept for a listen that binds in place. The server reads each connection asynchronously and parses on disconnect, rather than blocking in waitForReadyRead() on the UI thread. The client's one write followed by a disconnect is what marks the payload complete, a reader past the payload cap is aborted, and a connection that never hangs up is dropped after two seconds. A connection that writes nothing at all is the other launch's probe and is ignored without a warning. A socket that cannot be created does NOT stop the window opening. A read-only state directory costs single-instance behaviour, which is a degradation; it must not cost the user their mail client. Co-Authored-By: Claude Opus 5.5 --- tests/test_singleinstance.cpp | 142 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 tests/test_singleinstance.cpp (limited to 'tests/test_singleinstance.cpp') diff --git a/tests/test_singleinstance.cpp b/tests/test_singleinstance.cpp new file mode 100644 index 0000000..576ba64 --- /dev/null +++ b/tests/test_singleinstance.cpp @@ -0,0 +1,142 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + */ + +#include +#include +#include +#include + +#include "launchselectors.h" +#include "singleinstance.h" + +/// The socket half of item 200. Every case runs against a socket name of its +/// own inside a QTemporaryDir, so nothing here can reach a real running +/// qtmaildir, and two cases cannot collide. +class TestSingleInstance : public QObject +{ + Q_OBJECT + +private slots: + void init(); + + void theFirstInstanceBecomesTheServer(); + void aSecondInstanceHandsOverItsSelectors(); + void aSecondInstanceWithNoSelectorsStillArrives(); + void aStaleSocketFileIsReclaimed(); + void anUncreatableSocketDoesNotStopStartup(); + +private: + QTemporaryDir m_dir; + QString m_socketPath; +}; + +void TestSingleInstance::init() +{ + QVERIFY(m_dir.isValid()); + // A name per test function, so a socket left behind by one case cannot + // decide the next one's result. + m_socketPath = m_dir.filePath( + QStringLiteral("sock-%1").arg(QTest::currentTestFunction())); +} + +void TestSingleInstance::theFirstInstanceBecomesTheServer() +{ + SingleInstance first(m_socketPath); + QVERIFY(first.tryBecomeServer()); + QVERIFY(first.isServer()); +} + +void TestSingleInstance::aSecondInstanceHandsOverItsSelectors() +{ + // The whole point of the feature: the second process does not open a + // window, it hands its request to the first and exits. + SingleInstance first(m_socketPath); + QVERIFY(first.tryBecomeServer()); + + QSignalSpy arrived(&first, &SingleInstance::selectorsReceived); + + LaunchSelectors selectors; + selectors.account = QStringLiteral("work"); + selectors.messageId = QStringLiteral(""); + + SingleInstance second(m_socketPath); + QVERIFY(!second.tryBecomeServer()); + QVERIFY(second.sendToRunningInstance(selectors)); + + QTRY_VERIFY_WITH_TIMEOUT(arrived.count() == 1, 5000); + const auto received = + arrived.first().at(0).value(); + QCOMPARE(received.account, QStringLiteral("work")); + QCOMPARE(received.messageId, QStringLiteral("")); +} + +void TestSingleInstance::aSecondInstanceWithNoSelectorsStillArrives() +{ + // A bare `qtmaildir` against a running instance means "raise yourself". + // That is a real request, so it must arrive rather than being dropped as + // an empty message. + SingleInstance first(m_socketPath); + QVERIFY(first.tryBecomeServer()); + + QSignalSpy arrived(&first, &SingleInstance::selectorsReceived); + + SingleInstance second(m_socketPath); + QVERIFY(!second.tryBecomeServer()); + QVERIFY(second.sendToRunningInstance(LaunchSelectors())); + + QTRY_VERIFY_WITH_TIMEOUT(arrived.count() == 1, 5000); + QVERIFY(arrived.first().at(0).value().isEmpty()); +} + +void TestSingleInstance::aStaleSocketFileIsReclaimed() +{ + // A crash or a kill leaves the socket file behind, and listen() then fails + // with AddressInUse on a file nothing is serving. Without recovery the + // application would never start again until someone deleted it by hand. + // + // A plain file at the socket path stands in for what a killed process + // leaves behind: a filesystem entry with no process serving it, so nothing + // answers a connection. + QFile stale(m_socketPath); + QVERIFY(stale.open(QIODevice::WriteOnly)); + stale.close(); + QVERIFY(QFile::exists(m_socketPath)); + + SingleInstance fresh(m_socketPath); + QVERIFY2(fresh.tryBecomeServer(), + "a stale socket file must not stop the application starting"); + QVERIFY(fresh.isServer()); +} + +void TestSingleInstance::anUncreatableSocketDoesNotStopStartup() +{ + // A read-only state directory must degrade to today's behaviour, a window + // that opens and works, rather than to no mail client at all. The caller + // reads isServer() as false and carries on. + const QString impossible = + m_dir.filePath(QStringLiteral("no/such/directory/sock")); + + SingleInstance instance(impossible); + QVERIFY(!instance.tryBecomeServer()); + QVERIFY(!instance.isServer()); + // And it cannot reach a running instance either, since there is none. + QVERIFY(!instance.sendToRunningInstance(LaunchSelectors())); +} + +QTEST_MAIN(TestSingleInstance) +#include "test_singleinstance.moc" -- cgit v1.2.3