[CrashReporting] Name the files and processes behind a failed installer purge (#7590)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / Ubuntu 26.04 cover (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Desktop / Prune caches (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled

* [CrashReporting] Log which files and processes blocked the installer purge

A purge that fails aborts the upgrade with a message that can only guess
at the cause, and the log only listed which DLLs survived. Record what
Windows reports for each survivor and which processes still have the
install directory mapped, then repeat the holder list in the abort
message.

The diagnostics are additive and run only on the abort path: they open
each survivor for DELETE access and close it again without deleting, so a
failed upgrade can never end up half-removed. The purge itself is
unchanged.

* [CrashReporting] Name the holders of a failed purge with Restart Manager

The process-and-module scan could only name a holder whose image lives in the
install directory or that loaded one of its DLLs, so the case this diagnostic
exists for - an antivirus scanner holding a plain handle - was reported as "no
holder named, only a code". Reaching the Restart Manager from the NSIS script
would have meant marshalling RM_PROCESS_INFO through System::Call as a table of
hand-computed offsets, which cannot be checked without a Windows toolchain, and
that is why it was dropped when this diagnostics was written.

Move the call into tools/installer-holder-probe: a small executable the same
Windows build compiles, linking nothing from this project so it still starts
when the installed runtime is what the purge could not replace. It rides inside
the installer and is extracted to $PLUGINSDIR only when the purge has already
failed, so the successful path is unchanged and nothing new is installed.

.ci/compile.sh runs it against a file held with FileShare.None on the Windows
runners, so the lookup is exercised on real Windows instead of only compiled.

* [CrashReporting] Fix the holder probe check and cap the holder line

The probe block ran after the package steps, where the working directory is
already the build dir, so its $BUILD_DIR-prefixed paths named build/build and
the PowerShell file could not be written. Move it ahead of install/package,
where the build dir is still the cwd, and name both files relative to it.
Guard it on MAKE_NO_CLIENT, since the probe is only built with the client.

Cap the rendered holder line at HOLDER_LINE_CHAR_MAX: the count limit alone
does not keep long service names inside the NSIS string that also carries the
abort text and the log path. Whole entries are kept and the rest counted, with
the first holder always shown.

Also run cmake-format on the probe's CMakeLists so the lint job accepts it.

* [CrashReporting] Report a read-only purge survivor and bound the probe check

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL
2026-10-24 00:46:54 +02:00
committed by GitHub
parent ad4320cd7b
commit bbc6003f0a
12 changed files with 793 additions and 1 deletions

View File

@@ -459,6 +459,75 @@ if [[ $MAKE_COVERAGE ]]; then
echo "::endgroup::"
fi
if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then
echo "::group::Probe the installer's holder lookup"
# The working directory is still the build dir here: the package steps below are what
# leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name
# build/build. Both paths are relative to the dir the probe was built into.
probe="./installer-holder-probe.exe"
if [[ ! -f $probe ]]; then
echo "::error file=$0::installer-holder-probe.exe was not built"
exit 1
fi
# The probe exists to name a holder that a process-and-module scan cannot see: one
# that has a file open without having loaded it. Holding a temporary DLL with
# FileShare.None is exactly that case, so this asserts the reason the probe was
# added instead of only that the Restart Manager answers at all. It needs no NSIS
# and no packaging, so it runs on every Windows build that gets this far.
probe_check="./holder-probe-check.ps1"
cat >"$probe_check" <<'POWERSHELL'
$ErrorActionPreference = "Stop"
$dir = Join-Path ([IO.Path]::GetTempPath()) "cockatrice-holder-probe-$PID"
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force $dir | Out-Null
$probe = (Resolve-Path -LiteralPath $env:PROBE_EXE).Path
function Invoke-Probe([string] $guardDir) {
$start = New-Object System.Diagnostics.ProcessStartInfo
$start.FileName = $probe
$start.UseShellExecute = $false
$start.RedirectStandardOutput = $true
$start.EnvironmentVariables["COCKATRICE_GUARD_DIR"] = $guardDir
$process = [System.Diagnostics.Process]::Start($start)
$read = $process.StandardOutput.ReadToEndAsync()
if (-not $process.WaitForExit(20000)) {
try { $process.Kill() } catch { }
throw "the holder probe did not finish within 20 s for $guardDir"
}
$output = $read.Result
$code = $process.ExitCode
$process.Dispose()
return @{ Code = $code; Output = $output }
}
try {
$file = Join-Path $dir "locked.dll"
[IO.File]::WriteAllText($file, "x")
$handle = [IO.File]::Open($file, "Open", "ReadWrite", "None")
try {
$locked = Invoke-Probe $dir
} finally {
$handle.Dispose()
}
if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" }
if ($locked.Output -notmatch "pid\s+$PID\b") { throw "the lookup did not name this process: $($locked.Output)" }
$empty = Join-Path $dir "empty"
New-Item -ItemType Directory -Force $empty | Out-Null
$nothing = Invoke-Probe $empty
if ($nothing.Code -ne 0) { throw "expected exit 0 for a directory with nothing open, got $($nothing.Code) : $($nothing.Output)" }
Write-Host "holder lookup named pid $PID for a locked file and named nothing for an empty directory"
} finally {
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
}
POWERSHELL
if ! PROBE_EXE="$probe" powershell -NoProfile -File "$probe_check"; then
rm -f "$probe_check"
exit 1
fi
rm -f "$probe_check"
echo "::endgroup::"
fi
if [[ $MAKE_INSTALL ]]; then
echo "::group::Install"
cmake --build . --target install --config "$BUILDTYPE"

View File

@@ -431,6 +431,13 @@ if(WITH_CLIENT)
set(CPACK_INSTALL_CMAKE_PROJECTS "Cockatrice;Cockatrice;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})
endif()
if(WIN32 AND WITH_CLIENT)
# Runs from the installer's $PLUGINSDIR when a purge fails, and is never installed
# (see LogProcessesUsingInstallDir in cmake/NSIS.template.in), so it has no install
# rules and is not part of the client's staged tree.
add_subdirectory(${CMAKE_SOURCE_DIR}/tools/installer-holder-probe ${CMAKE_BINARY_DIR}/tools/installer-holder-probe)
endif()
if(WITH_ORACLE)
add_subdirectory(oracle)
set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS})

View File

@@ -28,6 +28,11 @@ Var DataBackupFindHandle
Var DataBackupFindName
Var IsUpgrade
Var AbortReason
; Diagnostics for a failed purge: the holder probe in LogProcessesUsingInstallDir writes
; $HolderOutput (one log line) and sets $HolderFound when it named anything.
Var HolderExit
Var HolderOutput
Var HolderFound
Var WaitPid
Var LauncherExited
Var InstallLogPath
@@ -1171,6 +1176,154 @@ Function CheckStaleRuntimeMask
${EndIf}
FunctionEnd
; Turns a Win32 error code into the wording that goes into the log.
; usage: set $0 to the code, result in $R0.
;
; These are Windows' own names for the codes and deliberately nothing more. A
; code on its own cannot say whether an upgrade was blocked by a running instance
; or by a permission: deleting a file whose image is mapped by another process
; surfaces as ACCESS_DENIED, exactly like a file whose ACL denies delete does.
; LogProcessesUsingInstallDir is what separates the two, which is why it runs
; right after this and why the two are read together.
Function GetWin32ErrorText
${If} $0 = 2
StrCpy $R0 "already gone"
${ElseIf} $0 = 3
StrCpy $R0 "path not found"
${ElseIf} $0 = 5
StrCpy $R0 "access denied"
${ElseIf} $0 = 19
StrCpy $R0 "write-protected media"
${ElseIf} $0 = 32
StrCpy $R0 "sharing violation"
${ElseIf} $0 = 33
StrCpy $R0 "lock violation"
${ElseIf} $0 = 1224
StrCpy $R0 "file is mapped in memory"
${Else}
StrCpy $R0 "windows error"
${EndIf}
FunctionEnd
; Records which runtime files the purge left behind, what Windows said about each
; one, and which processes are using the install directory.
;
; Diagnostic only, and only on the abort path: it deletes nothing, so it can never
; turn a failed upgrade into a half-removed old install, and it decides nothing -
; the abort that called it is already decided. It exists because NSIS Delete/RMDir
; only set the error flag: without this, a report of "files of a previous version
; survived the purge" said which files were present but never which one was held,
; and never whether the cause was a lock or a permission.
;
; The probe opens each survivor for DELETE access and then closes it again
; without deleting anything. The code is read by the plugin right after that call
; (?e), so a failure reports what Windows refused; clearing LastError first is
; what makes a zero code mean "this open worked", i.e. the purge-time failure was
; transient (an antivirus scan that has finished, an instance still shutting
; down) rather than a standing condition. The plugin parses the call's arguments
; between the clear and the call, so a non-zero code is reported as what Windows
; said rather than as proof of a cause - the wording below never claims one.
Function LogStaleRuntimeDiagnostics
FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\*.dll"
${If} $StaleFindHandle != ""
stale_diag_loop:
; ClearErrors tells a plugin failure apart from an API failure: the System
; plugin sets the error flag only when it could not make the call at all,
; in which case it pushes its own message instead of the call's value and
; the pop below must not run.
ClearErrors
System::Call 'kernel32::SetLastError(i 0)'
System::Call 'kernel32::CreateFileW(w "$INSTDIR\$StaleFindName", i 0x00010000, i 0, p 0, i 3, i 0, p 0) p.R1 ?e'
${If} ${Errors}
StrCpy $R0 "the delete probe could not be run"
${Else}
Pop $R2
${If} $R2 = 0
; A valid handle: close it. The open proves the file is not held
; open, but not that Delete would succeed: a read-only file opens
; for DELETE and still fails DeleteFile until the attribute is
; cleared, which the purge does not do. Report that standing
; cause instead of calling the failure transient.
System::Call 'kernel32::CloseHandle(p R1)'
System::Call 'kernel32::GetFileAttributesW(w "$INSTDIR\$StaleFindName") i.R3'
${If} $R3 = -1
StrCpy $R0 "a delete-access open now succeeds, but its attributes could not be read"
${Else}
IntOp $R3 $R3 & 1
${If} $R3 = 1
StrCpy $R0 "a delete-access open now succeeds, but it is still read-only, which Delete does not clear"
${Else}
StrCpy $R0 "a delete-access open now succeeds"
${EndIf}
${EndIf}
${Else}
StrCpy $0 $R2
Call GetWin32ErrorText
StrCpy $R0 "win32 error $R2: $R0"
${EndIf}
${EndIf}
!insertmacro CockLog "stale-runtime: '$StaleFindName' is still present: $R0"
ClearErrors
FindNext $StaleFindHandle $StaleFindName
IfErrors 0 stale_diag_loop
FindClose $StaleFindHandle
${EndIf}
Call LogProcessesUsingInstallDir
FunctionEnd
; Names the applications and services that still have the install directory's files
; open or mapped. Writes one line to the log and sets $HolderFound (1 when anything was
; named), so the abort message can repeat that line to the user.
;
; The lookup is a small executable built from tools/installer-holder-probe, extracted to
; $PLUGINSDIR only when the purge has already failed: the successful path pays nothing
; for it, and $PLUGINSDIR takes it away again when the installer exits. It is compiled by
; the same Windows build that compiles this installer, which is what lets it call the
; Restart Manager API directly. The same call made from this script would have to
; marshal RM_PROCESS_INFO through System::Call as a table of hand-computed offsets, and
; that table is precisely what cannot be checked without a Windows toolchain; in
; compiled code the layout belongs to the Windows SDK header.
;
; Restart Manager is what makes the answer complete. Walking processes can only name one
; whose image lives here or that loaded one of these libraries, while Restart Manager
; names a process that holds a plain handle on a file - an antivirus scanner - and names
; services as well as user programs. Its blind spot is a handle taken in kernel mode,
; where there is no process to name; then the codes logged above are the whole story, and
; the probe says so by naming nothing rather than by failing.
;
; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the
; running-instance guard uses, so a path with spaces or apostrophes never has to be
; embedded in a command line.
Function LogProcessesUsingInstallDir
StrCpy $HolderExit 0
StrCpy $HolderOutput ""
StrCpy $HolderFound 0
InitPluginsDir
; Embedded by the same relative path that resolves NSIS.definitions.nsh above (both
; are built into the build root, which is three levels up from project.nsi).
File "/oname=$PLUGINSDIR\installer-holder-probe.exe" "..\..\..\installer-holder-probe.exe"
System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$INSTDIR")'
; /TIMEOUT because this runs on the abort path, ahead of the message box: one call
; into the Restart Manager cannot take the minute a walk over every process can, but
; if that service stalls it must not leave the installer hanging with nothing on
; screen. On a timeout nsExec reports it through the exit code like any other failure.
nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe"'
Pop $HolderExit
Pop $HolderOutput
; The exit code, not the text, decides what happened: it cannot be mangled by what
; nsExec leaves on the end of the captured output. 1 = the line below names what is
; holding the files, 0 = nothing holds them any more, anything else = the probe could
; not answer, and says why in its own output.
${If} $HolderExit == "1"
StrCpy $HolderFound 1
${ElseIf} $HolderExit == "0"
StrCpy $HolderOutput "nothing is holding them any more"
${Else}
StrCpy $HolderOutput "the holder probe could not run: $HolderOutput"
${EndIf}
!insertmacro CockLog "holders: $HolderOutput"
FunctionEnd
; Uninstaller copies of the same routines (the uninstaller gets its own
; function set compiled in, it cannot call the installer functions).
Function un.IsAppRunning
@@ -1512,6 +1665,26 @@ ${EndIf}
; Remove any runtime DLLs left over from older versions, so a mismatched
; Qt/OpenSSL/protobuf set cannot survive next to the new executables.
;
; A purge that fails is reported with evidence, not only as a survivor list: the
; abort below logs which files survived, what Windows said about each of them and
; which processes still have the install directory mapped (see
; LogStaleRuntimeDiagnostics).
;
; Re-running the running-instance check here was considered and dropped: it would only
; narrow the window the uninstaller ExecWait and the backup/restore copy open between
; .onInit and this point, and on /R it would now abort after the old version has already
; been removed - leaving no install at all. The surviving files are the authority on
; whether anything still holds the directory, and they are measured anyway.
;
; The holder list comes from Restart Manager (RmRegisterResources/RmGetList), the API
; that names every process and service with one of these files open or mapped. It is
; called from tools/installer-holder-probe rather than from this script, because the
; RM_PROCESS_INFO array is a table of hand-computed offsets when it is marshalled
; through the System plugin, and that table is the one thing here that cannot be
; checked without a Windows toolchain. Compiled by the same build that compiles this
; installer, the layout belongs to the Windows SDK, and .ci/compile.sh runs the probe
; on the Windows runners instead of trusting that it compiles.
Call IsExistingCockatriceInstall
!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0"
; Remembered because the stale-runtime check below must only ever fire for a
@@ -1525,6 +1698,11 @@ ${AndIf} $R0 = 1
!insertmacro CockLog "section: non-portable upgrade purge"
!insertmacro CockLogListing "before-purge" "*.dll"
RMDir /r "$INSTDIR\Plugins"
; RMDir /r reports a failure only through the error flag, and only as a
; boolean; the surviving file names come from the listing below.
${If} ${FileExists} "$INSTDIR\Plugins\*.*"
!insertmacro CockLog "purge: '$INSTDIR\Plugins' could not be removed completely"
${EndIf}
Delete "$INSTDIR\*.dll"
!insertmacro CockLogListing "after-purge" "*.dll"
!insertmacro CockLogListing "after-plugins" "Plugins\*.*"
@@ -1549,6 +1727,10 @@ ${EndIf}
${If} $R0 = 1
!insertmacro CockLog "section: aborting, files of a previous version survived the purge"
DetailPrint "Files of a previous version could not be removed; aborting the installation."
; Who is holding the payload is recorded before the abort, so the log that
; reaches us answers the question the message can only guess at, and so the
; message can name the processes to close whenever any were found.
Call LogStaleRuntimeDiagnostics
; The in-app updater runs this installer silent and detached and never looks
; at its exit code, so the user has to be told what happened. A /S install
; without /R is unattended (a package manager, for instance) and a message
@@ -1556,6 +1738,9 @@ ${If} $R0 = 1
; and the exit code. AbortInstall owns that decision.
StrCpy $AbortReason \
"The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again."
${If} $HolderFound = 1
StrCpy $AbortReason "$AbortReason$\r$\n$\r$\nStill using the install directory:$\r$\n$HolderOutput"
${EndIf}
Call AbortInstall
${EndIf}

View File

@@ -19,7 +19,8 @@ libcockatrice_* \
"oracle/src" \
"servatrice/src" \
"cmake/pch" \
"tests")
"tests" \
"tools/installer-holder-probe")
exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \
"libcockatrice_utility/libcockatrice/utility/peglib.h" \
"oracle/src/lzma/" \

View File

@@ -178,6 +178,17 @@ cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp
cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility)
cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility)
# The installer's holder probe is Windows-only, but the wording it produces is plain C++
# and is what a stuck user has to act on, so it is tested everywhere.
cockatrice_add_test(
NAME
holder_line_test
SOURCES
${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/holder_line.cpp
holder_line_test.cpp
INCLUDE_DIRS
${CMAKE_SOURCE_DIR}/tools/installer-holder-probe
)
cockatrice_add_test(
NAME
playmat_resolver_test

102
tests/holder_line_test.cpp Normal file
View File

@@ -0,0 +1,102 @@
#include "holder_line.h"
#include <gtest/gtest.h>
#include <string>
namespace
{
Holder runningProcess(const std::wstring &imageName, unsigned long processId)
{
Holder holder;
holder.imageName = imageName;
holder.processId = processId;
return holder;
}
Holder service(const std::wstring &appName, const std::wstring &shortName, unsigned long processId)
{
Holder holder;
holder.appName = appName;
holder.serviceShortName = shortName;
holder.processId = processId;
holder.isService = true;
return holder;
}
} // namespace
TEST(HolderLine, NamesARunningProcessTheWayTaskManagerDoes)
{
EXPECT_EQ(formatHolder(runningProcess(L"cockatrice.exe", 24340)),
std::wstring(L"cockatrice.exe (pid 24340, running)"));
}
TEST(HolderLine, NamesAServiceByTheNameTheUserCanSearchFor)
{
// The image of a service is often a shared host process, so the image name would
// point at the wrong thing; both of the service's own names are kept.
Holder holder = service(L"Sophos Anti-Virus", L"SAVService", 4412);
holder.imageName = L"svchost.exe";
EXPECT_EQ(formatHolder(holder), std::wstring(L"Sophos Anti-Virus (pid 4412, service SAVService)"));
}
TEST(HolderLine, FallsBackFromTheImageToTheApplicationName)
{
// Restart Manager can name a process whose image this one is not allowed to open.
Holder holder;
holder.appName = L"Cockatrice";
holder.processId = 7;
EXPECT_EQ(formatHolder(holder), std::wstring(L"Cockatrice (pid 7, running)"));
}
TEST(HolderLine, NamesAnEmptyHolderRatherThanPrintingAnEmptyParenthesis)
{
Holder holder;
holder.processId = 9;
EXPECT_EQ(formatHolder(holder), std::wstring(L"an unnamed process (pid 9, running)"));
}
TEST(HolderLine, JoinsHoldersIntoOneLine)
{
const std::vector<Holder> holders = {
runningProcess(L"cockatrice.exe", 1),
runningProcess(L"oracle.exe", 2),
};
EXPECT_EQ(formatHolders(holders), std::wstring(L"cockatrice.exe (pid 1, running); oracle.exe (pid 2, running)"));
}
TEST(HolderLine, CountsTheHoldersItLeavesOut)
{
std::vector<Holder> holders;
for (unsigned long processId = 1; processId <= HOLDER_LINE_MAX + 2; ++processId) {
holders.push_back(runningProcess(L"cockatrice.exe", processId));
}
const std::wstring line = formatHolders(holders);
// The dialog has to stay readable, but a list that silently stopped at five would
// hide that there is more to close.
EXPECT_NE(line.find(L"pid 5, running"), std::wstring::npos);
EXPECT_EQ(line.find(L"pid 6, running"), std::wstring::npos);
EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos);
}
TEST(HolderLine, StopsNamingHoldersBeforeTheLineOutgrowsTheDialog)
{
// A Windows service name can be 255 characters, and the abort dialog has to fit the
// list next to its own text and the log path in a 1024-character NSIS string.
std::vector<Holder> holders;
for (unsigned long processId = 1; processId <= 3; ++processId) {
holders.push_back(service(std::wstring(255, L'S'), L"S", processId));
}
const std::wstring line = formatHolders(holders);
// The first holder is still named in full; the others are counted rather than written
// out until the line overflows.
EXPECT_EQ(line.compare(0, 255, std::wstring(255, L'S')), 0);
EXPECT_NE(line.find(L"(pid 1, service S)"), std::wstring::npos);
EXPECT_EQ(line.find(L"pid 2"), std::wstring::npos);
EXPECT_NE(line.find(L"and 2 more"), std::wstring::npos);
EXPECT_LE(line.size(), HOLDER_LINE_CHAR_MAX + 20);
}
TEST(HolderLine, FormatsNothingAsAnEmptyLine)
{
EXPECT_EQ(formatHolders({}), std::wstring());
}

View File

@@ -0,0 +1,21 @@
# Windows-only. The installer runs this on the abort path of a failed purge to name the
# applications and services holding the install directory open (Restart Manager); see
# the LogProcessesUsingInstallDir function in cmake/NSIS.template.in. It ships inside
# the installer and is extracted to $PLUGINSDIR there, never installed, so 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 holder_line.cpp main.cpp restart_manager_holders.cpp)
target_link_libraries(installer-holder-probe PRIVATE rstrtmgr)
# 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(
installer-holder-probe
PROPERTIES MSVC_RUNTIME_LIBRARY "MultiThreaded"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}"
RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}"
RUNTIME_OUTPUT_DIRECTORY_MINSIZEREL "${CMAKE_BINARY_DIR}"
RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}"
RUNTIME_OUTPUT_DIRECTORY_RELWITHDEBINFO "${CMAKE_BINARY_DIR}"
)

View File

@@ -0,0 +1,64 @@
#include "holder_line.h"
#include <algorithm>
namespace
{
std::wstring displayNameOf(const Holder &holder)
{
// A service's application name is its long name, which is what the user can
// search for; its image name is usually a shared host process (svchost.exe) and
// would name the wrong thing.
if (holder.isService && !holder.appName.empty()) {
return holder.appName;
}
if (!holder.imageName.empty()) {
return holder.imageName;
}
if (!holder.appName.empty()) {
return holder.appName;
}
return L"an unnamed process";
}
} // namespace
std::wstring formatHolder(const Holder &holder)
{
std::wstring line = displayNameOf(holder);
line += L" (pid ";
line += std::to_wstring(holder.processId);
if (holder.isService) {
line += L", service ";
line += holder.serviceShortName.empty() ? L"without a short name" : holder.serviceShortName;
} else {
line += L", running";
}
line += L")";
return line;
}
std::wstring formatHolders(const std::vector<Holder> &holders)
{
const std::size_t wanted = std::min(holders.size(), HOLDER_LINE_MAX);
std::wstring line;
std::size_t listed = 0;
for (; listed < wanted; ++listed) {
const std::wstring entry = formatHolder(holders[listed]);
const std::size_t separator = line.empty() ? 0 : 2;
// The first holder is always named, however long its name: it is the answer the
// dialog exists to show, and Windows bounds one entry below the whole budget.
if (!line.empty() && line.size() + separator + entry.size() > HOLDER_LINE_CHAR_MAX) {
break;
}
if (!line.empty()) {
line += L"; ";
}
line += entry;
}
if (holders.size() > listed) {
line += L"; and ";
line += std::to_wstring(holders.size() - listed);
line += L" more";
}
return line;
}

View File

@@ -0,0 +1,59 @@
#ifndef HOLDER_LINE_H
#define HOLDER_LINE_H
#include <cstddef>
#include <string>
#include <vector>
/**
* @brief One application or service that has an installer file open or mapped.
*
* Deliberately free of Windows types: the wording is what the abort dialog shows,
* and it is the only part of the probe that can be exercised off Windows.
*/
struct Holder
{
std::wstring imageName; ///< "cockatrice.exe", empty when the image could not be read
std::wstring appName; ///< Restart Manager's name for the application or service
std::wstring serviceShortName; ///< service short name, empty when this is not a service
unsigned long processId = 0;
bool isService = false;
};
/// How many holders the single-line summary names before it says how many were left out.
inline constexpr std::size_t HOLDER_LINE_MAX = 5;
/**
* @brief How many characters the summary may spend on the holders it names.
*
* The line is not the whole string: the abort dialog puts it after a fixed paragraph and
* before the path of the installer log, inside an NSIS string of 1024 characters. Windows
* caps one application or service name well below this budget, so it bounds the list
* without ever cutting a single holder short.
*/
inline constexpr std::size_t HOLDER_LINE_CHAR_MAX = 400;
/**
* @brief Renders one holder the way the user has to recognise it, e.g.
* "cockatrice.exe (pid 24340, running)".
*
* The image name is preferred over the application name because it is what Task
* Manager shows, and the application name is what remains for a process whose
* image cannot be opened (another account, a protected process). A service is
* named by Restart Manager's long name and identified by its short name, because
* both are how the user finds it again in services.msc.
*/
std::wstring formatHolder(const Holder &holder);
/**
* @brief Renders every holder as a single "; "-joined line.
*
* One line because it is written to the installer log as one and repeated in a
* message box, where nsExec hands the text back as one string. Only the first
* HOLDER_LINE_MAX are named, and only while they fit in HOLDER_LINE_CHAR_MAX
* characters; the rest are counted, so a machine with a dozen open handles cannot
* push the real answer off the dialog.
*/
std::wstring formatHolders(const std::vector<Holder> &holders);
#endif

View File

@@ -0,0 +1,78 @@
#include "holder_line.h"
#include "restart_manager_holders.h"
#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";
/// 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.
enum ExitCode
{
EXIT_NO_HOLDERS = 0,
EXIT_HOLDERS_FOUND = 1,
EXIT_NOT_CONFIGURED = 2,
EXIT_PROBE_FAILED = 3,
};
/// 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()
{
const std::wstring installDir = installDirFromEnvironment();
if (installDir.empty()) {
writeLine(L"the probe was not given the install directory");
return EXIT_NOT_CONFIGURED;
}
std::vector<Holder> holders;
const unsigned long error = findProcessesUsingFiles(collectProbeFiles(installDir), holders);
if (error != ERROR_SUCCESS) {
writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error));
return EXIT_PROBE_FAILED;
}
if (holders.empty()) {
// No output: the installer words the "nothing is holding them" case, and it
// owns the message either way.
return EXIT_NO_HOLDERS;
}
writeLine(formatHolders(holders));
return EXIT_HOLDERS_FOUND;
}

View File

@@ -0,0 +1,160 @@
#include "restart_manager_holders.h"
// clang-format off
// restartmanager.h uses the types from windows.h without including it, so the order
// here is a requirement; Regroup would sort them the other way round.
#include <windows.h>
#include <restartmanager.h>
// clang-format on
#include <algorithm>
#include <iterator>
namespace
{
/// Stop registering after this many files. The install directory carries around 150
/// files in practice; the cap only exists so a directory someone has filled with files
/// cannot turn the abort path into a long Restart Manager call.
constexpr std::size_t MAX_PROBED_FILES = 2000;
/// How deep the Plugins walk goes. Its real shape is Plugins\<category>\<plugin>.dll.
constexpr int MAX_PLUGIN_DEPTH = 4;
void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector<std::wstring> &files)
{
if (files.size() >= MAX_PROBED_FILES) {
return;
}
WIN32_FIND_DATAW entry{};
const HANDLE find = FindFirstFileW((directory + L"\\*").c_str(), &entry);
if (find == INVALID_HANDLE_VALUE) {
return;
}
do {
if (files.size() >= MAX_PROBED_FILES) {
break;
}
const std::wstring name = entry.cFileName;
if (name == L"." || name == L".." || (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) {
continue;
}
const std::wstring path = directory + L"\\" + name;
if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) {
if (depth < maxDepth) {
collectFilesOf(path, depth + 1, maxDepth, files);
}
} else {
files.push_back(path);
}
} while (FindNextFileW(find, &entry) != 0);
FindClose(find);
}
std::wstring imagePathOf(unsigned long processId)
{
// PROCESS_QUERY_LIMITED_INFORMATION is the right to ask, not to debug: it reads the
// path of a process this one does not own, which is what keeps a SYSTEM-owned holder
// from turning the whole list into an error. One that refuses even this is still
// named, by whatever Restart Manager called it.
const HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, processId);
if (process == nullptr) {
return {};
}
wchar_t path[MAX_PATH * 2] = {};
DWORD length = static_cast<DWORD>(std::size(path));
std::wstring imagePath;
if (QueryFullProcessImageNameW(process, 0, path, &length) != 0) {
imagePath.assign(path, length);
}
CloseHandle(process);
return imagePath;
}
std::wstring fileNameOf(const std::wstring &path)
{
const std::size_t separator = path.find_last_of(L"\\/");
return separator == std::wstring::npos ? path : path.substr(separator + 1);
}
/// Copies a fixed-size, NUL-terminated WCHAR array the API filled in.
template <std::size_t N> std::wstring fixedString(const wchar_t (&text)[N])
{
return std::wstring(text, std::find(text, text + N, L'\0'));
}
Holder holderOf(const RM_PROCESS_INFO &info)
{
Holder holder;
holder.processId = info.Process.dwProcessId;
holder.isService = info.ApplicationType == RmService;
holder.appName = fixedString(info.strAppName);
if (holder.isService) {
holder.serviceShortName = fixedString(info.strServiceShortName);
}
holder.imageName = fileNameOf(imagePathOf(holder.processId));
return holder;
}
} // namespace
std::vector<std::wstring> collectProbeFiles(const std::wstring &installDir)
{
std::vector<std::wstring> files;
// depth 0, maxDepth 0: the install root's files and nothing below it. Its data
// directory holds downloaded pictures, which the purge does not touch and which
// would turn this into a walk over thousands of files.
collectFilesOf(installDir, 0, 0, files);
if (files.size() < MAX_PROBED_FILES) {
collectFilesOf(installDir + L"\\Plugins", 0, MAX_PLUGIN_DEPTH, files);
}
return files;
}
unsigned long findProcessesUsingFiles(const std::vector<std::wstring> &files, std::vector<Holder> &holders)
{
holders.clear();
if (files.empty()) {
return ERROR_SUCCESS;
}
DWORD session = 0;
wchar_t sessionKey[CCH_RM_SESSION_KEY + 1] = {};
DWORD error = RmStartSession(&session, 0, sessionKey);
if (error != ERROR_SUCCESS) {
return error;
}
// One call for the whole set, as the API asks: RmRegisterResources does registry
// work per file and is documented as too expensive to call once per file.
std::vector<LPCWSTR> names;
names.reserve(files.size());
for (const std::wstring &file : files) {
names.push_back(file.c_str());
}
error = RmRegisterResources(session, static_cast<UINT>(names.size()), names.data(), 0, nullptr, 0, nullptr);
if (error == ERROR_SUCCESS) {
UINT needed = 0;
UINT count = 0;
DWORD rebootReasons = 0;
// The first call asks how large the answer is; Restart Manager reports
// ERROR_MORE_DATA rather than truncating a list that does not fit.
error = RmGetList(session, &needed, &count, nullptr, &rebootReasons);
// Bounded: each retry more than covers the list growing between the two calls,
// and an abort path cannot chase a process that keeps opening files. Giving up
// reports the code, which is a truthful answer instead of a truncated list.
for (int attempt = 0; attempt < 3 && error == ERROR_MORE_DATA; ++attempt) {
count = needed;
std::vector<RM_PROCESS_INFO> infos(count);
error = RmGetList(session, &needed, &count, infos.data(), &rebootReasons);
if (error == ERROR_SUCCESS) {
holders.reserve(count);
for (UINT index = 0; index < count; ++index) {
holders.push_back(holderOf(infos[index]));
}
}
}
}
RmEndSession(session);
return error;
}

View File

@@ -0,0 +1,35 @@
#ifndef RESTART_MANAGER_HOLDERS_H
#define RESTART_MANAGER_HOLDERS_H
#include "holder_line.h"
#include <string>
#include <vector>
/**
* @brief Lists the files a failed purge can be holding up, as full paths.
*
* Everything directly in @p installDir plus everything below its Plugins directory:
* those are the two things the purge removes, so they are the two sets of files a
* holder can be holding. Restart Manager refuses a directory with ERROR_ACCESS_DENIED,
* which is why the probe registers files and this has to name them first.
*
* Missing or unreadable directories are skipped rather than reported, and the list is
* capped: this runs on the abort path and must not walk a tree of downloaded pictures.
*/
std::vector<std::wstring> collectProbeFiles(const std::wstring &installDir);
/**
* @brief Names the applications and services that have one of @p files open or mapped.
*
* Restart Manager reports both a process that loaded one of the files as a module and
* one that merely holds a handle to it, and it reports services, which is the whole
* reason it is used instead of walking every process's module list.
*
* @param holders replaced with one entry per holder, in the order Windows reported them.
* @return 0 when the lookup ran, even if nothing is holding the files; otherwise the
* Win32 code of the Restart Manager call that failed.
*/
unsigned long findProcessesUsingFiles(const std::vector<std::wstring> &files, std::vector<Holder> &holders);
#endif