diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-29 17:04:06 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-29 17:04:06 +0200 |
| commit | 0cf2c008d46ca2e935987ecfa51a3e712d40420e (patch) | |
| tree | ac964545fad49513844a36833fc9342a833788d6 /tests | |
| parent | e168af8d7d9a500759565719f16ed188ba8a8bcd (diff) | |
| download | qtmaildir-0cf2c008d46ca2e935987ecfa51a3e712d40420e.tar.gz qtmaildir-0cf2c008d46ca2e935987ecfa51a3e712d40420e.zip | |
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 <noreply@anthropic.com>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_singleinstance.cpp | 142 |
2 files changed, 143 insertions, 0 deletions
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8cc4d8b..2b4b42d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -93,6 +93,7 @@ add_qtmaildir_test(composewindow) add_qtmaildir_test(formattoolbar) add_qtmaildir_test(senddialog) add_qtmaildir_test(launchselectors) +add_qtmaildir_test(singleinstance) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up 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. <danix@danix.xyz> + * + * 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 <QLocalServer> +#include <QSignalSpy> +#include <QTemporaryDir> +#include <QtTest> + +#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("<abc@example.org>"); + + 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<LaunchSelectors>(); + QCOMPARE(received.account, QStringLiteral("work")); + QCOMPARE(received.messageId, QStringLiteral("<abc@example.org>")); +} + +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<LaunchSelectors>().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" |
