[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

* [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:
sentry[bot] 2026-10-31 15:32:59 +01:00 • committed by GitHub
parent 720adf5410
commit bbc7e462ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 897 additions and 130 deletions

View file

@ -6,11 +6,23 @@
# it links nothing from this project: no Qt, and a static C runtime because the installed
# runtime is exactly what the purge may have failed to replace.
add_executable(
installer-holder-probe close_holders.cpp holder_line.cpp main.cpp payload_paths.cpp restart_manager_holders.cpp
installer-holder-probe
close_holders.cpp
holder_line.cpp
installer_close_name.cpp
installer_close_request.cpp
main.cpp
payload_locks.cpp
payload_paths.cpp
restart_manager_holders.cpp
)
target_link_libraries(installer-holder-probe PRIVATE rstrtmgr)
# The close command is a header-only constant shared with the client. The probe still links
# nothing from this project: the header is plain C++ and pulls in no library.
target_include_directories(installer-holder-probe PRIVATE ${CMAKE_SOURCE_DIR}/libcockatrice_utility)
# The NSIS template embeds the built file by the same relative path that resolves
# NSIS.definitions.nsh, and that path points at the build root.
set_target_properties(

View file

@ -1,5 +1,6 @@
#include "close_holders.h"
#include "installer_close_request.h"
#include "payload_paths.h"
#include "restart_manager_holders.h"
@ -248,19 +249,21 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
search.processId = holder.processId;
EnumWindows(collectProcessWindow, reinterpret_cast<LPARAM>(&search));
CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows);
if (target.main == nullptr) {
// Nothing of the application's own to post a close to: every window left is one of
// Qt's bookkeeping windows, or the only thing on screen is a question whose owner
// window is gone. Neither is a close the application would answer - a close to the
// notification-area window terminates without the confirmation - so the instance is
// named and the guard's end answer is what can free its files.
if (target.main == nullptr && !askHiddenWindows) {
// A launcher with no window on screen has already answered its own close and is
// stuck in shutdown (see the header): asking it again cannot reach a user who is
// not there, so it is named and the installer's wait ends it.
++result.withoutWindow;
lines.push_back(processLabel(holder) + L" has no window of its own to close");
continue;
}
std::wstring line = processLabel(holder);
if (target.hidden) {
if (target.main == nullptr) {
// No window of the application's own at all: every window left is one of Qt's
// bookkeeping windows, which must never be asked to close. The installer socket
// below is the only thing that can reach it.
} else if (target.hidden) {
line += L" is hidden in the notification area";
} else {
if (target.toRaise == nullptr) {
@ -269,19 +272,40 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward";
}
// Posted to the instance's own window even when a question of its own is up. The close
// is what that window's close button does, and the application is the one that answers
// it: a current client refuses the close while its confirmation is pending, leaving the
// question in front, and only a client from before that guard accepts it and hides the
// window with the question. Withholding the request therefore protected nobody - it
// left the installer asking the user to close a program it had never asked - while the
// guard's end answer is what frees the files when the close cannot.
//
// Kept open across the wait so a pid reused after the process exits cannot be
// mistaken for the instance that was asked to close.
const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId);
const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS;
if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) {
// Ask over the installer socket first. It is the same request as the window's close
// button - the application still answers its own confirmation - but it reaches an
// instance whose window is hidden in the notification area or is sitting on that
// confirmation, the two states a posted close cannot get through. A client from
// before the socket answers with a failed connect, so the posted close below stays
// the fallback and older versions keep behaving exactly as they did.
if (requestInstallerClose(holder.processId)) {
// "and" only when the line already says something about the instance's window.
line += target.main == nullptr ? L" asked to close over its installer socket"
: L" and asked to close over its installer socket";
} else if (target.main == nullptr) {
// Nothing of the application's own to post a close to, and no socket to ask: the
// instance is named and the guard's end answer is what can free its files.
if (process != nullptr) {
CloseHandle(process);
}
++result.withoutWindow;
lines.push_back(line + L" has no window of its own to close");
continue;
} else if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) {
// Posted to the instance's own window even when a question of its own is up. The
// close is what that window's close button does, and the application is the one
// that answers it: a current client refuses the close while its confirmation is
// pending, leaving the question in front, and only a client from before that guard
// accepts it and hides the window with the question. Withholding the request
// therefore protected nobody - it left the installer asking the user to close a
// program it had never asked - while the guard's end answer is what frees the files
// when the close cannot.
//
// Read the error before CloseHandle, which is free to reset it.
const DWORD postError = GetLastError();
if (process != nullptr) {
@ -290,8 +314,9 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) +
L")");
continue;
} else {
line += L" and asked to close";
}
line += L" and asked to close";
++result.askedToClose;
if (process == nullptr) {
line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")";
@ -311,6 +336,10 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0;
if (exited) {
lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms";
} else if (askedWindows[index] == nullptr) {
// Asked over the socket with no window of its own: there is no window state
// to describe, only that it is still there.
lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms";
} else {
lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" +
describeWindowState(askedWindows[index], askedWasHidden[index]);

View file

@ -17,7 +17,7 @@
*/
struct CloseHoldersResult
{
unsigned int askedToClose = 0; ///< holders a WM_CLOSE was posted to
unsigned int askedToClose = 0; ///< holders asked to close, over the socket or with WM_CLOSE
unsigned int withoutWindow = 0; ///< holders with no window of their own to ask
std::wstring report; ///< one line, narrowed to ASCII when written
};
@ -42,16 +42,19 @@ bool isCockatriceImage(const std::wstring &imageName);
* asked to close. Oracle and Servatrice are left to the user - Oracle writes the card
* database in place - and so is every service.
*
* One thing happens per instance: its own window is asked to close with WM_CLOSE, the same
* request as clicking that window's close button - whether it is on screen or only in the
* notification area, since the request does not need the window to be visible and the
* confirmation Cockatrice answers it with is a window Qt shows itself, painted, unlike one
* restored from outside. A window that is on screen is brought forward first, because that
* confirmation would otherwise open behind the installer's prompt; nothing is shown from
* here. Qt's own bookkeeping windows are never the target - posting a close to the
* notification-area one terminates the instance without its confirmation - so a process
* without a window of its own is named and left to the guard, which offers to end it.
* Stopping the process is never attempted here.
* One thing happens per instance: it is asked to close over the local socket it listens on,
* which is the same request as clicking its close button and is the only way to reach one
* whose window is hidden in the notification area or is sitting on its own confirmation. A
* client from before that socket is asked with WM_CLOSE on its own window instead, whether
* the window is on screen or only in the notification area, since the request does not need
* the window to be visible and the confirmation Cockatrice answers it with is a window Qt
* shows itself, painted, unlike one restored from outside. A window that is on screen is
* brought forward first, because that confirmation would otherwise open behind the
* installer's prompt; nothing is shown from here. Qt's own bookkeeping windows are never the
* target - posting a close to the notification-area one terminates the instance without its
* confirmation - so a process without a window of its own that does not answer the socket is
* named and left to the guard, which offers to end it. Stopping the process is never
* attempted here.
*
* Once the requests are out, this waits up to @c waitMilliseconds for the processes to
* exit, so the caller can measure the purge immediately afterwards instead of sleeping

View file

@ -0,0 +1,12 @@
#include "installer_close_name.h"
#include <string>
std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName)
{
std::wstring name = L"CockatriceInstallerClose-";
name += std::to_wstring(processId);
name += L"-";
name += userName;
return name;
}

View file

@ -0,0 +1,24 @@
#ifndef INSTALLER_CLOSE_NAME_H
#define INSTALLER_CLOSE_NAME_H
#include <string>
/**
* @brief The local-socket name a running instance listens on for the installer's close
* request.
*
* The name carries the process id, so every instance of the payload has its own socket - a
* second Cockatrice that was started independently, an Oracle and a Servatrice - and the
* installer can ask each one instead of guessing which is the primary. It also carries the
* account name, so one user's installer cannot reach another user's session.
*
* This is the same string the client builds with scopedLocalServerName() in
* libcockatrice_utility/local_server_name.h: `CockatriceInstallerClose-<pid>-<user>`. It is
* plain C++ so the probe and the client can be tested against each other off Windows.
*
* @param processId the id of the process that listens on the socket
* @param userName the account name scopedLocalServerName() appends
*/
std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName);
#endif // INSTALLER_CLOSE_NAME_H

View file

@ -0,0 +1,96 @@
#include "installer_close_request.h"
#include "installer_close_name.h"
// clang-format off
// windows.h has to come before anything that pulls in a Windows type, as everywhere in
// this tool.
#include <windows.h>
// clang-format on
#include <cstddef>
#include <libcockatrice/utility/installer_close_protocol.h>
#include <string>
#include <vector>
namespace
{
/// How long a busy pipe may take before this gives up. The client answers in milliseconds;
/// the wait only covers a machine under load.
constexpr DWORD PIPE_WAIT_MILLISECONDS = 1000;
std::wstring environmentVariable(const wchar_t *name)
{
for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) {
std::vector<wchar_t> buffer(size);
const DWORD length = GetEnvironmentVariableW(name, buffer.data(), size);
if (length == 0) {
return {};
}
if (length < size) {
return std::wstring(buffer.data(), length);
}
}
return {};
}
/// The account name scopedLocalServerName() appends: USER, then USERNAME, then the last
/// component of the profile path - the same order and the same fallbacks the client uses.
std::wstring currentUserName()
{
for (const wchar_t *name : {L"USER", L"USERNAME"}) {
const std::wstring value = environmentVariable(name);
if (!value.empty()) {
return value;
}
}
const std::wstring profile = environmentVariable(L"USERPROFILE");
const std::size_t separator = profile.find_last_of(L"\\/");
if (separator == std::wstring::npos) {
return profile;
}
return profile.substr(separator + 1);
}
/// The named pipe QLocalServer creates for the client's socket, or empty when the account
/// name could not be read and there is nothing to connect to.
std::wstring pipePath(unsigned long processId)
{
const std::wstring userName = currentUserName();
if (userName.empty()) {
return {};
}
return L"\\\\.\\pipe\\" + installerCloseSocketName(processId, userName);
}
} // namespace
bool requestInstallerClose(unsigned long processId)
{
const std::wstring path = pipePath(processId);
if (path.empty()) {
return false;
}
// A client that is still serving the previous connection answers ERROR_PIPE_BUSY; one
// retry covers that, while a client that is not listening at all (an older version, or
// one that has already exited) is answered with a plain failure and the caller falls
// back to WM_CLOSE.
for (int attempt = 0; attempt < 2; ++attempt) {
const HANDLE pipe = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
if (pipe == INVALID_HANDLE_VALUE) {
if (GetLastError() == ERROR_PIPE_BUSY && WaitNamedPipeW(path.c_str(), PIPE_WAIT_MILLISECONDS) != FALSE) {
continue;
}
return false;
}
DWORD written = 0;
const BOOL ok =
WriteFile(pipe, INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1, &written, nullptr);
CloseHandle(pipe);
// The request is in the pipe buffer, so the client reads it even though this handle
// is closed immediately. No acknowledgment is waited for: the caller only needs to
// know whether there was a listening instance to ask, and a hung client must not
// stall the guard.
return ok != FALSE && written == sizeof(INSTALLER_CLOSE_COMMAND) - 1;
}
return false;
}

View file

@ -0,0 +1,21 @@
#ifndef INSTALLER_CLOSE_REQUEST_H
#define INSTALLER_CLOSE_REQUEST_H
/**
* @brief Asks one running payload instance to close over its installer socket.
*
* A 3.1.3 client (and later) listens on a local socket named by
* installerCloseSocketName(). Connecting to it and writing one line is the same request as
* the window's close button, but it reaches an instance whose window is hidden in the
* notification area or is sitting on its own confirmation - the two states WM_CLOSE cannot
* get through. Older clients have no socket, so the caller still falls back to WM_CLOSE.
*
* Best effort: a failed connect or write is not an error. The caller has the window close
* and the guard's force-close answer left.
*
* @param processId the process to ask, as the Restart Manager named it
* @return true when the request was written to a listening instance
*/
bool requestInstallerClose(unsigned long processId);
#endif // INSTALLER_CLOSE_REQUEST_H

View file

@ -1,5 +1,7 @@
#include "close_holders.h"
#include "holder_line.h"
#include "payload_locks.h"
#include "payload_paths.h"
#include "restart_manager_holders.h"
#include <algorithm>
@ -33,11 +35,12 @@ 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 payload program
/// running from the directory is answered with that process. 4 is only the launcher mode:
/// the process the installer waits on has no window a user could answer, which is the one
/// answer the installer acts on by itself instead of asking the user.
/// to the user, which is what the report line next to the code spells out. 3 is for an
/// answer that could not name what it found: a lookup that failed and named nobody, or the
/// lookups naming nobody while a payload program refuses to be replaced, which is proof one
/// runs from the directory. 4 is only the launcher mode: the process the installer waits on
/// has no window a user could answer, which is the one answer the installer acts on by
/// itself instead of asking the user.
enum ExitCode
{
EXIT_NO_HOLDERS = 0,
@ -60,12 +63,15 @@ enum ExitCode
/// whether there was a window to ask at all, because an instance with
/// no window has answered its own close and is stuck in shutdown
///
/// The guard's three modes answer from two lookups that cover each other's blind spot: the
/// Restart Manager names whoever has a payload file open, and every payload program running
/// The guard's three modes answer from three questions that cover each other's blind spots:
/// the Restart Manager names whoever has a payload file open, every payload program running
/// from the directory itself 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.
/// not always report those, and a payload executable that refuses to be written is running
/// even when neither lookup could name it (see payloadExecutablesInUse). No one of them is
/// the guard's authority: an instance the process lookups do not name, while its executable
/// is locked, is exactly the case that left a half-replaced install behind, so it is reported
/// as a check that could not name what it found instead of as a free payload.
/// A program in a folder below the directory is not named by the second lookup: it is not an
/// instance of this payload, and the guard could neither close nor end it.
enum class Mode
@ -109,6 +115,24 @@ void writeLine(const std::wstring &text)
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast<DWORD>(narrow.size()), &written, nullptr);
}
/// The line for a payload executable that is in use while no lookup could name the program
/// holding it: the answer the guard shows, so it names the file the user has to free and
/// says that the program doing it is still to be closed by hand.
std::wstring inUseLine(const std::vector<std::wstring> &files)
{
std::wstring names;
for (const std::wstring &file : files) {
if (!names.empty()) {
names += L", ";
}
names += fileNameOf(file);
}
if (files.size() == 1) {
return names + L" is in use and the program holding it could not be identified";
}
return names + L" are in use and the programs holding them could not be identified";
}
std::wstring environmentVariable(const wchar_t *name)
{
for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) {
@ -226,15 +250,30 @@ int main(int argumentCount, char **arguments)
}
}
// A failed lookup only makes the probe unable to answer when it also named nobody: with
// a payload program 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;
}
// Nobody was named. That is not yet the answer that nothing holds the payload: a lookup
// that failed only makes the probe unable to answer when it also named nobody, and a
// payload executable that refuses to be replaced is running even when neither lookup
// could name it.
if (holders.empty()) {
// Neither process lookup is proof that the payload is free: the Restart Manager does
// not report every mapping, and a program running under another account cannot be
// read from here, so "nobody was named" is not the answer "nothing is running". The
// file cannot be missed - a running image refuses the write the replacement needs -
// and an answer of "nothing is holding them" over that locked file is what sent
// updates into an uninstall that could only fail halfway. Reported as a check that
// could not name what it found, so the guard asks the user to close it and retry
// instead of replacing a payload that is still running.
const std::vector<std::wstring> inUse = payloadExecutablesInUse(installDir);
if (!inUse.empty()) {
writeLine(inUseLine(inUse));
return EXIT_PROBE_FAILED;
}
if (error != ERROR_SUCCESS) {
writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error));
return EXIT_PROBE_FAILED;
}
// 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.

View file

@ -0,0 +1,67 @@
#include "payload_locks.h"
#include "payload_paths.h"
#include <windows.h>
namespace
{
/// How long the second look waits before it re-tests the files that refused the write open.
/// Only those are re-tested, so the one answer costs this at most once: a scanner, or a
/// program that is only starting, holds a file without write sharing for a moment, and that
/// must not turn a free payload into a run the user has to answer.
constexpr DWORD RECHECK_DELAY_MILLISECONDS = 300;
/// Whether @p path refuses an open that asks to write it.
///
/// Windows opens a program's image, and every module loaded into it, with read and delete
/// sharing but not write sharing, so this is refused for as long as the program runs and by
/// nothing else that merely reads the file. A read-only file is skipped: it refuses a write
/// open for its own reason and would answer yes for a payload nobody is running, and the
/// purge clears that attribute before it deletes anything. Any other error is no answer at
/// all - access this probe does not have says nothing about whether the file is in use, and
/// still answering "in use" would stop installs that could have finished.
bool refusesWrite(const std::wstring &path)
{
const DWORD attributes = GetFileAttributesW(path.c_str());
if (attributes == INVALID_FILE_ATTRIBUTES ||
(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)) != 0) {
return false;
}
const HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE) {
return GetLastError() == ERROR_SHARING_VIOLATION;
}
// Nothing was written: the open is the whole test, and closing the handle leaves the
// file exactly as it was.
CloseHandle(file);
return false;
}
} // namespace
std::vector<std::wstring> payloadExecutablesInUse(const std::wstring &installDir)
{
std::vector<std::wstring> refused;
for (const wchar_t *name : PAYLOAD_PROGRAMS) {
const std::wstring path = installDir + L"\\" + name;
if (refusesWrite(path)) {
refused.push_back(path);
}
}
if (refused.empty()) {
return refused;
}
// One re-test of the files that refused, and only those: the answer is shown to a user
// who then has to close a program and run the installer again, so it has to be the
// settled state rather than a moment in which something happened to hold the file.
Sleep(RECHECK_DELAY_MILLISECONDS);
std::vector<std::wstring> settled;
for (const std::wstring &path : refused) {
if (refusesWrite(path)) {
settled.push_back(path);
}
}
return settled;
}

