diff options
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/singleinstance.cpp | 188 | ||||
| -rw-r--r-- | src/singleinstance.h | 81 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_singleinstance.cpp | 142 |
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" |
