diff --git a/.ci/compile.sh b/.ci/compile.sh index e85e2b5ba..1a30a0fd3 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -474,6 +474,10 @@ if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then # 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. + # The holder is a process of its own: the probe never names the process that started it, + # which is what keeps an in-place uninstaller from being named as a holder of the + # directory it is emptying. The same DLL held by the script that starts the probe + # therefore has to come back as nothing, and that is the second case below. probe_check="./holder-probe-check.ps1" cat >"$probe_check" <<'POWERSHELL' $ErrorActionPreference = "Stop" @@ -500,17 +504,61 @@ function Invoke-Probe([string] $guardDir) { return @{ Code = $code; Output = $output } } +function Wait-UntilLocked([string] $path) { + $deadline = (Get-Date).AddSeconds(15) + while ((Get-Date) -lt $deadline) { + try { + $open = [IO.File]::Open($path, "Open", "ReadWrite", "None") + $open.Dispose() + } catch { + return + } + Start-Sleep -Milliseconds 100 + } + throw "the holder never locked $path" +} + try { $file = Join-Path $dir "locked.dll" [IO.File]::WriteAllText($file, "x") + + # The holder is a process of its own, not this script: the probe never names the process + # that started it (the case below asserts that), so a file held here would be left out on + # purpose and the check would pass for the wrong reason. + $holderScript = Join-Path $dir "hold.ps1" + Set-Content -LiteralPath $holderScript -Value @' +param([string] $Path) +$handle = [IO.File]::Open($Path, "Open", "ReadWrite", "None") +Start-Sleep -Seconds 120 +'@ + $powershell = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe" + $holder = Start-Process -FilePath $powershell -PassThru -ArgumentList @( + "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$holderScript`"", "-Path", "`"$file`"") + try { + Wait-UntilLocked $file + $locked = Invoke-Probe $dir + if ($locked.Code -ne 1) { throw "expected exit 1 for a locked file, got $($locked.Code) : $($locked.Output)" } + if ($locked.Output -notmatch "pid\s+$($holder.Id)\b") { + throw "the lookup did not name the process holding the file: $($locked.Output)" + } + } finally { + try { $holder.Kill() } catch { } + try { $holder.WaitForExit(5000) | Out-Null } catch { } + } + + # The probe is started by the installer, or by an uninstaller that runs in place from the + # directory it is emptying, so the asking process holds files there itself. Naming it would + # ask the user to close the program that is asking - a question neither the close nor the + # end offer can answer, so the guard repeated it until the update was cancelled. A holder + # that is only the asking process has to come back as nothing. $handle = [IO.File]::Open($file, "Open", "ReadWrite", "None") try { - $locked = Invoke-Probe $dir + $self = 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)" } + if ($self.Code -ne 0) { throw "expected exit 0 when the asking process is the only holder, got $($self.Code) : $($self.Output)" } + if ($self.Output -match "pid\s+$PID\b") { throw "the lookup named the process that started it: $($self.Output)" } $empty = Join-Path $dir "empty" New-Item -ItemType Directory -Force $empty | Out-Null $nothing = Invoke-Probe $empty @@ -535,7 +583,7 @@ try { if ($byImage.Output -notmatch "fake-cockatrice\.exe \(pid\s+$($running.Id)") { throw "the lookup did not name the process running from the directory: $($byImage.Output)" } - Write-Host "holder lookup named pid $PID for a locked file, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" + Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, named nothing for an empty directory, and named pid $($running.Id) for a process running from the directory" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index d54111a84..72d5287fc 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -42,7 +42,9 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; Whether the guard already asked Cockatrice to close once (see GuardAppsNotRunning) +; How many closes the guard has asked for: 0 before the automatic one, 1 after it, and one +; more for every "Yes" at the prompt. From the second one on, the prompt offers to end the +; instance instead (see GuardAppsNotRunning). Var GuardCloseTried Var WaitPid Var LauncherExited @@ -391,7 +393,9 @@ StrCpy $OldInstallDir $INSTDIR StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." Call BackupUserDataBeforeUninstall -ExecWait '$R0 _?=$OldInstallDir' +ExecWait '$R0 _?=$OldInstallDir' $R5 +!insertmacro CockLog "uninstall (32-bit): exit=$R5" +Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -406,7 +410,9 @@ ${If} ${RunningX64} StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." Call BackupUserDataBeforeUninstall - ExecWait '$R0 _?=$OldInstallDir' + ExecWait '$R0 _?=$OldInstallDir' $R5 + !insertmacro CockLog "uninstall (64-bit): exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: @@ -414,6 +420,44 @@ ${EndIf} FunctionEnd +; Waits for the previous installation's payload to be gone, so the sections below never +; write into a directory an old uninstaller is still emptying. +; +; ExecWait only waits for as long as the uninstaller runs in place: an NSIS uninstaller +; that did not get a usable _?= copies itself to %TEMP%, starts that copy and exits, so +; ExecWait returns at once while the copy keeps deleting - and it finishes after this +; window has closed, removing the version that was just installed. Waiting for +; cockatrice.exe to disappear covers both cases, and stops with a message instead of +; installing over a directory that is still being emptied. +Function WaitForOldPayloadToDisappear +${If} $OldInstallDir == "" + Return +${EndIf} +StrCpy $R2 0 +old_uninstall_wait: +IfFileExists "$OldInstallDir\cockatrice.exe" 0 old_uninstall_gone +IntOp $R2 $R2 + 1 +${If} $R2 > 60 + !insertmacro CockLog "old uninstall: $OldInstallDir still holds cockatrice.exe after 30 s" + StrCpy $AbortReason \ + "The previous version in $OldInstallDir is still there after its uninstaller ran.$\r$\n$\r$\nIf that uninstaller was cancelled or reported an error, close anything still using those files and run this installer again." + Call AbortInstall +${EndIf} +Sleep 500 +Goto old_uninstall_wait +old_uninstall_gone: +${If} $R2 > 0 + ; ExecWait returned while the old payload was still there: that uninstaller + ; copied itself to %TEMP%, and the copy is still working through its file list. + ; cockatrice.exe is gone before the rest of it, so let the copy settle before + ; the sections below start writing files it would otherwise remove. + Sleep 1000 + !insertmacro CockLog "old uninstall: ExecWait returned early, settled 1 s after $R2 * 500 ms" +${Else} + !insertmacro CockLog "old uninstall: $OldInstallDir was already clear when ExecWait returned" +${EndIf} +FunctionEnd + ; BackupUserDataBeforeUninstall / RestoreUserDataAfterUninstall ; ; The uninstallers shipped in 3.1.0/3.1.1 run a blanket "RMDir /r $INSTDIR". @@ -748,13 +792,15 @@ FunctionEnd ; the image lookup matches on the directory a process runs from, so it does not ; widen that set. ; -; Nothing is force-stopped anywhere: Cockatrice may be holding an unsaved deck -; or showing a "really leave this game?" confirmation, Oracle writes the card -; database in place and killing it mid-write would corrupt it, and Servatrice +; Nothing is force-stopped without the user asking for it: Cockatrice may be holding +; an unsaved deck or showing a "really leave this game?" confirmation, Oracle writes +; the card database in place and killing it mid-write would corrupt it, and Servatrice ; has no window to ask. A close is a request (WM_CLOSE), made automatically once ; because that is what the user started the installer to do, and again whenever -; they ask for it from the prompt; when it is not answered the user is told which -; program is in the way instead of having it killed. +; they ask for it from the prompt. When it is not answered the user is told which +; program is in the way, and once a close has been asked for twice without freeing +; the files they are offered to end Cockatrice themselves - the last resort, with +; what it costs spelled out, and never applied to Oracle or Servatrice. ; Emitted once for the installer and once for the uninstaller: the uninstaller ; cannot call installer functions, and its own copy of the probe keeps a running @@ -806,8 +852,9 @@ FunctionEnd ; log as it stands: it names every holder, including the ones left to the user, so a close ; that did nothing can be told apart from one that was never sent. ; -; Never a force-close: the application decides whether to quit, and one that does not answer -; is left to the user instead of being killed. +; Never a force-close here: the application decides whether to quit. Ending it is a separate +; answer the prompt offers once a close has been asked for twice and ignored, spelled out +; with what it costs - see EndHolders - and it is only ever applied to cockatrice.exe. Function ${prefix}CloseHolders Call ${prefix}ExtractHolderProbe System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' @@ -822,6 +869,27 @@ Function ${prefix}CloseHolders Call ${prefix}CockLogWrite DetailPrint "Closing Cockatrice: $R4" FunctionEnd + +; usage: set $GuardDir to the directory being replaced, then call this. Ends the running +; Cockatrice instances the probe names. +; +; This is the only place anything is stopped without being asked to stop, so it is only +; reached after the user picked it from a prompt that says what it costs: a client that is +; still running may have an unsaved deck, a game or a settings write in flight. The probe +; ends cockatrice.exe and nothing else - Oracle writes the card database in place and +; Servatrice is a service, so both stay the user's job, exactly as they do for the close. +Function ${prefix}EndHolders + Call ${prefix}ExtractHolderProbe + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; The probe waits up to 5 s for what it ended to be gone, so the check that follows + ; measures a settled directory instead of racing the processes it just terminated. + nsExec::ExecToStack /TIMEOUT=20000 '"$PLUGINSDIR\installer-holder-probe.exe" --end-holders' + Pop $R3 + Pop $R4 + Push "end-holders: probe exit=$R3 [$R4]" + Call ${prefix}CockLogWrite + DetailPrint "Ending Cockatrice: $R4" +FunctionEnd !macroend !insertmacro CockHolderProbeFunctions "" install @@ -835,7 +903,8 @@ FunctionEnd ; remains when the close cannot reach everything: Oracle writes the card database in ; place, Servatrice has no window, and Cockatrice may still be waiting for an answer in ; its own confirmation. It offers one more close (Yes), a plain re-check for after the -; user closed them by hand (No), and Cancel. +; user closed them by hand (No), and Cancel. Once a close has been asked for twice without +; freeing the files, it offers to end Cockatrice instead - the last resort, see EndHolders. ; ; An error is always shown, in every mode. A /S install without /R has nobody at ; the keyboard, but this installer does not promise unattended runs: when an @@ -867,13 +936,35 @@ Function GuardAppsNotRunning Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" + ; A close that has been asked for twice and changed nothing is where asking again + ; stops being a plan: the instance is either waiting on a question the user has not + ; answered, or stuck where a close cannot reach it. Offer to end it - the only thing + ; left that can free the files - with the cost in the text, because a client that is + ; still running may have an unsaved deck, a game or a settings write in flight. + ${If} $GuardCloseTried >= 2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ + IDYES guard_end IDNO guard_close + !insertmacro CockLog "guard: cancelled by user" + StrCpy $AbortReason \ + "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\nClose them and run the installer again." + Call AbortInstall + ; Only reached through Yes: AbortInstall does not return. + guard_end: + !insertmacro CockLog "guard: ending cockatrice on request, then checking again" + Call EndHolders + ; Back to the ordinary prompt afterwards: Oracle and Servatrice are never ended, + ; so what is left may still have to be closed by hand. + StrCpy $GuardCloseTried 1 + Goto guard_check + ${EndIf} ; Three answers, and the close is one of them: the automatic attempt above ran ; before this prompt and could not have answered Cockatrice's own confirmation, so a ; second close is often exactly what is needed once the user has dealt with that ; window. NSIS does not let a MessageBox relabel its buttons, so the text says what ; Yes, No and Cancel do. MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the installation." \ IDYES guard_close IDNO guard_check !insertmacro CockLog "guard: cancelled by user" StrCpy $AbortReason \ @@ -881,6 +972,7 @@ Function GuardAppsNotRunning Call AbortInstall ; Only reached through Yes: AbortInstall does not return. guard_close: + IntOp $GuardCloseTried $GuardCloseTried + 1 !insertmacro CockLog "guard: closing cockatrice on request, then checking again" Call CloseHolders Goto guard_check @@ -1517,16 +1609,33 @@ Function un.GuardAppsNotRunning Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" + ; Same escalation as in the installer's guard: after a second close that changed + ; nothing, offer to end the instance, with what it costs in the text. + ${If} $GuardCloseTried >= 2 + MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 \ + "Cockatrice is still running after being asked to close twice.$\r$\n$\r$\nChoose Yes to end it now - an unsaved deck, a running game or a setting it has not written yet will be lost - or No to ask it to close again.$\r$\n$\r$\n$GuardOutput" \ + IDYES un_guard_end IDNO un_guard_close + !insertmacro CockLogUn "uninst-guard: cancelled by user" + SetErrorLevel 1 + Abort + ; Only reached through Yes: Abort does not return. + un_guard_end: + !insertmacro CockLogUn "uninst-guard: ending cockatrice on request, then checking again" + Call un.EndHolders + StrCpy $GuardCloseTried 1 + Goto un_guard_check + ${EndIf} ; Same three answers as the installer's guard; the automatic close above already ; ran once before this prompt. MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ + "Cockatrice, Oracle or Servatrice is still using files of the installation:$\r$\n$\r$\n$GuardOutput$\r$\n$\r$\nChoose Yes to close Cockatrice and check again. It may ask you to confirm quitting - it does while a card database update is running, a game is open or a deck is unsaved - so answer that window. If it has no window on screen, open it from the notification area first.$\r$\n$\r$\nClose Oracle, Servatrice and any other program listed above by hand. Choose No to check again after that, or Cancel to cancel the uninstallation." \ IDYES un_guard_close IDNO un_guard_check !insertmacro CockLogUn "uninst-guard: cancelled by user" SetErrorLevel 1 Abort ; Only reached through Yes: Abort does not return. un_guard_close: + IntOp $GuardCloseTried $GuardCloseTried + 1 !insertmacro CockLogUn "uninst-guard: closing cockatrice on request, then checking again" Call un.CloseHolders Goto un_guard_check @@ -1601,7 +1710,10 @@ ${If} $PortableMode = 0 ${If} $ReinstallMode = 0 MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst32 - Abort + ; Abort would only skip this page and install over the old version anyway; + ; the message said the old one must go first, so Cancel means cancel. + !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt" + Quit ${Else} Goto uninst32 ${EndIf} @@ -1618,10 +1730,24 @@ ${If} $PortableMode = 0 StrCpy $OldUninstallString $R0 Call EnsureOldInstallNotRunning StrCpy $OldInstallDir $GuardDir + ; Run it silently. The question above is the one the user answered; the + ; uninstaller's own confirmation page would be a second one, and that one opens + ; behind this window, where nobody can answer it. The installer then looks hung, + ; the user clicks Next on it, the new version is installed, and the uninstaller + ; removes everything once this window closes. /S leaves no window to be behind, + ; so ExecWait is waiting for something that cannot outlive it. + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + ${If} $R1 == "" + StrCpy $OldUninstallString "$OldUninstallString /S" + ${Else} + StrCpy $OldUninstallString $R1 + ${EndIf} !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" + ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done32: @@ -1635,7 +1761,8 @@ ${If} $PortableMode = 0 ${If} $ReinstallMode = 0 MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "A previous version of Cockatrice must be uninstalled before installing the new one." IDOK uninst64 - Abort + !insertmacro CockLog "componentsPagePre: cancelled at the previous-version prompt" + Quit ${Else} Goto uninst64 ${EndIf} @@ -1644,10 +1771,19 @@ ${If} $PortableMode = 0 StrCpy $OldUninstallString $R0 Call EnsureOldInstallNotRunning StrCpy $OldInstallDir $GuardDir + ; Silent, for the reason given in the 32-bit branch above. + ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + ${If} $R1 == "" + StrCpy $OldUninstallString "$OldUninstallString /S" + ${Else} + StrCpy $OldUninstallString $R1 + ${EndIf} !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$OldInstallDir' (oldDir=$OldInstallDir InstDir=$INSTDIR)" ClearErrors Call BackupUserDataBeforeUninstall - ExecWait "$OldUninstallString _?=$OldInstallDir" + ExecWait "$OldUninstallString _?=$OldInstallDir" $R5 + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall exit=$R5" + Call WaitForOldPayloadToDisappear Call RestoreUserDataAfterUninstall done64: diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a3446e42b..a44176f38 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -923,11 +923,28 @@ void MainWindow::actShow() void MainWindow::closeEvent(QCloseEvent *event) { - // workaround Qt bug where closeEvent gets called twice - if (bClosingDown) { - return; + switch (shutdownState) { + case ShutdownState::Accepted: + // A duplicate close for a shutdown that already went through (Qt delivers the event + // twice on some platforms). Accepting it again keeps the quit that is already under + // way; ignoring it would cancel it. + event->accept(); + return; + case ShutdownState::Confirming: + // A second close request arrived while one of our own confirmations is up. Another + // program asking us to close - an installer replacing our files, for one - looks + // exactly like this, and accepting it would be destructive: close() hides this + // window, hides the dialog with it, and leaves the process running inside the nested + // loop that dialog opened. Nothing can reach that loop afterwards, so the process + // stays alive with no window and every later close request is accepted into the same + // dead end. Refuse this one and leave the question in front of the user; the shutdown + // that is already running finishes as soon as they answer it. + event->ignore(); + return; + case ShutdownState::Idle: + break; } - bClosingDown = true; + shutdownState = ShutdownState::Confirming; if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { const QString runningFor = cardUpdateStatusBar ? cardUpdateStatusBar->statusText() : QString(); @@ -939,19 +956,20 @@ void MainWindow::closeEvent(QCloseEvent *event) .arg(runningFor); if (!QtUtils::confirm(this, tr("Are you sure?"), message, QMessageBox::No)) { event->ignore(); - bClosingDown = false; + shutdownState = ShutdownState::Idle; return; } } if (!tabSupervisor->close()) { event->ignore(); - bClosingDown = false; + shutdownState = ShutdownState::Idle; return; } tip->close(); event->accept(); + shutdownState = ShutdownState::Accepted; SettingsCache::instance().layouts().setMainWindowGeometry(saveGeometry()); tabSupervisor->deleteLater(); } @@ -962,7 +980,7 @@ bool MainWindow::closeForUpdate() // closeEvent() is blocked on a user prompt). close() would hit the re-entrancy guard and return // true without any shutdown having happened, so report faithfully instead: the caller must keep // the process alive until the user resolves whatever is blocking the close. - if (bClosingDown) { + if (shutdownState != ShutdownState::Idle) { return false; } return close(); diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index bcf709cb1..98ef6a9c1 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -194,7 +194,17 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; - bool bClosingDown = false; ///< guards closeEvent() against re-entrancy + /** + * How far a shutdown that was asked for has got. A close request that arrives while one is + * already asking the user something must not be accepted - see closeEvent(). + */ + enum class ShutdownState + { + Idle, ///< no shutdown has been asked for + Confirming, ///< asking about a card database update, open games or an unsaved deck + Accepted, ///< the close was accepted and the window is hiding + }; + ShutdownState shutdownState = ShutdownState::Idle; bool skipStartupAutoConnect = false; bool startupAutoConnectAttempted = false; bool firstRunWizardActive = false; diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 508d9b3c1..d5bd5251f 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -44,39 +44,123 @@ std::wstring processLabel(const Holder &holder) return label; } +/// Joins the per-holder lines into the one line the installer logs and cuts it to what one +/// NSIS string holds. +std::wstring joinReport(const std::vector &lines) +{ + std::wstring report; + for (const std::wstring &line : lines) { + if (!report.empty()) { + report += L"; "; + } + report += line; + } + if (report.size() > REPORT_CHAR_MAX) { + report.erase(REPORT_CHAR_MAX); + report += L"..."; + } + return report; +} + struct WindowSearch { DWORD processId = 0; - HWND topmost = nullptr; ///< first visible window in Z-order: a dialog when one is up - HWND main = nullptr; ///< first visible window without an owner: the one to close - HWND any = nullptr; ///< first window at all, used when nothing is visible + std::vector windows; ///< every top-level window of the process, in Z-order }; -BOOL CALLBACK findProcessWindow(HWND window, LPARAM parameter) +BOOL CALLBACK collectProcessWindow(HWND window, LPARAM parameter) { auto &search = *reinterpret_cast(parameter); DWORD processId = 0; GetWindowThreadProcessId(window, &processId); - if (processId != search.processId) { - return TRUE; - } - if (search.any == nullptr) { - search.any = window; - } - // A tray icon leaves the main window hidden, so a window that is not visible is still - // kept as a fallback. EnumWindows walks the Z-order top down, so the first visible - // window is the one the user would see in front. - if (IsWindowVisible(window) != FALSE) { - if (search.topmost == nullptr) { - search.topmost = window; - } - if (search.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { - search.main = window; - } + if (processId == search.processId) { + search.windows.push_back(window); } return TRUE; } +/// Whether a window is one the user could be asked to answer. +/// +/// Only a framed, non-empty window qualifies. Qt keeps hidden top-level windows of its own +/// next to the application's: the tray icon creates one to receive the TaskbarCreated +/// message, styled WS_OVERLAPPED - which is no frame at all - and titled +/// QTrayIconMessageWindow. Showing one of those puts an empty white rectangle on the +/// taskbar that nothing ever closes again: it has no window procedure of its own to hide it, +/// and the process is busy in the loop this is meant to break, so it stays until the process +/// dies. Posting a close to one is worse than useless too: Qt's tray window answers WM_CLOSE +/// by asking the application to terminate, without going through the confirmation the close +/// is supposed to reach. +bool isAnswerableWindow(HWND window) +{ + if ((GetWindowLongPtr(window, GWL_EXSTYLE) & WS_EX_TOOLWINDOW) != 0) { + return false; + } + if ((GetWindowLongPtr(window, GWL_STYLE) & WS_CAPTION) == 0) { + return false; + } + RECT bounds{}; + if (GetWindowRect(window, &bounds) == FALSE) { + return false; + } + return bounds.right > bounds.left && bounds.bottom > bounds.top; +} + +/// Picks the window to post the close to and the one the user should end up looking at. +struct CloseTarget +{ + HWND main = nullptr; ///< first visible window without an owner: the application's own + HWND toRaise = nullptr; ///< topmost visible window: a dialog when one is up + bool modalOpen = false; ///< the window a close would reach is disabled, so a dialog of its own is up +}; + +/// Only the windows the user could answer are candidates - see isAnswerableWindow. An +/// instance left with nothing but Qt's own bookkeeping windows visible has no window to ask: +/// the caller reports that instead of posting a close the application would answer with a +/// terminate that skips its own confirmation. +CloseTarget chooseCloseTarget(const std::vector &windows) +{ + CloseTarget target; + HWND topmost = nullptr; + for (const HWND window : windows) { + if (IsWindowVisible(window) == FALSE || !isAnswerableWindow(window)) { + continue; + } + if (topmost == nullptr) { + topmost = window; + } + if (target.main == nullptr && GetWindow(window, GW_OWNER) == nullptr) { + target.main = window; + } + } + target.toRaise = topmost != nullptr ? topmost : target.main; + // A modal dialog disables the windows behind it, so a disabled main window is the + // instance asking a question of its own. + target.modalOpen = + target.toRaise != nullptr && IsWindowEnabled(target.main != nullptr ? target.main : target.toRaise) == FALSE; + return target; +} + +/// What is left of the window the close was posted to, so a log line tells an instance that +/// answered by hiding from one that never answered at all. A process that hid its window and +/// stayed alive is the one that needs the next close to bring it back into view. +std::wstring describeWindowState(HWND window) +{ + if (window == nullptr || IsWindow(window) == FALSE) { + return L", its window is gone"; + } + // WM_NULL is the cheapest way to ask whether the thread behind the window still dispatches. + // A hung instance does not answer at all, which is a different problem from one that + // answered the close and stayed open, and the two want different words in the prompt. + DWORD_PTR ignored = 0; + const bool responding = + SendMessageTimeoutW(window, WM_NULL, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 1000, &ignored) != 0; + std::wstring state = IsWindowVisible(window) != FALSE ? L", window still open" : L", window hidden again"; + if (!responding) { + state += L" and not responding"; + } + return state; +} + /// Restores a minimized window and takes the foreground, so a confirmation Cockatrice /// answers the close with is in front of the installer instead of behind it. /// @@ -133,6 +217,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un std::vector lines; std::vector asked; std::vector askedLines; + std::vector askedWindows; for (const Holder &holder : holders) { if (holder.isService || !isCockatriceImage(holder.imageName)) { @@ -144,23 +229,41 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un WindowSearch search; search.processId = holder.processId; - EnumWindows(findProcessWindow, reinterpret_cast(&search)); - if (search.any == nullptr) { + EnumWindows(collectProcessWindow, reinterpret_cast(&search)); + if (search.windows.empty()) { lines.push_back(processLabel(holder) + L" has no window to close, left to you"); continue; } - const HWND target = - search.main != nullptr ? search.main : (search.topmost != nullptr ? search.topmost : search.any); - const HWND toRaise = search.topmost != nullptr ? search.topmost : target; + const CloseTarget target = chooseCloseTarget(search.windows); + if (target.main == nullptr) { + // Nothing visible that a user could answer: the instance is hidden - in the + // notification area, say - or has nothing but Qt's own bookkeeping windows on + // screen. Neither is a window a close could reach, and showing one from here does + // not help: Windows would display it while the application still believes it is + // hidden, so it stays blank and answers nothing. The instance is named and left to + // the user, who can open it from the notification area. + lines.push_back(processLabel(holder) + L" has no window to ask, left to you"); + continue; + } std::wstring line = processLabel(holder); - line += bringForward(toRaise) ? L" brought forward" : L" could not be brought forward"; + line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward"; + + // Posting the close to an instance that is asking a question of its own is what made + // an earlier attempt useless: the client accepts it, hides this window and the dialog + // with it, and stays alive inside the nested loop the dialog opened, where nothing can + // reach it any more. The question is the request now, so it is brought forward and + // left alone. + if (target.modalOpen) { + lines.push_back(line + L", and is waiting for an answer to a question of its own"); + continue; + } // 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, WM_CLOSE, 0, 0) == FALSE) { + if (PostMessageW(target.main, WM_CLOSE, 0, 0) == FALSE) { // Read the error before CloseHandle, which is free to reset it. const DWORD postError = GetLastError(); if (process != nullptr) { @@ -177,6 +280,7 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un } else { askedLines.push_back(lines.size()); asked.push_back(process); + askedWindows.push_back(target.main); } lines.push_back(std::move(line)); } @@ -186,8 +290,12 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un const bool allExited = waitForExit(asked, waitMilliseconds, elapsed); for (std::size_t index = 0; index < asked.size(); ++index) { const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0; - lines[askedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" - : L", still running after " + std::to_wstring(elapsed) + L" ms"; + if (exited) { + lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms"; + } else { + lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" + + describeWindowState(askedWindows[index]); + } } } for (const HANDLE process : asked) { @@ -198,15 +306,61 @@ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, un result.report = L"nothing is holding the files"; return result; } - for (const std::wstring &line : lines) { - if (!result.report.empty()) { - result.report += L"; "; - } - result.report += line; - } - if (result.report.size() > REPORT_CHAR_MAX) { - result.report.erase(REPORT_CHAR_MAX); - result.report += L"..."; - } + result.report = joinReport(lines); + return result; +} + +EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds) +{ + EndHoldersResult result; + std::vector lines; + std::vector ended; + std::vector endedLines; + + for (const Holder &holder : holders) { + if (holder.isService || !isCockatriceImage(holder.imageName)) { + // The same split as the close: Oracle writes the card database in place and a + // service has nothing to ask, so neither is ever ended here. + lines.push_back(processLabel(holder) + L" left to you"); + continue; + } + + const HANDLE process = OpenProcess(PROCESS_TERMINATE | SYNCHRONIZE, FALSE, holder.processId); + if (process == nullptr) { + lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(GetLastError()) + + L")"); + continue; + } + if (TerminateProcess(process, 1) == FALSE) { + // Read the error before CloseHandle, which is free to reset it. + const DWORD error = GetLastError(); + CloseHandle(process); + lines.push_back(processLabel(holder) + L" could not be ended (win32 " + std::to_wstring(error) + L")"); + continue; + } + ++result.ended; + endedLines.push_back(lines.size()); + lines.push_back(processLabel(holder) + L" ended"); + ended.push_back(process); + } + + if (!ended.empty() && waitMilliseconds > 0) { + unsigned long long elapsed = 0; + const bool allExited = waitForExit(ended, waitMilliseconds, elapsed); + for (std::size_t index = 0; index < ended.size(); ++index) { + const bool exited = allExited || WaitForSingleObject(ended[index], 0) == WAIT_OBJECT_0; + lines[endedLines[index]] += exited ? L", exited after " + std::to_wstring(elapsed) + L" ms" + : L", still running after " + std::to_wstring(elapsed) + L" ms"; + } + } + for (const HANDLE process : ended) { + CloseHandle(process); + } + + if (lines.empty()) { + result.report = L"nothing is holding the files"; + return result; + } + result.report = joinReport(lines); return result; } diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 427da5ea6..4f9f5f0af 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -28,19 +28,55 @@ struct CloseHoldersResult * asked to close. Oracle and Servatrice are left to the user - Oracle writes the card * database in place - and so is every service. * - * Two things happen per instance. Its top-level window is restored and brought to the - * foreground, because Cockatrice may answer the close with a confirmation of its own that - * would otherwise open behind the installer's prompt, and then WM_CLOSE is posted to the - * main window, the same request as clicking its close button. Stopping the process is - * never attempted. + * One thing happens per instance: its main window is brought to the foreground, because + * Cockatrice may answer the close with a confirmation of its own that would otherwise open + * behind the installer's prompt. Then WM_CLOSE is posted to the main window, the same + * request as clicking its close button - unless the instance is asking a question of its + * own, which disables that window: posting the close then only hides the question, so it is + * brought forward and left to the user. An instance with no window a user could answer - + * one hidden in the notification area, say - is named and left to the user as well; showing + * its windows from here does not help, because the application would still believe they are + * hidden and never paint them. Stopping the process is never attempted. * * 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 - * for a fixed time or re-asking a question this call already answered. + * for a fixed time or re-asking a question this call already answered. A process that is + * still running is described further: whether its window is gone, still open, hidden + * again, and whether it answers at all, so the log can tell an instance that refused the + * close from one that never received it. * * @param holders the Restart Manager answer, one entry per application or service * @param waitMilliseconds how long to wait for the asked instances to exit, 0 to not wait */ CloseHoldersResult closeCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); +/** + * @brief What ending the holders did, in the form the installer logs. + * + * @c report is one line naming every holder, including the ones this deliberately leaves + * alone, because the installer repeats it in its log. + */ +struct EndHoldersResult +{ + unsigned int ended = 0; ///< holders TerminateProcess was accepted for + std::wstring report; ///< one line, narrowed to ASCII when written +}; + +/** + * @brief Ends every running Cockatrice the probe named. + * + * This is the last resort behind the installer's "end it now" answer, never a first move: a + * client that is still running may have an unsaved deck, an open game or a settings write in + * flight, so the caller only gets here after the user picked it from a prompt that says so. + * Only cockatrice.exe is 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. + * + * Waits up to @c waitMilliseconds for what it ended to be gone, so the caller can measure the + * purge immediately afterwards instead of racing a process that is still exiting. + * + * @param holders what the probe named, one entry per application or service + * @param waitMilliseconds how long to wait for the ended instances to be gone, 0 to not wait + */ +EndHoldersResult endCockatriceHolders(const std::vector &holders, unsigned long waitMilliseconds); + #endif diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index db46035d3..3f1556b75 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -20,6 +20,11 @@ constexpr wchar_t INSTALL_DIR_VARIABLE[] = L"COCKATRICE_GUARD_DIR"; /// 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 @@ -34,22 +39,40 @@ enum ExitCode EXIT_PROBE_FAILED = 3, }; -/// The probe has two modes, selected by the first argument: +/// 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 /// -/// Both answer from two lookups that cover each other's blind spot: the Restart Manager +/// 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. -bool closeModeRequested(int argumentCount, char **arguments) +enum class Mode { - return argumentCount > 1 && std::string(arguments[1]) == "--close-holders"; + 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 @@ -96,10 +119,17 @@ int main(int argumentCount, char **arguments) // 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 holders = findProcessesRunningFrom(installDir); + const std::vector asking = askingProcessIds(); std::vector 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; }); @@ -116,20 +146,25 @@ int main(int argumentCount, char **arguments) return EXIT_PROBE_FAILED; } - const bool closeMode = closeModeRequested(argumentCount, arguments); + 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 close mode says it itself, because - // its line is logged verbatim next to what it was asked to do. - if (closeMode) { + // 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 (!closeMode) { + 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); diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index c00e5da8c..1abfcb352 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -22,9 +22,28 @@ constexpr std::size_t MAX_PROBED_FILES = 2000; /// How deep the Plugins walk goes. Its real shape is Plugins\\.dll. constexpr int MAX_PLUGIN_DEPTH = 4; +/// How many parents askingProcessIds() walks up before it stops on its own. A real chain +/// is the probe, the installer or uninstaller, and the shell that started it; the bound +/// 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"}; +/// 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"}; + +bool isPayloadProgram(const std::wstring &imageName) +{ + for (const wchar_t *program : PAYLOAD_PROGRAMS) { + if (CompareStringOrdinal(imageName.c_str(), -1, program, -1, TRUE) == CSTR_EQUAL) { + return true; + } + } + return false; +} + void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -214,6 +233,45 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st return error; } +std::vector askingProcessIds() +{ + std::vector asking; + asking.push_back(GetCurrentProcessId()); + + // One snapshot for the whole walk: the parent ids come from it, so a level costs no + // second lookup. + std::vector processes; + const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) { + return asking; + } + PROCESSENTRY32W entry{}; + entry.dwSize = sizeof(entry); + if (Process32FirstW(snapshot, &entry) != FALSE) { + do { + processes.push_back(entry); + } while (Process32NextW(snapshot, &entry) != FALSE); + } + CloseHandle(snapshot); + + unsigned long current = asking.back(); + for (int level = 0; level < MAX_ASKER_LEVELS; ++level) { + const auto named = std::find_if(processes.begin(), processes.end(), [current](const PROCESSENTRY32W &process) { + return process.th32ProcessID == current; + }); + if (named == processes.end() || named->th32ParentProcessID == 0) { + break; + } + const unsigned long parent = named->th32ParentProcessID; + if (isPayloadProgram(fileNameOf(imagePathOf(parent)))) { + break; + } + asking.push_back(parent); + current = parent; + } + return asking; +} + std::vector findProcessesRunningFrom(const std::wstring &installDir) { std::vector holders; @@ -222,10 +280,10 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) } const std::wstring prefix = directoryPrefixOf(installDir); - // Never the probe itself: it runs from the directory being replaced when the - // uninstaller asks in place or the installer was copied next to the payload, and - // naming it would ask the user to close the process that is doing the asking. - const DWORD self = GetCurrentProcessId(); + // Never the probe, and never the installer or uninstaller that started it: either can + // run from the directory being replaced, and naming one asks the user to close the + // program that is asking (see askingProcessIds). + const std::vector asking = askingProcessIds(); const HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot == INVALID_HANDLE_VALUE) { @@ -235,7 +293,7 @@ std::vector findProcessesRunningFrom(const std::wstring &installDir) entry.dwSize = sizeof(entry); if (Process32FirstW(snapshot, &entry) != FALSE) { do { - if (entry.th32ProcessID == self) { + if (std::find(asking.begin(), asking.end(), entry.th32ProcessID) != asking.end()) { continue; } // One process at a time, because the path is per process; a process that diff --git a/tools/installer-holder-probe/restart_manager_holders.h b/tools/installer-holder-probe/restart_manager_holders.h index 6dcd91108..044cbe449 100644 --- a/tools/installer-holder-probe/restart_manager_holders.h +++ b/tools/installer-holder-probe/restart_manager_holders.h @@ -52,9 +52,30 @@ unsigned long findProcessesUsingFiles(const std::vector &files, st * database server next to a Cockatrice install) is never named. * * @param installDir the directory being replaced, as a full path. - * @return one entry per process running from it, in snapshot order, without the probe - * itself; empty when the directory is empty or no process runs from it. + * @return one entry per process running from it, in snapshot order, without the processes + * named by askingProcessIds(); empty when the directory is empty or no process + * runs from it. */ std::vector findProcessesRunningFrom(const std::wstring &installDir); +/** + * @brief The processes that must never be named as holders: the probe and the ones that + * started it. + * + * The installer or uninstaller doing the asking can legitimately run from the directory it + * is replacing - the old uninstaller is started with _?= so it stays in $INSTDIR, and an + * installer can be downloaded into the folder - and naming it asks the user to close the + * program that is asking. That cannot be answered: the close skips it, the end offer skips + * it, and the guard loops until the user cancels, which is what a run that named itself + * did. + * + * 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. + * + * @return process ids, the probe first, then its ancestors while they are not payload + * programs; only the probe itself when the snapshot fails. + */ +std::vector askingProcessIds(); + #endif