mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
[Installer] Graceful close and a way out of a stuck update (#7687)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Stop calling the payload free while an executable is locked The guard answered "nothing is holding the payload" from two lookups that can both miss a running instance: Restart Manager does not reliably report a program's own image or the modules it mapped, and the image path of a process running under another account cannot be read from the probe at all. A user log showed exactly that: the first guard passed while cockatrice.exe was running from the directory, the old uninstaller silently skipped the file it could not delete, and the update aborted half an uninstall later with "cancelled by user" after a close attempt the running window ignored. Ask the file itself. A running image is opened without write sharing, so the write open replacing it needs is refused with ERROR_SHARING_VIOLATION for as long as the program runs, while a file that is merely being read still accepts it. When neither lookup named anybody and a payload executable refuses that open, the probe answers that the file is in use and that the program holding it could not be identified - the guard's "could not confirm" dialog, whose Retry works once the user has closed it - instead of passing the payload as free. Only the payload executables are tested, not every file the purge removes: a locked executable proves a program is running and a mapped DLL belongs to a running executable, while a locked DLL or qt.conf can be the asking process itself, which is the loop the guard already had. The open is retried once after 300 ms, and only for the files that refused, so a scanner holding a file for a moment cannot stop an install for nothing. The Windows probe check now covers that answer: a payload executable held by the asking process, so that it cannot be named, has to come back as in use rather than as free. Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de> * [Installer] Close running clients over a local socket The installer could only ask a running client to close with WM_CLOSE, which an instance hidden in the notification area or sitting on its own confirmation cannot answer usefully. Give every client a per-process local socket and have the holder probe ask it there first, falling back to WM_CLOSE for clients from before the socket. * [Installer] Offer to check again or update by hand when files stay locked Replace the two-answer guard prompt with force close, close by hand and check again, or cancel. Retry the automatic close while the set of holders changes, and point a cancelled run at the latest release so a user who does not want anything killed has a way forward that is not cancelling the update again. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
720adf5410
commit
bbc7e462ff
25 changed files with 897 additions and 130 deletions
|
|
@ -345,6 +345,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp
|
||||
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
|
||||
src/interface/window_main.cpp
|
||||
src/installer_close_server.cpp
|
||||
src/main.cpp
|
||||
src/single_instance_manager.cpp
|
||||
src/interface/widgets/tabs/abstract_tab_deck_editor.cpp
|
||||
|
|
@ -450,6 +451,7 @@ set(cockatrice_SOURCES
|
|||
src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h
|
||||
src/interface/widgets/utility/compact_push_button.cpp
|
||||
src/interface/widgets/utility/compact_push_button.h
|
||||
src/installer_close_server.h
|
||||
src/single_instance_manager.h
|
||||
src/client/url_scheme_event_filter.h
|
||||
src/interface/intents/intent_connect_to_server.cpp
|
||||
|
|
|
|||
81
cockatrice/src/installer_close_server.cpp
Normal file
81
cockatrice/src/installer_close_server.cpp
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#include "installer_close_server.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QLocalServer>
|
||||
#include <QLocalSocket>
|
||||
#include <QTimer>
|
||||
#include <libcockatrice/utility/installer_close_protocol.h>
|
||||
#include <libcockatrice/utility/local_server_name.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
/// The one line the probe writes, from the header both ends include. A QByteArray view of
|
||||
/// the shared literal, so the client cannot drift from the probe's copy.
|
||||
const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1);
|
||||
|
||||
/// How long a connection that never sends the command may stay open. The probe writes and
|
||||
/// closes at once, so this only reaps a stray client; it must not run while a close request
|
||||
/// is being handled, which is why it is a timer on the socket and not a disconnected slot.
|
||||
constexpr int STRAY_CONNECTION_MILLISECONDS = 5000;
|
||||
} // namespace
|
||||
|
||||
InstallerCloseServer::InstallerCloseServer(QObject *parent) : QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
QString InstallerCloseServer::socketNameForUser(qint64 processId, const QString &userName)
|
||||
{
|
||||
return QStringLiteral("CockatriceInstallerClose-%1-%2").arg(processId).arg(userName);
|
||||
}
|
||||
|
||||
QString InstallerCloseServer::socketName(qint64 processId)
|
||||
{
|
||||
// The same scoping helper the single-instance socket uses, so the probe only has to
|
||||
// reproduce one rule (USER, then USERNAME, then the home directory name).
|
||||
return scopedLocalServerName(QStringLiteral("CockatriceInstallerClose-%1").arg(processId));
|
||||
}
|
||||
|
||||
bool InstallerCloseServer::listen()
|
||||
{
|
||||
if (server != nullptr) {
|
||||
return server->isListening();
|
||||
}
|
||||
name = socketName(QCoreApplication::applicationPid());
|
||||
server = new QLocalServer(this);
|
||||
// A crashed instance can leave a Unix socket file behind; on Windows this is a no-op.
|
||||
QLocalServer::removeServer(name);
|
||||
if (!server->listen(name)) {
|
||||
delete server;
|
||||
server = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
connect(server, &QLocalServer::newConnection, this, [this]() {
|
||||
while (QLocalSocket *socket = server->nextPendingConnection()) {
|
||||
// No disconnected -> deleteLater() slot: the close request can spin a nested
|
||||
// event loop, the probe disconnects while it runs, and a deferred delete would
|
||||
// then free the socket from inside its own readyRead emission. The reaper covers
|
||||
// a client that never sends the line; the handler below deletes after the emit.
|
||||
auto *reaper = new QTimer(socket);
|
||||
reaper->setSingleShot(true);
|
||||
connect(reaper, &QTimer::timeout, socket, &QObject::deleteLater);
|
||||
reaper->start(STRAY_CONNECTION_MILLISECONDS);
|
||||
connect(socket, &QLocalSocket::readyRead, this, [this, socket, reaper]() {
|
||||
if (!socket->canReadLine()) {
|
||||
return;
|
||||
}
|
||||
if (socket->readLine() != CLOSE_COMMAND) {
|
||||
return;
|
||||
}
|
||||
// Stop the reaper before the emit: the receiver may close the window and run
|
||||
// a nested event loop for as long as the user takes, and the reaper must not
|
||||
// free the socket from inside that loop while this handler still holds it.
|
||||
reaper->stop();
|
||||
emit closeRequested();
|
||||
socket->disconnectFromServer();
|
||||
socket->deleteLater();
|
||||
});
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
50
cockatrice/src/installer_close_server.h
Normal file
50
cockatrice/src/installer_close_server.h
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#ifndef COCKATRICE_INSTALLER_CLOSE_SERVER_H
|
||||
#define COCKATRICE_INSTALLER_CLOSE_SERVER_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
class QLocalServer;
|
||||
|
||||
/**
|
||||
* @brief Listens for the installer's request to close this instance gracefully.
|
||||
*
|
||||
* The Windows installer cannot reach an instance whose window is hidden in the notification
|
||||
* area with WM_CLOSE, and it must not kill a process that may hold an unsaved deck. It
|
||||
* connects to this local socket instead and writes one line; the instance then closes the
|
||||
* same way its own close button does - saving settings and answering its own confirmation,
|
||||
* never bypassing it.
|
||||
*
|
||||
* The socket name carries the process id, so every instance has its own and an installer
|
||||
* that found several can ask each one. It is built by installerCloseSocketName() in
|
||||
* tools/installer-holder-probe/installer_close_name.h; the two must stay identical.
|
||||
*/
|
||||
class InstallerCloseServer : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit InstallerCloseServer(QObject *parent = nullptr);
|
||||
|
||||
/**
|
||||
* @brief Starts listening. A failure is not fatal: the installer falls back to
|
||||
* WM_CLOSE, which is what older clients answer.
|
||||
*/
|
||||
bool listen();
|
||||
|
||||
/// The socket name this process listens on. The installer's probe builds the same
|
||||
/// string from the process id and the account name.
|
||||
static QString socketName(qint64 processId);
|
||||
|
||||
/// The name for an explicit account, exposed so the probe's name helper and this one
|
||||
/// can be tested against each other.
|
||||
static QString socketNameForUser(qint64 processId, const QString &userName);
|
||||
|
||||
signals:
|
||||
void closeRequested();
|
||||
|
||||
private:
|
||||
QLocalServer *server = nullptr;
|
||||
QString name;
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_INSTALLER_CLOSE_SERVER_H
|
||||
|
|
@ -986,6 +986,30 @@ bool MainWindow::closeForUpdate()
|
|||
return close();
|
||||
}
|
||||
|
||||
void MainWindow::requestInstallerClose()
|
||||
{
|
||||
// The same shutdown the in-app updater asks for: close the window, flush settings and let
|
||||
// the tabs answer their own prompts. Never a forced quit - ending a process that has not
|
||||
// agreed is the installer's separate force-close answer, not this one.
|
||||
if (closeForUpdate()) {
|
||||
QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); });
|
||||
return;
|
||||
}
|
||||
// A confirmation is already up, or a shutdown is in progress. Bring it forward: an
|
||||
// installer prompt behind a hidden confirmation is the deadlock this request exists to
|
||||
// break, and the user has to be able to answer it. showWindowIfHidden() also clears
|
||||
// Qt::WindowMinimized, which raise() alone does not: a minimized window can keep the
|
||||
// confirmation behind the installer.
|
||||
showWindowIfHidden();
|
||||
if (QWidget *modal = QApplication::activeModalWidget()) {
|
||||
modal->raise();
|
||||
modal->activateWindow();
|
||||
}
|
||||
raise();
|
||||
activateWindow();
|
||||
QApplication::alert(this, 0);
|
||||
}
|
||||
|
||||
void MainWindow::changeEvent(QEvent *event)
|
||||
{
|
||||
if (event->type() == QEvent::LanguageChange) {
|
||||
|
|
|
|||
|
|
@ -255,6 +255,17 @@ public:
|
|||
*/
|
||||
bool closeForUpdate();
|
||||
|
||||
/**
|
||||
* @brief Closes this instance because the installer asked it to over its local socket.
|
||||
*
|
||||
* The request reaches an instance hidden in the notification area or sitting on its own
|
||||
* confirmation, which is exactly where a WM_CLOSE from the installer does not. The close
|
||||
* is still the ordinary one: a clean client exits, one with unsaved work raises the same
|
||||
* confirmation the close button does, and a confirmation already up is brought to the
|
||||
* front instead of being left behind the installer.
|
||||
*/
|
||||
void requestInstallerClose();
|
||||
|
||||
protected:
|
||||
void closeEvent(QCloseEvent *event) override;
|
||||
void changeEvent(QEvent *event) override;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
#include "client/sound_engine.h"
|
||||
#include "client/url_scheme_event_filter.h"
|
||||
#include "database/interface/settings_card_preference_provider.h"
|
||||
#include "installer_close_server.h"
|
||||
#include "interface/intents/intent.h"
|
||||
#include "interface/intents/intent_open_local_deck.h"
|
||||
#include "interface/logger.h"
|
||||
|
|
@ -431,6 +432,15 @@ int main(int argc, char *argv[])
|
|||
|
||||
MainWindow ui;
|
||||
|
||||
// The installer asks a running instance to close over this socket, which reaches a
|
||||
// hidden window and one sitting on its own confirmation. Failure is not fatal: the
|
||||
// installer falls back to WM_CLOSE for clients that have no socket.
|
||||
InstallerCloseServer installerClose;
|
||||
if (installerClose.listen()) {
|
||||
QObject::connect(&installerClose, &InstallerCloseServer::closeRequested, &ui,
|
||||
&MainWindow::requestInstallerClose);
|
||||
}
|
||||
|
||||
// A URL launch must own the connection: the intent chain triggered by the
|
||||
// URL connects to the server named in the URL, so the window's own startup
|
||||
// auto-connect must not race against it (two connectToServer calls tear
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue