aboutsummaryrefslogtreecommitdiffstats
path: root/tests/test_singleinstance.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-29 17:04:06 +0200
committerDanilo M. <danix@danix.xyz>2026-09-29 17:04:06 +0200
commit0cf2c008d46ca2e935987ecfa51a3e712d40420e (patch)
treeac964545fad49513844a36833fc9342a833788d6 /tests/test_singleinstance.cpp
parente168af8d7d9a500759565719f16ed188ba8a8bcd (diff)
downloadqtmaildir-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/test_singleinstance.cpp')
-rw-r--r--tests/test_singleinstance.cpp142
1 files changed, 142 insertions, 0 deletions
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"