aboutsummaryrefslogtreecommitdiffstats
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
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>
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/singleinstance.cpp188
-rw-r--r--src/singleinstance.h81
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_singleinstance.cpp142
5 files changed, 413 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index ca26933..4a344d5 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -54,6 +54,7 @@ add_library(qtmaildir_lib STATIC
eventpane.cpp
calendarwindow.cpp
launchselectors.cpp
+ singleinstance.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/singleinstance.cpp b/src/singleinstance.cpp
new file mode 100644
index 0000000..dbc53ed
--- /dev/null
+++ b/src/singleinstance.cpp
@@ -0,0 +1,188 @@
+/*
+ * 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 "singleinstance.h"
+
+#include <QDebug>
+#include <QFileInfo>
+#include <QLocalServer>
+#include <QLocalSocket>
+#include <QTimer>
+
+namespace {
+
+/// How long a client waits for the running instance, and how long the server
+/// keeps a connection open waiting for its payload. Short: both processes are
+/// on this machine and the peer is either there or it is not. A long wait
+/// would stall a launch behind a wedged instance, which is worse than opening
+/// a window.
+constexpr int kTimeoutMs = 2000;
+
+} // namespace
+
+SingleInstance::SingleInstance(const QString &socketPath, QObject *parent)
+ : QObject(parent), m_socketPath(socketPath)
+{
+ // Registered here rather than at a call site so any connection carrying
+ // this type works, including a queued one a future caller might add. A
+ // Q_DECLARE_METATYPE alone gives the type a metatype but does not register
+ // it under the name a queued invoke resolves, which is the trap AGENTS.md
+ // records for Q_ENUM.
+ qRegisterMetaType<LaunchSelectors>();
+}
+
+SingleInstance::~SingleInstance()
+{
+ if (m_server) {
+ m_server->close();
+ // Removes the filesystem entry, so an orderly exit leaves nothing for
+ // the next launch to reclaim.
+ QLocalServer::removeServer(m_socketPath);
+ }
+}
+
+bool SingleInstance::isServer() const
+{
+ return m_server != nullptr && m_server->isListening();
+}
+
+bool SingleInstance::tryBecomeServer()
+{
+ if (isServer())
+ return true;
+
+ // CONNECT FIRST, and the order is the design. A successful connection means
+ // a live instance owns this socket and this process is the messenger. A
+ // refused connection on an EXISTING file means the file is stale, left by a
+ // crash, and can be removed; doing it in this order is what stops a live
+ // instance being removed out from under itself.
+ {
+ QLocalSocket probe;
+ probe.connectToServer(m_socketPath);
+ if (probe.waitForConnected(kTimeoutMs)) {
+ probe.disconnectFromServer();
+ return false;
+ }
+ }
+
+ auto *server = new QLocalServer(this);
+ // The socket is the user's own, in their own state directory. Nothing else
+ // has any business connecting to it.
+ //
+ // With this option Qt binds in a private temporary directory and RENAMES
+ // the socket onto the path, and a rename replaces whatever is there: a
+ // stale file and a LIVE instance's socket alike (measured on Qt 6.11). So
+ // listen() does not refuse a taken path, and the probe above is the only
+ // thing standing between a second launch and the first one's socket.
+ server->setSocketOptions(QLocalServer::UserAccessOption);
+
+ if (!server->listen(m_socketPath)) {
+ if (server->serverError() == QAbstractSocket::AddressInUseError
+ && QFileInfo::exists(m_socketPath)) {
+ // Nothing answered the probe above, so this file is stale. Not
+ // reached on Qt 6.11, for the reason above; kept because a listen
+ // that binds in place (no socket options, or a future Qt) fails
+ // here with AddressInUse on a stale file.
+ QLocalServer::removeServer(m_socketPath);
+ server->listen(m_socketPath);
+ }
+ }
+
+ if (!server->isListening()) {
+ // A read-only state directory, a filesystem that has no unix sockets,
+ // or a path whose parent does not exist. Report and carry on: the
+ // window must still open. Losing single-instance behaviour is a
+ // degradation; losing the mail client is not acceptable.
+ qWarning() << "qtmaildir: cannot create the single-instance socket at"
+ << m_socketPath << ":" << server->errorString()
+ << "- continuing without it";
+ delete server;
+ return false;
+ }
+
+ m_server = server;
+ connect(m_server, &QLocalServer::newConnection, this, [this]() {
+ while (QLocalSocket *socket = m_server->nextPendingConnection()) {
+ // Read ASYNCHRONOUSLY and parse on disconnect. The client's whole
+ // life is one write followed by a disconnect, so the disconnect is
+ // what says the payload is complete; a readAll() after the first
+ // readyRead could see only part of it. And no waitForReadyRead():
+ // this runs on the UI thread, and a peer that connects and writes
+ // nothing (the probe in tryBecomeServer() is exactly that) must
+ // not freeze the window.
+ auto *buffer = new QByteArray;
+ connect(socket, &QLocalSocket::readyRead, socket, [socket, buffer]() {
+ buffer->append(socket->readAll());
+ // A peer still writing past the cap is not a launch payload.
+ if (buffer->size() > LaunchSelectors::kMaxPayloadBytes)
+ socket->abort();
+ });
+ connect(socket, &QLocalSocket::disconnected, this,
+ [this, socket, buffer]() {
+ buffer->append(socket->readAll());
+ const QByteArray payload = *buffer;
+ delete buffer;
+ socket->deleteLater();
+
+ // Nothing written at all is the other launch's connect-first
+ // probe, not a request. A real request is never empty, even
+ // with no selectors, because the payload carries a version.
+ if (payload.isEmpty())
+ return;
+
+ QString error;
+ const LaunchSelectors selectors =
+ LaunchSelectors::fromPayload(payload, &error);
+ if (!error.isEmpty()) {
+ qWarning() << "qtmaildir: ignoring a launch payload:"
+ << error;
+ return;
+ }
+
+ // Emitted even when empty: a bare launch means "raise
+ // yourself".
+ emit selectorsReceived(selectors);
+ });
+ // A peer that connects and never hangs up is dropped rather than
+ // held open for the life of the window.
+ QTimer::singleShot(kTimeoutMs, socket, [socket]() {
+ socket->abort();
+ });
+ }
+ });
+
+ return true;
+}
+
+bool SingleInstance::sendToRunningInstance(const LaunchSelectors &selectors)
+{
+ QLocalSocket socket;
+ socket.connectToServer(m_socketPath);
+ if (!socket.waitForConnected(kTimeoutMs))
+ return false;
+
+ socket.write(selectors.toPayload());
+ // Flushed before returning, because the caller exits immediately
+ // afterwards and an unflushed write would be lost with the process.
+ if (!socket.waitForBytesWritten(kTimeoutMs))
+ return false;
+
+ // The disconnect is what tells the server the payload is complete.
+ socket.disconnectFromServer();
+ return true;
+}
diff --git a/src/singleinstance.h b/src/singleinstance.h
new file mode 100644
index 0000000..3f63bfd
--- /dev/null
+++ b/src/singleinstance.h
@@ -0,0 +1,81 @@
+/*
+ * 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.
+ */
+
+#pragma once
+
+#include <QObject>
+#include <QString>
+
+#include "launchselectors.h"
+
+class QLocalServer;
+
+/// Makes a launch either the running instance or a messenger to it (item 200).
+///
+/// **This is not network protocol work.** A QLocalServer is a unix domain
+/// socket between two copies of this program, owned by the user, in the user's
+/// own state directory. The rule in AGENTS.md is about IMAP and SMTP.
+///
+/// Knows nothing about queries, accounts or mail: it carries a LaunchSelectors
+/// from one process to another and emits what arrived.
+class SingleInstance : public QObject
+{
+ Q_OBJECT
+
+public:
+ /// \p socketPath is a filesystem path, so a test can point it inside a
+ /// QTemporaryDir and never touch the user's real one.
+ explicit SingleInstance(const QString &socketPath,
+ QObject *parent = nullptr);
+ ~SingleInstance() override;
+
+ /// Tries to become the instance others talk to.
+ ///
+ /// Returns true when this process is now listening, false when another
+ /// instance already is OR when no socket could be created at all. The
+ /// caller treats both falses the same way for the second case: **a socket
+ /// that cannot be created must not stop the window opening**, or a
+ /// read-only state directory costs the user their mail client.
+ ///
+ /// Handles the stale socket file, which is the ordinary aftermath of a
+ /// crash: it attempts a CONNECTION first, and a refused connection on an
+ /// existing file proves nothing is serving it, so the file may be
+ /// replaced. Connecting first is what stops a live instance being removed
+ /// out from under itself, and it is the ONLY guard: listen() replaces a
+ /// taken path rather than refusing it (see the .cpp).
+ bool tryBecomeServer();
+
+ /// True when tryBecomeServer() succeeded and this process is listening.
+ bool isServer() const;
+
+ /// Sends \p selectors to the running instance. Returns false when there is
+ /// none, or when the write could not be completed.
+ ///
+ /// An EMPTY selector set is still sent: a bare `qtmaildir` against a
+ /// running window means "raise yourself", which is a request and not a
+ /// no-op.
+ bool sendToRunningInstance(const LaunchSelectors &selectors);
+
+signals:
+ /// A later launch handed these over. Emitted on the server side only.
+ void selectorsReceived(const LaunchSelectors &selectors);
+
+private:
+ QString m_socketPath;
+ QLocalServer *m_server = nullptr;
+};
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"