Cockatrice/tools/installer-holder-probe/main.cpp
sentry[bot] 30d3afc7b4
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
[Updater] Close a running Cockatrice when the installer asks (#7672)
* [Updater] Refuse a close that arrives while one is already asking

MainWindow::closeEvent() had a re-entrancy guard that returned without touching
the event. A QCloseEvent is accepted unless something ignores it, so a second
close request - an installer replacing our files posts one every 30 s - accepted
the close while the first one was still blocked on its own confirmation. close()
then hid this window, hid the confirmation dialog with it, and left the process
running inside the nested loop that dialog had opened: no window, no way to
answer it, and every later close request accepted into the same dead end. That
is what an update looked like when it could not close a running client, and it
is why the installer could name the process, bring it forward and ask it to
close for minutes without anything happening.

Track how far the shutdown has got instead. While a confirmation is up, a close
from anywhere else is refused, which leaves the question in front of the user;
once the shutdown has been accepted, a duplicate close (Qt delivers the event
twice on some platforms) is accepted again so it cannot cancel the quit.

* [Installer] Leave an instance that is asking a question alone

An instance that answered a close by hiding rather than quitting - the state the
client used to get into - has no visible window left, and the confirmation it is
waiting on is hidden with the window that owns it. The close probe only ever
looked at visible windows, so it restored the main window, posted WM_CLOSE into
the same dead end, and reported "still running" without saying why.

Show every window the instance left hidden before choosing the one to close, and
skip the close when the window it would reach is disabled: a modal dialog of the
instance's own is up, and posting the close then only hides the question again.
Name what is left of the window in the report as well - gone, still open, hidden
again, and whether the thread answers at all - so the log tells a refused close
from one that was never delivered.

* [Installer] Offer to end a Cockatrice that will not close

A close is a request, and an instance that does not answer it - waiting on a
question nobody is looking at, or stuck where a close cannot reach - leaves the
user with no way forward: the files cannot be replaced while it runs, and
clicking Yes again only repeats the request.

After a second close that changed nothing, offer the only thing that can free
the files: end it. The prompt says what it costs - an unsaved deck, a running
game or a setting it has not written yet - the default button is the safe one,
and it is a separate answer rather than a relabelled "Yes", so it is not
reached by clicking through the close prompt. Only cockatrice.exe is ever
ended: Oracle writes the card database in place and Servatrice is a service, so
both are named and left to the user, exactly as they are for the close.

The probe gains an --end-holders mode for it, and the uninstaller's guard gets
the same offer.

* [Installer] Never name the process that is doing the asking

The old uninstaller is started with _?= so it runs in place, from the very
directory it is emptying, and an installer can be downloaded into the folder it
installs to. Both are then processes running from the directory, so the probe
named them - and the guard asked the user to close the program that was asking.
That question cannot be answered: a close and the end offer both skip anything
that is not cockatrice.exe, so the guard repeated itself until the update or the
uninstall was cancelled.

The probe and the processes that started it are now never named, by either
lookup. The walk up the parent chain stops at the first program the payload
installs, because in an in-app update the installer's own parent is the
Cockatrice instance being replaced - the one the guard exists to name.

The Windows check held its locked file in the script that starts the probe, so
that holder is excluded by design now and the check read it as "nothing found".
It holds the file from a process of its own instead, and asserts the exclusion
itself: a file that only the asking process holds comes back as nothing.

* [Installer] Restore only the windows a user could answer

Showing every hidden window an instance owns put Qt's own bookkeeping windows on
screen: the tray icon keeps one to receive the TaskbarCreated message, styled
WS_OVERLAPPED and titled QTrayIconMessageWindow. Restored, it is an empty white
rectangle in the taskbar with no title bar to close it - it has no window
procedure that would hide it again, and the process is busy in the very loop the
restore is meant to break, so it stayed until the process was force-ended.

Only framed, non-empty windows are restored and considered for the close now.
That also keeps the close off Qt's tray window, which answers WM_CLOSE by asking
the application to terminate - a quit that skips the confirmation the close is
supposed to reach.

* [Installer] Uninstall the previous version before installing over it

A regular install over an existing one asked "a previous version must be
uninstalled", then ran the old uninstaller interactively. That uninstaller
put its confirmation page behind the installer window, where it could not be
answered, and it only removed the installation after the installer had
already written the new one over it.

Run the previous version's uninstaller silently instead. The installer has
already asked the question the user answered, so a second, hidden confirmation
is pure risk: with /S there is no window to be behind, and ExecWait is then
waiting for something that cannot outlive it. QuietUninstallString is used
when the old installation has one, otherwise /S is appended.

ExecWait only waits while the uninstaller runs in place: one that did not get
a usable _?= copies itself to %TEMP% and returns at once, while the copy keeps
deleting - which is how an uninstall can finish after the install it was
supposed to precede. Log the exit code and wait for the old cockatrice.exe to
disappear before restoring user data or writing anything, and abort with a
message instead of installing into a directory that is still being emptied.

Cancelling the "must be uninstalled" prompt now quits the installer; it used
to skip the page and install over the old version anyway.

* [Installer] Do not show windows the application has hidden

The guard restored every hidden window an instance owned before asking it to
close, so that a confirmation hidden together with its window would come back.
Windows does as it is told and shows them; the application does not, because Qt
still believes they are hidden and never paints them. What the user gets is a
blank white rectangle, on screen and on the taskbar, that answers nothing - and
the question it was meant to bring back is not answerable in it either.

An instance with no window a user could answer is named and left to the user
instead, and the prompt says what to do about it: open it from the notification
area. Ending it stays the last resort, offered once a close has been asked for
twice.

The abort after a failed uninstall now says what it saw - the old version is
still there after its uninstaller ran - instead of promising a removal that is
not happening, which is the state a cancelled old uninstaller leaves behind.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-27 11:19:49 +01:00

172 lines
7.4 KiB
C++

#include "close_holders.h"
#include "holder_line.h"
#include "restart_manager_holders.h"
#include <algorithm>
#include <string>
#include <vector>
#include <windows.h>
namespace
{
/// The installer sets this, the same variable the running-instance guard uses, instead
/// of passing the directory on the command line: a path with spaces, quotes or a
/// trailing backslash then never has to survive NSIS quoting or a command line.
constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR";
/// How long the close mode waits for the instance it asked to exit before it reports the
/// process as still running. The installer measures the purge right after this call, so
/// the wait belongs here: it is the only place that holds a handle on the process whose
/// exit it is waiting for.
constexpr unsigned long CLOSE_WAIT_MILLISECONDS = 30000;
/// How long the end mode waits for what it terminated to be gone. TerminateProcess is
/// asynchronous, and the installer measures the purge right after this returns, so a process
/// that is still tearing down would otherwise be reported as a holder for one more round.
constexpr unsigned long END_WAIT_MILLISECONDS = 5000;
/// Exit codes the installer reads. "Something was found" is deliberately not 0, so an
/// answer of "nothing holds the files" stays distinguishable from a probe that failed.
/// In the close mode 1 means a close was actually sent and 0 that every holder was left
/// to the user, which is what the report line next to the code spells out. 3 is only for
/// a lookup that failed and named nobody: one that failed but still named a process
/// running from the directory is answered with that process.
enum ExitCode
{
EXIT_NO_HOLDERS = 0,
EXIT_HOLDERS_FOUND = 1,
EXIT_NOT_CONFIGURED = 2,
EXIT_PROBE_FAILED = 3,
};
/// The probe has three modes, selected by the first argument:
///
/// (none) name what holds the payload, one line, for the guard prompt and the
/// abort dialog/log
/// --close-holders bring the running Cockatrice forward, ask it to close, and wait for
/// it to exit, for the offer to close after a failed purge
/// --end-holders end the running Cockatrice and wait for it to be gone, for the last
/// resort the guard prompt offers after a close was asked for twice
///
/// All three answer from two lookups that cover each other's blind spot: the Restart Manager
/// names whoever has a payload file open, and every process running from the directory is
/// named as well, because a running instance holds its own image and the modules it
/// loaded as mappings rather than open files and the Restart Manager does not always
/// report those. Neither lookup alone is the guard's authority: an instance the file
/// handles do not name is exactly the case that left a half-replaced install behind.
enum class Mode
{
Name, ///< name the holders
Close, ///< ask the holders to close
End, ///< end the holders
};
Mode requestedMode(int argumentCount, char **arguments)
{
if (argumentCount > 1) {
const std::string mode(arguments[1]);
if (mode == "--close-holders") {
return Mode::Close;
}
if (mode == "--end-holders") {
return Mode::End;
}
}
return Mode::Name;
}
/// Writes one line to the pipe nsExec reads. Narrowed to ASCII on purpose: the bytes
/// come back to the installer as an ANSI string, so a non-ASCII process name would be
/// mojibake in the log; a '?' at least stays honest. Control characters are dropped
/// too, so a name can never forge a second log line.
void writeLine(const std::wstring &text)
{
std::string narrow;
narrow.reserve(text.size() + 2);
for (const wchar_t character : text) {
narrow.push_back(character >= 0x20 && character < 0x7F ? static_cast<char>(character) : '?');
}
narrow += "\r\n";
DWORD written = 0;
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast<DWORD>(narrow.size()), &written, nullptr);
}
std::wstring installDirFromEnvironment()
{
for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) {
std::vector<wchar_t> buffer(size);
const DWORD length = GetEnvironmentVariableW(INSTALL_DIR_VARIABLE, buffer.data(), size);
if (length == 0) {
return {};
}
if (length < size) {
return std::wstring(buffer.data(), length);
}
}
return {};
}
} // namespace
int main(int argumentCount, char **arguments)
{
const std::wstring installDir = installDirFromEnvironment();
if (installDir.empty()) {
writeLine(L"the probe was not given the install directory");
return EXIT_NOT_CONFIGURED;
}
// Processes running from the directory first: they are the instance the user is asked
// to close, and the Restart Manager may not name any of them (see
// findProcessesRunningFrom). The file holders follow, deduplicated by process, so an
// instance that both runs from the directory and holds a payload file is named once.
// Neither lookup may name the probe or the process that started it, whichever one found
// it: an installer or uninstaller running from the directory it is replacing would
// otherwise be named as a holder, and closing it is a request nobody can answer.
std::vector<Holder> holders = findProcessesRunningFrom(installDir);
const std::vector<unsigned long> asking = askingProcessIds();
std::vector<Holder> fileHolders;
const unsigned long error = findProcessesUsingFiles(collectPayloadFiles(installDir), fileHolders);
for (const Holder &holder : fileHolders) {
if (std::find(asking.begin(), asking.end(), holder.processId) != asking.end()) {
continue;
}
const bool alreadyNamed = std::any_of(holders.begin(), holders.end(), [&holder](const Holder &named) {
return named.processId == holder.processId;
});
if (!alreadyNamed) {
holders.push_back(holder);
}
}
// A failed lookup only makes the probe unable to answer when it also named nobody: with
// a process running from the directory the installer still knows what to close, and
// that is worth more to the guard than the code of the query that missed it.
if (error != ERROR_SUCCESS && holders.empty()) {
writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error));
return EXIT_PROBE_FAILED;
}
const Mode mode = requestedMode(argumentCount, arguments);
if (holders.empty()) {
// No output in the naming mode: the installer words the "nothing is holding them"
// case, and it owns the message either way. The acting modes say it themselves,
// because their line is logged verbatim next to what they were asked to do.
if (mode != Mode::Name) {
writeLine(L"nothing is holding the files");
}
return EXIT_NO_HOLDERS;
}
if (mode == Mode::Name) {
writeLine(formatHolders(holders));
return EXIT_HOLDERS_FOUND;
}
if (mode == Mode::End) {
const EndHoldersResult result = endCockatriceHolders(holders, END_WAIT_MILLISECONDS);
writeLine(result.report);
return result.ended > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS;
}
const CloseHoldersResult result = closeCockatriceHolders(holders, CLOSE_WAIT_MILLISECONDS);
writeLine(result.report);
return result.askedToClose > 0 ? EXIT_HOLDERS_FOUND : EXIT_NO_HOLDERS;
}