diff --git a/.ci/compile.sh b/.ci/compile.sh index cea837528..bf5a169af 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -559,6 +559,29 @@ Start-Sleep -Seconds 120 } 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)" } + + # A payload executable that refuses to be replaced is running, whether or not this probe + # may name it: the Restart Manager does not report every mapping, and the image path of a + # process under another account cannot be read from here, so both process lookups can + # come back empty while the program is still there. Held here by the asking process, so + # that it cannot be named - the answer has to be "could not name it", never "nothing is + # holding it", which is what sent an update into an uninstall that could only fail + # halfway. Exit 3 is the code the guard answers by asking the user to close it and retry. + $image = Join-Path $dir "oracle.exe" + [IO.File]::WriteAllText($image, "x") + $handle = [IO.File]::Open($image, "Open", "ReadWrite", "None") + try { + $unnameable = Invoke-Probe $dir + } finally { + $handle.Dispose() + } + if ($unnameable.Code -ne 3) { + throw "expected exit 3 for a payload executable that is in use but cannot be named, got $($unnameable.Code) : $($unnameable.Output)" + } + if ($unnameable.Output -notmatch "oracle\.exe is in use") { + throw "the answer did not name the executable it could not replace: $($unnameable.Output)" + } + $empty = Join-Path $dir "empty" New-Item -ItemType Directory -Force $empty | Out-Null $nothing = Invoke-Probe $empty @@ -623,7 +646,7 @@ Start-Sleep -Seconds 120 if ($ignored.Code -ne 0) { throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)" } - 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, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install" + Write-Host "holder lookup named pid $($holder.Id) for a file another process holds, named nothing for a file only the asking process holds, reported a payload executable it could not name as in use instead of free, named nothing for an empty directory, named pid $($running.Id) for a payload program running from the directory, and named nothing for a payload program in a folder below it or for a program the payload does not install" } finally { Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue } diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index d7a6e82c6..923aded9d 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -57,12 +57,16 @@ Var HolderFound ; whether the payload is free, while the abort dialog reports what survived. Var GuardExit Var GuardOutput -; Whether the guard has already asked Cockatrice to close by itself: 0 before the automatic -; close, 1 after it. The prompt that follows offers to end the instance rather than asking -; a second time (see GuardAppsNotRunning). -Var GuardCloseTried -; The question CockEndPrompt assembles: fixed sentences around the probe's line, and the -; result has to stay inside the characters one NSIS string holds. +; How many times the guard has asked the payload programs to close by itself. Bounded, and +; only repeated while the listing changed between rounds (see GuardAppsNotRunning). +Var GuardCloseRounds +; The listing the last automatic close was sent against, so a second round is spent only +; when something actually exited and the rest is worth asking again. Empty before round one. +Var GuardLastHolders +; How many times the user asked to check again after closing programs by hand (logged only). +Var GuardRecheckCount +; The question CockEndPrompt/CockEndRecheckPrompt assemble: fixed sentences around the +; probe's line, and the result has to stay inside the characters one NSIS string holds. Var GuardPrompt Var WaitPid Var LauncherExited @@ -1029,11 +1033,14 @@ FunctionEnd ; 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 can be killed mid-write, and Servatrice may be serving -; a game. A close is a request (WM_CLOSE), made automatically once because that is what -; the user started the installer to do, and only Cockatrice can answer one - the other -; two have no window. When the close is not answered the user is told which program is -; in the way and is offered to end all three themselves - the last resort, with what it -; costs spelled out in the prompt. +; a game. A close is a request, made automatically because that is what the user started +; the installer to do: the probe first asks every instance over its local socket, which +; reaches a hidden window and an instance sitting on its own confirmation, and falls back +; to WM_CLOSE for an older client. The automatic close is retried while the set of holders +; changes, so one instance exiting does not leave the next one unasked. When a close is +; still not answered the user is told which program is in the way and is offered to force +; close all three, check again after closing them by hand, or abort - the last resort, with +; what it costs spelled out in the prompt. ; One sentence for every prompt whose Yes ends the whole set, so that answer reads the same ; wherever the user meets it: this guard before anything is replaced, the uninstaller's, and @@ -1045,9 +1052,15 @@ FunctionEnd ; where the answer to "why did it stop" ends up. !define AFTER_CLOSING_GUIDANCE "Answer the confirmation Cockatrice may be showing - open it from the notification area if it is hidden there - and use Task Manager for a program that would not end." +; The escape hatch for a user who would rather not force-close anything: the same release, +; downloaded and run by hand once Cockatrice is closed. Every abort dialog shows this and +; offers to open the page, so an update that cannot free the files still leaves the user a +; way forward that does not end in Task Manager. +!define LATEST_RELEASE_URL "https://github.com/Cockatrice/Cockatrice/releases/latest" +!define MANUAL_UPDATE_GUIDANCE "To update safely instead, close Cockatrice and download the latest installer from:$\r$\n${LATEST_RELEASE_URL}$\r$\nThen run it manually." + ; usage: set the variable named by the second argument to the probe's listing, then call -; this. Sets $GuardPrompt to the question the guard, the uninstaller's guard and the -; recovery path all ask. +; this. Sets $GuardPrompt to the question the guard and the recovery path ask. ; ; One macro rather than three copies, because the wording is a promise: Yes ends the ; programs the payload installs - which is exactly the set EndHolders acts on - and anything @@ -1055,9 +1068,9 @@ FunctionEnd ; service hosted elsewhere does not come with an answer that cannot do what it says. ; ; Two answers, one of each: the automatic close already ran when this is asked, so Yes is the -; only thing left that can free the files and No stops the run. There is deliberately no -; "try again" - the same question, word for word, with nothing changed in between was what -; made an abort out of a stall. +; only thing left that can free the files and No stops the run. The guard itself uses +; CockEndRecheckPrompt instead, whose No checks again; this one stays for the recovery path, +; where a retry cannot change anything because the purge was already retried. ; ; The assembled string has to fit the characters one NSIS string holds, which is what ; bounds HOLDER_LINE_CHAR_MAX in tools\installer-holder-probe\holder_line.h: with the @@ -1069,7 +1082,29 @@ FunctionEnd ; @param cancelWord what No gives up, "installation" or "uninstallation" !macro CockEndPrompt header listing cancelWord StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" - StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to end the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to cancel the ${cancelWord}. ${AFTER_CLOSING_GUIDANCE}" +!macroend + +; usage: like CockEndPrompt, for a MB_YESNOCANCEL question whose No checks again. +; +; The automatic close has already run, so the three answers are what is left: force close +; the programs the payload installs (Yes), close them by hand and check again (No), or stop +; the run (Cancel). No used to be the only alternative to ending them, which meant a user who +; would rather close the instance themselves had to abandon the update and start the +; installer again. The listing the question is asked with is refreshed on every pass, so the +; question changes as programs go away instead of repeating itself word for word. +; +; The manual-update guidance lives in the abort dialog, which is where a Cancel ends up, not +; in this prompt. With the longest listing HOLDER_LINE_CHAR_MAX allows, the assembled string +; stays under 900 characters, inside the 1024 one NSIS string holds, so the Cancel line is +; never the part that gets cut off. +; +; @param header the sentence that introduces the listing, with its trailing colon +; @param listing the variable holding the probe's line, $GuardOutput +; @param cancelWord what Cancel gives up, "installation" or "uninstallation" +!macro CockEndRecheckPrompt header listing cancelWord + StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}" + StrCpy $GuardPrompt "$GuardPrompt$\r$\n$\r$\nChoose Yes to force close the Cockatrice, Oracle and Servatrice programs listed above. ${END_COST}$\r$\n$\r$\nChoose No to close them yourself, then check again - answer the confirmation Cockatrice may be showing, and open it from the notification area if it is hidden there.$\r$\n$\r$\nChoose Cancel to abort the ${cancelWord}." !macroend ; Emitted once for the installer and once for the uninstaller: the uninstaller @@ -1087,8 +1122,9 @@ FunctionEnd ; $GuardExit to the probe's exit code and $GuardOutput to its one-line answer. ; 0 = nothing holds the payload and no payload program runs from the directory, 1 = the ; line names what does, anything else = the probe could not answer (2 = no -; directory was given, 3 = the Restart Manager call failed and no payload program -; runs from the directory either, "timeout" = it did not return in time). +; directory was given, 3 = no payload program was named but one refuses to be replaced, or +; the Restart Manager call failed and no payload program runs from the directory either, +; "timeout" = it did not return in time). Function ${prefix}RunHolderProbe Call ${prefix}ExtractHolderProbe ; The directory travels in an environment variable, never on the command @@ -1112,12 +1148,13 @@ FunctionEnd ; cockatrice.exe is asked: Oracle writes the card database in place and Servatrice has no ; window to ask, so both are left alone here and only ended by the explicit answer. ; -; Each instance's own window is asked to close with WM_CLOSE - whether it is on screen or -; only in the notification area, since the request does not need a visible window and the -; notification-area instance is the one this update has no other way to reach. A visible -; window is brought forward first. WM_CLOSE is answered, not obeyed - Cockatrice asks before -; it quits while a card database update is running, a game is open or a deck is unsaved - and -; that question is only useful if it is in front of the installer instead of behind it. +; Each instance is asked to close over the local socket it listens on, which is the same +; request as clicking its close button and reaches 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, and a visible window is brought forward +; first. Either way the close is answered, not obeyed - Cockatrice asks before it quits while +; a card database update is running, a game is open or a deck is unsaved - and that question +; is only useful if it is in front of the installer instead of behind it. ; ; The probe waits up to 30 s for the processes it asked to exit, so the check that follows ; measures a settled directory rather than sleeping for a fixed time. Its line goes into the @@ -1188,7 +1225,9 @@ FunctionEnd ; that has nothing to report stays silent. Function GuardAppsNotRunning !insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)" - StrCpy $GuardCloseTried 0 + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" guard_check: Call RunHolderProbe ${If} $GuardExit == "0" @@ -1198,26 +1237,40 @@ Function GuardAppsNotRunning ${EndIf} ${If} $GuardExit == "1" !insertmacro CockLog "guard: still in use by $GuardOutput" - ; Close it for the user first. The probe knows exactly which cockatrice.exe holds - ; these files, and asking it to close is the same request as clicking the window's - ; close button - the application still answers its own "really quit?" prompt, so - ; nothing is lost. One attempt per guard call keeps the common case (the client was - ; still shutting down) from turning into a question, without hiding the case where - ; the close did not help: the pass after it names what is still holding the files. - ${If} $GuardCloseTried = 0 - StrCpy $GuardCloseTried 1 - !insertmacro CockLog "guard: asking cockatrice to close, then checking again" + ; Close it for the user first. The probe asks every instance over its local socket + ; and falls back to WM_CLOSE for an older client, so this is the same request as + ; clicking the window's close button: the application still answers its own "really + ; quit?" prompt, so nothing is lost. A second round is spent only when the listing + ; changed, which means something exited and the rest is worth asking again; repeating + ; the same request against the same processes cannot change anything. + ${If} $GuardCloseRounds < 2 + ${AndIf} $GuardOutput != $GuardLastHolders + StrCpy $GuardLastHolders $GuardOutput + IntOp $GuardCloseRounds $GuardCloseRounds + 1 + !insertmacro CockLog "guard: asking the payload programs to close (round $GuardCloseRounds)" Call CloseHolders Goto guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; The close ran and did not free the files, so ending them is the only thing left that - ; can - and that is the one question, asked once. Its other answer stops the run - ; instead of asking again: a second identical dialog, with nothing changed in between, - ; is what made users cancel an update the installer could have finished by itself. - !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end - !insertmacro CockLog "guard: cancelled by user" + ; The close ran and did not free the files. Force-closing them is what the installer + ; can do (Yes); closing them by hand is what the user can do (No checks again, with + ; the fresh listing); Cancel stops the run and the abort dialog offers the same + ; update from the official download page. Without the middle answer a user who did + ; not want anything killed had no way forward but to abandon the update. + guard_ask: + !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end IDNO guard_recheck + Goto guard_cancel + guard_recheck: + IntOp $GuardRecheckCount $GuardRecheckCount + 1 + !insertmacro CockLog "guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)" + ; A fresh set gets a fresh automatic close, exactly as if the guard had just started: + ; what the user closed by hand may have been one instance of several. + StrCpy $GuardCloseRounds 0 + StrCpy $GuardLastHolders "" + Goto guard_check + guard_cancel: + !insertmacro CockLog "guard: cancelled by user after $GuardRecheckCount recheck(s)" StrCpy $AbortReason \ "The installation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the previous version.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the installer again." ; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken; @@ -1249,25 +1302,27 @@ Function GuardAppsNotRunning Call AbortInstall ${EndIf} ; Not an answer at all: the probe was not configured, the Restart Manager - ; call failed and no payload program runs from the directory, or it timed - ; out. Carrying on from that is a decision, so it is asked for rather than - ; assumed - the stale-runtime check after the purge still stands between a - ; failed guard and a mismatched install, but it only fires after files have - ; been replaced, and on an upgrade the launcher asked for nobody is watching - ; the installer's exit code. Retry asks the probe again (a Restart Manager - ; that was busy answers the second time), Ignore installs anyway, Abort - ; cancels instead of writing a payload next to files the guard could not - ; rule out. + ; call failed and no payload program runs from the directory, a payload + ; program refuses to be replaced but no lookup could name it (it may run + ; under another account), or the probe timed out. Carrying on from that is a + ; decision, so it is asked for rather than assumed - the stale-runtime check + ; after the purge still stands between a failed guard and a mismatched + ; install, but it only fires after files have been replaced, and on an + ; upgrade the launcher asked for nobody is watching the installer's exit + ; code. Retry asks the probe again (a Restart Manager that was busy answers + ; the second time, and a program the user has closed frees its file), Ignore + ; installs anyway, Abort cancels instead of writing a payload next to files + ; the guard could not rule out. !insertmacro CockLog "guard: the holder probe could not answer ($GuardExit): $GuardOutput" - DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "This installer could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \ + "This installer could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to install anyway, or Abort to cancel the installation." \ IDRETRY guard_check IDABORT guard_unknown_abort !insertmacro CockLog "guard: continuing without an answer from the probe" Return guard_unknown_abort: StrCpy $AbortReason \ - "The installation was cancelled because it could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again." + "The installation was cancelled because it could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nPlease close them and run the installer again." ; Same reason as the cancel path above: this abort can be reached from the ; leftover-removal guard, after the backup was taken. Call RestoreUserDataAfterUninstall @@ -1292,7 +1347,18 @@ FunctionEnd Function AbortInstall !insertmacro CockLog "ABORT: $AbortReason" !insertmacro CockLog "full installer log: $InstallLogPath" - MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + !insertmacro CockLog "manual update: ${LATEST_RELEASE_URL}" + ; The reason names what stopped the run; the manual-update paragraph is the way forward + ; that does not need this installer to free anything. Shown in every mode, exactly like + ; the reason, because a /R upgrade has nobody watching its exit code. + MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\n${MANUAL_UPDATE_GUIDANCE}$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + ; Offer the page as well: copying a URL out of a message box is a step users skip. + ; explorer.exe hands the URL to the already-running shell, so the browser opens at the + ; user's integrity level rather than as the elevated installer. + MessageBox MB_YESNO|MB_ICONQUESTION|MB_DEFBUTTON2 "Open the download page now?$\r$\n$\r$\n${LATEST_RELEASE_URL}" IDNO abort_no_link + !insertmacro CockLog "manual update: opening the download page" + Exec '"$WINDIR\explorer.exe" "${LATEST_RELEASE_URL}"' + abort_no_link: ; Offer to send the installer log to Sentry so aborted installs can be diagnosed. ; Only a package built with a baked DSN offers to send, so the prompt can only appear ; when a send can actually go out. A preprocessor guard rather than a runtime test: a @@ -1868,9 +1934,9 @@ FunctionEnd ; itself - not one in a folder below it, which is another application the user installed ; there - because a running instance holds its payload as mapped images rather than as open ; handles and the Restart Manager does not reliably report those. Its remaining blind spot -; is a handle taken in kernel mode, where there is no process to name; then the per-file -; codes logged above are the whole story, and the probe says so by naming nothing rather -; than by failing. +; is a handle taken in kernel mode, where there is no process to name; a payload executable +; among the files then still refuses to be replaced, and the probe answers with that file +; instead of naming nothing. ; ; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard ; uses, so a path with spaces or apostrophes never has to be embedded in a command line. @@ -1894,7 +1960,7 @@ Function LogProcessesUsingInstallDir ${ElseIf} $HolderExit == "0" StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory" ${Else} - StrCpy $HolderOutput "the holder probe could not run: $HolderOutput" + StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput" ${EndIf} !insertmacro CockLog "holders: $HolderOutput" FunctionEnd @@ -1992,7 +2058,9 @@ Function un.EnsureAppsNotRunning FunctionEnd Function un.GuardAppsNotRunning - StrCpy $GuardCloseTried 0 + StrCpy $GuardCloseRounds 0 + StrCpy $GuardRecheckCount 0 + StrCpy $GuardLastHolders "" un_guard_check: !insertmacro CockLogUn "uninst-guard: checking $GuardDir" Call un.RunHolderProbe @@ -2002,24 +2070,36 @@ Function un.GuardAppsNotRunning ${EndIf} ${If} $GuardExit == "1" !insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput" - ; Same convenience as in the installer: ask Cockatrice to close once, then report - ; what the close could not reach. - ${If} $GuardCloseTried = 0 - StrCpy $GuardCloseTried 1 - !insertmacro CockLogUn "uninst-guard: asking cockatrice to close, then checking again" + ; Same convenience as in the installer: ask every instance to close, repeat only + ; while the set changed, then report what the close could not reach. + ${If} $GuardCloseRounds < 2 + ${AndIf} $GuardOutput != $GuardLastHolders + StrCpy $GuardLastHolders $GuardOutput + IntOp $GuardCloseRounds $GuardCloseRounds + 1 + !insertmacro CockLogUn "uninst-guard: asking the payload programs to close (round $GuardCloseRounds)" Call un.CloseHolders Goto un_guard_check ${EndIf} DetailPrint "The install directory is in use by: $GuardOutput" - ; Same question as in the installer's guard, and the same two answers: Yes ends the - ; programs the payload installs, anything else stops the uninstallation. Asking again - ; after a No only repeated the dialog. CockEndPrompt words it, so this copy and the - ; installer's promise the same thing. - !insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" - MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end - !insertmacro CockLogUn "uninst-guard: cancelled by user" + ; Same question and the same three answers as the installer's guard: force close, + ; close by hand and check again, or abort. CockEndRecheckPrompt words it, so the two + ; copies promise the same thing. + !insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation" + MessageBox MB_YESNOCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end IDNO un_guard_recheck + !insertmacro CockLogUn "uninst-guard: cancelled by user after $GuardRecheckCount recheck(s)" + ; The installer's Cancel lands in AbortInstall, which explains what to do next. The + ; uninstaller has no such dialog, so say the same thing here: a bare Abort would + ; close the window and leave the user with no reason and no next step. + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "The uninstallation was cancelled because Cockatrice, Oracle or Servatrice is still running and holding files of the installation.$\r$\n$\r$\n${AFTER_CLOSING_GUIDANCE}$\r$\n$\r$\nClose them and run the uninstaller again." SetErrorLevel 1 Abort + un_guard_recheck: + IntOp $GuardRecheckCount $GuardRecheckCount + 1 + !insertmacro CockLogUn "uninst-guard: user closed programs by hand, checking again (recheck $GuardRecheckCount)" + StrCpy $GuardCloseRounds 0 + StrCpy $GuardLastHolders "" + Goto un_guard_check ; Only reached through Yes: Abort does not return. un_guard_end: !insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again" @@ -2039,11 +2119,12 @@ Function un.GuardAppsNotRunning ; Same reasoning as in the installer: without an answer from the probe we cannot tell ; whether a running instance still holds file locks, and refusing outright would leave ; the user with an uninstaller that never completes. Same three answers too, so an - ; uninstall that hits a Restart Manager that was busy can simply be retried. + ; uninstall that hits a Restart Manager that was busy, or a payload program the user has + ; not closed yet, can simply be retried. !insertmacro CockLogUn "uninst-guard: the holder probe could not answer ($GuardExit): $GuardOutput" - DetailPrint "Could not check whether Cockatrice is still running: $GuardOutput" + DetailPrint "Could not confirm whether Cockatrice is still running: $GuardOutput" MessageBox MB_ABORTRETRYIGNORE|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ - "This uninstaller could not check whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \ + "This uninstaller could not confirm whether Cockatrice, Oracle or Servatrice is still running.$\r$\n$\r$\nThe check reported: $GuardOutput$\r$\n$\r$\nClose them, then choose Retry to check again. Choose Ignore to uninstall anyway, or Abort to cancel the uninstallation." \ IDRETRY un_guard_check IDABORT un_guard_unknown_abort !insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe" Return diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index bec858dcb..4bd1bde56 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -345,6 +345,7 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp src/interface/window_main.cpp + src/installer_close_server.cpp src/main.cpp src/single_instance_manager.cpp src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -450,6 +451,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/installer_close_server.h src/single_instance_manager.h src/client/url_scheme_event_filter.h src/interface/intents/intent_connect_to_server.cpp diff --git a/cockatrice/src/installer_close_server.cpp b/cockatrice/src/installer_close_server.cpp new file mode 100644 index 000000000..26f0b2c87 --- /dev/null +++ b/cockatrice/src/installer_close_server.cpp @@ -0,0 +1,81 @@ +#include "installer_close_server.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +/// The one line the probe writes, from the header both ends include. A QByteArray view of +/// the shared literal, so the client cannot drift from the probe's copy. +const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1); + +/// How long a connection that never sends the command may stay open. The probe writes and +/// closes at once, so this only reaps a stray client; it must not run while a close request +/// is being handled, which is why it is a timer on the socket and not a disconnected slot. +constexpr int STRAY_CONNECTION_MILLISECONDS = 5000; +} // namespace + +InstallerCloseServer::InstallerCloseServer(QObject *parent) : QObject(parent) +{ +} + +QString InstallerCloseServer::socketNameForUser(qint64 processId, const QString &userName) +{ + return QStringLiteral("CockatriceInstallerClose-%1-%2").arg(processId).arg(userName); +} + +QString InstallerCloseServer::socketName(qint64 processId) +{ + // The same scoping helper the single-instance socket uses, so the probe only has to + // reproduce one rule (USER, then USERNAME, then the home directory name). + return scopedLocalServerName(QStringLiteral("CockatriceInstallerClose-%1").arg(processId)); +} + +bool InstallerCloseServer::listen() +{ + if (server != nullptr) { + return server->isListening(); + } + name = socketName(QCoreApplication::applicationPid()); + server = new QLocalServer(this); + // A crashed instance can leave a Unix socket file behind; on Windows this is a no-op. + QLocalServer::removeServer(name); + if (!server->listen(name)) { + delete server; + server = nullptr; + return false; + } + + connect(server, &QLocalServer::newConnection, this, [this]() { + while (QLocalSocket *socket = server->nextPendingConnection()) { + // No disconnected -> deleteLater() slot: the close request can spin a nested + // event loop, the probe disconnects while it runs, and a deferred delete would + // then free the socket from inside its own readyRead emission. The reaper covers + // a client that never sends the line; the handler below deletes after the emit. + auto *reaper = new QTimer(socket); + reaper->setSingleShot(true); + connect(reaper, &QTimer::timeout, socket, &QObject::deleteLater); + reaper->start(STRAY_CONNECTION_MILLISECONDS); + connect(socket, &QLocalSocket::readyRead, this, [this, socket, reaper]() { + if (!socket->canReadLine()) { + return; + } + if (socket->readLine() != CLOSE_COMMAND) { + return; + } + // Stop the reaper before the emit: the receiver may close the window and run + // a nested event loop for as long as the user takes, and the reaper must not + // free the socket from inside that loop while this handler still holds it. + reaper->stop(); + emit closeRequested(); + socket->disconnectFromServer(); + socket->deleteLater(); + }); + } + }); + return true; +} diff --git a/cockatrice/src/installer_close_server.h b/cockatrice/src/installer_close_server.h new file mode 100644 index 000000000..847c35523 --- /dev/null +++ b/cockatrice/src/installer_close_server.h @@ -0,0 +1,50 @@ +#ifndef COCKATRICE_INSTALLER_CLOSE_SERVER_H +#define COCKATRICE_INSTALLER_CLOSE_SERVER_H + +#include +#include + +class QLocalServer; + +/** + * @brief Listens for the installer's request to close this instance gracefully. + * + * The Windows installer cannot reach an instance whose window is hidden in the notification + * area with WM_CLOSE, and it must not kill a process that may hold an unsaved deck. It + * connects to this local socket instead and writes one line; the instance then closes the + * same way its own close button does - saving settings and answering its own confirmation, + * never bypassing it. + * + * The socket name carries the process id, so every instance has its own and an installer + * that found several can ask each one. It is built by installerCloseSocketName() in + * tools/installer-holder-probe/installer_close_name.h; the two must stay identical. + */ +class InstallerCloseServer : public QObject +{ + Q_OBJECT +public: + explicit InstallerCloseServer(QObject *parent = nullptr); + + /** + * @brief Starts listening. A failure is not fatal: the installer falls back to + * WM_CLOSE, which is what older clients answer. + */ + bool listen(); + + /// The socket name this process listens on. The installer's probe builds the same + /// string from the process id and the account name. + static QString socketName(qint64 processId); + + /// The name for an explicit account, exposed so the probe's name helper and this one + /// can be tested against each other. + static QString socketNameForUser(qint64 processId, const QString &userName); + +signals: + void closeRequested(); + +private: + QLocalServer *server = nullptr; + QString name; +}; + +#endif // COCKATRICE_INSTALLER_CLOSE_SERVER_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index a44176f38..f8ab5d3f6 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -986,6 +986,30 @@ bool MainWindow::closeForUpdate() return close(); } +void MainWindow::requestInstallerClose() +{ + // The same shutdown the in-app updater asks for: close the window, flush settings and let + // the tabs answer their own prompts. Never a forced quit - ending a process that has not + // agreed is the installer's separate force-close answer, not this one. + if (closeForUpdate()) { + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + return; + } + // A confirmation is already up, or a shutdown is in progress. Bring it forward: an + // installer prompt behind a hidden confirmation is the deadlock this request exists to + // break, and the user has to be able to answer it. showWindowIfHidden() also clears + // Qt::WindowMinimized, which raise() alone does not: a minimized window can keep the + // confirmation behind the installer. + showWindowIfHidden(); + if (QWidget *modal = QApplication::activeModalWidget()) { + modal->raise(); + modal->activateWindow(); + } + raise(); + activateWindow(); + QApplication::alert(this, 0); +} + void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 98ef6a9c1..9e342e4b0 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -255,6 +255,17 @@ public: */ bool closeForUpdate(); + /** + * @brief Closes this instance because the installer asked it to over its local socket. + * + * The request reaches an instance hidden in the notification area or sitting on its own + * confirmation, which is exactly where a WM_CLOSE from the installer does not. The close + * is still the ordinary one: a clean client exits, one with unsaved work raises the same + * confirmation the close button does, and a confirmation already up is brought to the + * front instead of being left behind the installer. + */ + void requestInstallerClose(); + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 7b8648d69..829a8ac42 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -26,6 +26,7 @@ #include "client/sound_engine.h" #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" +#include "installer_close_server.h" #include "interface/intents/intent.h" #include "interface/intents/intent_open_local_deck.h" #include "interface/logger.h" @@ -431,6 +432,15 @@ int main(int argc, char *argv[]) MainWindow ui; + // The installer asks a running instance to close over this socket, which reaches a + // hidden window and one sitting on its own confirmation. Failure is not fatal: the + // installer falls back to WM_CLOSE for clients that have no socket. + InstallerCloseServer installerClose; + if (installerClose.listen()) { + QObject::connect(&installerClose, &InstallerCloseServer::closeRequested, &ui, + &MainWindow::requestInstallerClose); + } + // A URL launch must own the connection: the intent chain triggered by the // URL connects to the server named in the URL, so the window's own startup // auto-connect must not race against it (two connectToServer calls tear diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 748f6b90f..55f1daf24 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -23,6 +23,7 @@ set(UTILITY_HEADERS libcockatrice/utility/cryptoutil.h libcockatrice/utility/debouncer.h libcockatrice/utility/expression.h + libcockatrice/utility/installer_close_protocol.h libcockatrice/utility/levenshtein.h libcockatrice/utility/local_server_name.h libcockatrice/utility/macros.h diff --git a/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h b/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h new file mode 100644 index 000000000..1db467b2f --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/installer_close_protocol.h @@ -0,0 +1,18 @@ +#ifndef COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H +#define COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H + +/** + * @brief The one line the installer's holder probe writes to a running instance. + * + * Both ends include this header: the probe writes the line + * (tools/installer-holder-probe/installer_close_request.cpp) and the client's + * InstallerCloseServer reads it. Keeping the bytes in one place means the two ends cannot + * drift apart, and the test pins the exact line so a change to it is a deliberate protocol + * change rather than a typo in one copy. + * + * Plain C++ on purpose: the probe links no Qt and no part of this project, so this header + * must stay free of both. + */ +inline constexpr char INSTALLER_CLOSE_COMMAND[] = "COCKATRICE_INSTALLER_CLOSE\n"; + +#endif // COCKATRICE_INSTALLER_CLOSE_PROTOCOL_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1750a448e..19f9268ee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -445,6 +445,22 @@ if(WITH_CLIENT) ${CMAKE_SOURCE_DIR}/cockatrice/src ${CMAKE_SOURCE_DIR}/libcockatrice_utility ) + # The installer's probe and the client build the socket name independently, and the + # command they exchange is spelled in both places. Compiling both ends into one test is + # what keeps them from drifting apart unnoticed: a mismatch would leave the installer + # unable to close an instance whose window it cannot reach. + cockatrice_add_test( + NAME + installer_close_server_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/installer_close_server.cpp + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe/installer_close_name.cpp + installer_close_server_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_utility + ${CMAKE_SOURCE_DIR}/tools/installer-holder-probe + ) # CrashReporter is client code; DEFINITIONS keeps the test hermetic by compiling the # no-op path (no sentry-native headers needed) regardless of WITH_CRASH_REPORTING. cockatrice_add_test( diff --git a/tests/installer_close_server_test.cpp b/tests/installer_close_server_test.cpp new file mode 100644 index 000000000..158d0ce47 --- /dev/null +++ b/tests/installer_close_server_test.cpp @@ -0,0 +1,101 @@ +#include "installer_close_name.h" +#include "installer_close_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int HANDSHAKE_TIMEOUT_MS = 5000; + +/// The bytes both ends exchange, from the header they both include. The separate +/// WireCommandIsThePinnedBytes test is what catches an accidental change to the literal. +const QByteArray CLOSE_COMMAND = QByteArray::fromRawData(INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1); + +/// Scopes the socket to this test process, so a Cockatrice the developer happens to be +/// running cannot claim the name and the test cannot close it. Kept short on purpose: +/// QLocalServer on Unix puts the socket file under QDir::tempPath(), and macOS caps that +/// path at 104 bytes (sun_path). A longer name passes on Linux (/tmp) and fails to listen +/// on the macOS CI runners, whose temp directory is already ~48 characters. +QByteArray testUserName() +{ + return QByteArrayLiteral("t") + QByteArray::number(QCoreApplication::applicationPid()); +} +} // namespace + +// The probe builds the name from the process id and the account name it reads from the +// environment; the client builds it with scopedLocalServerName(). They have to produce the +// same string or the installer can never reach the instance it is trying to close. +TEST(InstallerCloseServerTest, SocketNameMatchesTheProbeForTheSameProcessAndUser) +{ + constexpr unsigned long PROCESS_ID = 4242; + const QString userName = QStringLiteral("installer-test"); + const QString expected = QStringLiteral("CockatriceInstallerClose-4242-installer-test"); + + EXPECT_EQ(expected, InstallerCloseServer::socketNameForUser(PROCESS_ID, userName)); + EXPECT_EQ(expected.toStdWString(), installerCloseSocketName(PROCESS_ID, userName.toStdWString())); +} + +TEST(InstallerCloseServerTest, SocketNameScopesTheProcessIdByTheCurrentUser) +{ + qputenv("USER", testUserName()); + + EXPECT_EQ(QStringLiteral("CockatriceInstallerClose-4242-") + QString::fromLatin1(testUserName()), + InstallerCloseServer::socketName(4242)); +} + +// The line is a wire format shared with the Windows-only probe, which cannot be compiled +// into this test. Pinning the exact bytes here makes a change to the shared header a +// deliberate protocol change rather than a typo that only shows up in the installer. +TEST(InstallerCloseServerTest, WireCommandIsThePinnedBytes) +{ + EXPECT_EQ(QByteArrayLiteral("COCKATRICE_INSTALLER_CLOSE\n"), CLOSE_COMMAND); + EXPECT_EQ(27, CLOSE_COMMAND.size()); +} + +// The installer only writes the command; the listening instance is what turns it into a +// close. Anything but that exact line must not be treated as a request. +TEST(InstallerCloseServerTest, CloseRequestIsEmittedForTheProbeCommand) +{ + qputenv("USER", testUserName()); + + InstallerCloseServer server; + ASSERT_TRUE(server.listen()) << "the installer close socket could not be started"; + + int closeRequests = 0; + QObject::connect(&server, &InstallerCloseServer::closeRequested, &server, [&closeRequests]() { ++closeRequests; }); + + QLocalSocket socket; + socket.connectToServer(InstallerCloseServer::socketName(QCoreApplication::applicationPid())); + ASSERT_TRUE(socket.waitForConnected(HANDSHAKE_TIMEOUT_MS)) << "could not reach the installer close socket"; + ASSERT_EQ(CLOSE_COMMAND.size(), socket.write(CLOSE_COMMAND)) << "the close request was not accepted"; + + // No waitForBytesWritten(): on Windows QLocalSocket completes the write through the + // event dispatcher, and this test runs the server in the same thread, so a blocking + // wait would hold the loop the server needs to accept and read. The loop below is the + // real handshake: it flushes the write and delivers the server's readyRead. + QEventLoop loop; + QTimer timeout; + timeout.setSingleShot(true); + QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit); + QObject::connect(&server, &InstallerCloseServer::closeRequested, &loop, &QEventLoop::quit); + timeout.start(HANDSHAKE_TIMEOUT_MS); + loop.exec(); + + EXPECT_EQ(1, closeRequests) << "the probe's line must reach the close handler exactly once"; +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tools/installer-holder-probe/CMakeLists.txt b/tools/installer-holder-probe/CMakeLists.txt index e8ea6df16..99e7d5045 100644 --- a/tools/installer-holder-probe/CMakeLists.txt +++ b/tools/installer-holder-probe/CMakeLists.txt @@ -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( diff --git a/tools/installer-holder-probe/close_holders.cpp b/tools/installer-holder-probe/close_holders.cpp index 7c599bdec..24fe7f0e7 100644 --- a/tools/installer-holder-probe/close_holders.cpp +++ b/tools/installer-holder-probe/close_holders.cpp @@ -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 &holders, unsigned long waitMil search.processId = holder.processId; EnumWindows(collectProcessWindow, reinterpret_cast(&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 &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 &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 &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]); diff --git a/tools/installer-holder-probe/close_holders.h b/tools/installer-holder-probe/close_holders.h index 91e6b316b..3f6c889fd 100644 --- a/tools/installer-holder-probe/close_holders.h +++ b/tools/installer-holder-probe/close_holders.h @@ -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 diff --git a/tools/installer-holder-probe/installer_close_name.cpp b/tools/installer-holder-probe/installer_close_name.cpp new file mode 100644 index 000000000..74659e151 --- /dev/null +++ b/tools/installer-holder-probe/installer_close_name.cpp @@ -0,0 +1,12 @@ +#include "installer_close_name.h" + +#include + +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; +} diff --git a/tools/installer-holder-probe/installer_close_name.h b/tools/installer-holder-probe/installer_close_name.h new file mode 100644 index 000000000..b659f8b8f --- /dev/null +++ b/tools/installer-holder-probe/installer_close_name.h @@ -0,0 +1,24 @@ +#ifndef INSTALLER_CLOSE_NAME_H +#define INSTALLER_CLOSE_NAME_H + +#include + +/** + * @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--`. 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 diff --git a/tools/installer-holder-probe/installer_close_request.cpp b/tools/installer-holder-probe/installer_close_request.cpp new file mode 100644 index 000000000..0475aedf3 --- /dev/null +++ b/tools/installer-holder-probe/installer_close_request.cpp @@ -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 +// clang-format on + +#include +#include +#include +#include + +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 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; +} diff --git a/tools/installer-holder-probe/installer_close_request.h b/tools/installer-holder-probe/installer_close_request.h new file mode 100644 index 000000000..f30c49b2c --- /dev/null +++ b/tools/installer-holder-probe/installer_close_request.h @@ -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 diff --git a/tools/installer-holder-probe/main.cpp b/tools/installer-holder-probe/main.cpp index f9ca84ac8..31901a0ec 100644 --- a/tools/installer-holder-probe/main.cpp +++ b/tools/installer-holder-probe/main.cpp @@ -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 @@ -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(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 &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 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. diff --git a/tools/installer-holder-probe/payload_locks.cpp b/tools/installer-holder-probe/payload_locks.cpp new file mode 100644 index 000000000..83b77ac58 --- /dev/null +++ b/tools/installer-holder-probe/payload_locks.cpp @@ -0,0 +1,67 @@ +#include "payload_locks.h" + +#include "payload_paths.h" + +#include + +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 payloadExecutablesInUse(const std::wstring &installDir) +{ + std::vector 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 settled; + for (const std::wstring &path : refused) { + if (refusesWrite(path)) { + settled.push_back(path); + } + } + return settled; +} diff --git a/tools/installer-holder-probe/payload_locks.h b/tools/installer-holder-probe/payload_locks.h new file mode 100644 index 000000000..1d23648a2 --- /dev/null +++ b/tools/installer-holder-probe/payload_locks.h @@ -0,0 +1,36 @@ +#ifndef PAYLOAD_LOCKS_H +#define PAYLOAD_LOCKS_H + +#include +#include + +/** + * @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 payloadExecutablesInUse(const std::wstring &installDir); + +#endif diff --git a/tools/installer-holder-probe/payload_paths.cpp b/tools/installer-holder-probe/payload_paths.cpp index 8fac40040..8d6274e91 100644 --- a/tools/installer-holder-probe/payload_paths.cpp +++ b/tools/installer-holder-probe/payload_paths.cpp @@ -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 diff --git a/tools/installer-holder-probe/payload_paths.h b/tools/installer-holder-probe/payload_paths.h index 247fc64ed..ea63732f5 100644 --- a/tools/installer-holder-probe/payload_paths.h +++ b/tools/installer-holder-probe/payload_paths.h @@ -3,6 +3,18 @@ #include +/** + * @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. diff --git a/tools/installer-holder-probe/restart_manager_holders.cpp b/tools/installer-holder-probe/restart_manager_holders.cpp index e3fb3e3c8..21ddd6988 100644 --- a/tools/installer-holder-probe/restart_manager_holders.cpp +++ b/tools/installer-holder-probe/restart_manager_holders.cpp @@ -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 &files) { if (files.size() >= MAX_PROBED_FILES) { @@ -129,7 +126,12 @@ Holder holderOf(const RM_PROCESS_INFO &info) std::vector collectPayloadFiles(const std::wstring &installDir) { std::vector 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 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) {