View file

@ -0,0 +1,36 @@
#ifndef PAYLOAD_LOCKS_H
#define PAYLOAD_LOCKS_H
#include <string>
#include <vector>
/**
* @brief The payload executables in @p installDir that refuse to be written, as full paths:
* the programs that are running from it right now.
*
* The guard asks two questions and both can come back empty for a running instance: the
* 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 here,
* so the process is not named either. The file itself is the evidence neither question
* depends on: a program's image is opened without write sharing for as long as it runs, so
* the write access replacing it needs is refused for exactly that long, while a file that is
* merely being read still accepts it.
*
* An installer that answers "nothing is holding the payload" from two lookups that both
* missed is what sent updates into an uninstall that could only fail halfway, leaving the
* previous version half removed. This answer is what keeps that from being said: a payload
* executable that refuses the write is running, whether or not this process may name it.
*
* Only the programs the payload installs are tested, not every file the purge removes. A
* payload executable refusing the write proves that it is running; a mapped module belongs
* to a running executable, which is tested. A DLL or qt.conf on its own can be held by the
* program that is asking - the uninstaller runs in place from the directory it empties - and
* naming that as a holder is the loop the guard already had.
*
* @return one full path per executable that still refused after the re-test, in the order
* PAYLOAD_PROGRAMS names them; empty when every one of them can be written, or none
* is installed.
*/
std::vector<std::wstring> payloadExecutablesInUse(const std::wstring &installDir);
#endif

View file

@ -6,10 +6,6 @@
namespace
{
/// The payload programs, by image name. A process running one of these is what the guard
/// exists to name, so askingProcessIds() stops its walk up the parent chain at one.
constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"};
/// Compares two file names the way the file system does, without a locale.
///
/// Only the names above are ever compared against, and all of them are ASCII, so folding

View file

@ -3,6 +3,18 @@
#include <string>
/**
* @brief The file names of the programs the payload installs, in the order the guard names
* them.
*
* The single list behind isPayloadProgram(), so the guard acts on exactly the programs it
* names, and so a payload program added later is closed, ended and tested for a lock
* without a second list to update. A payload executable that refuses to be written is
* running, which is what makes this list the evidence the two process lookups cannot
* provide - see payloadExecutablesInUse().
*/
inline constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"};
/**
* @brief The last name in @p path: what follows its last separator, or @p path itself when
* it has none.

View file

@ -27,9 +27,6 @@ constexpr int MAX_PLUGIN_DEPTH = 4;
/// only keeps a snapshot with a cycle in it from walking forever.
constexpr int MAX_ASKER_LEVELS = 8;
/// The payload files the installer replaces by name; the DLLs are matched by mask below.
constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"};
void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector<std::wstring> &files)
{
if (files.size() >= MAX_PROBED_FILES) {
@ -129,7 +126,12 @@ Holder holderOf(const RM_PROCESS_INFO &info)
std::vector<std::wstring> collectPayloadFiles(const std::wstring &installDir)
{
std::vector<std::wstring> files;
for (const wchar_t *name : PAYLOAD_FILES) {
// The programs the payload installs, by name, plus the configuration file next to
// them: what the purge deletes by name, and therefore what an instance can have
// mapped.
std::vector<std::wstring> replaced(std::begin(PAYLOAD_PROGRAMS), std::end(PAYLOAD_PROGRAMS));
replaced.emplace_back(L"qt.conf");
for (const std::wstring &name : replaced) {
const std::wstring path = installDir + L"\\" + name;
const DWORD attributes = GetFileAttributesW(path.c_str());
if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {