mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
[Installer] Graceful close and a way out of a stuck update (#7687)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 cover (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Desktop / Prune caches (push) Waiting to run
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* [Installer] Stop calling the payload free while an executable is locked The guard answered "nothing is holding the payload" from two lookups that can both miss a running instance: Restart Manager does not reliably report a program's own image or the modules it mapped, and the image path of a process running under another account cannot be read from the probe at all. A user log showed exactly that: the first guard passed while cockatrice.exe was running from the directory, the old uninstaller silently skipped the file it could not delete, and the update aborted half an uninstall later with "cancelled by user" after a close attempt the running window ignored. Ask the file itself. A running image is opened without write sharing, so the write open replacing it needs is refused with ERROR_SHARING_VIOLATION for as long as the program runs, while a file that is merely being read still accepts it. When neither lookup named anybody and a payload executable refuses that open, the probe answers that the file is in use and that the program holding it could not be identified - the guard's "could not confirm" dialog, whose Retry works once the user has closed it - instead of passing the payload as free. Only the payload executables are tested, not every file the purge removes: a locked executable proves a program is running and a mapped DLL belongs to a running executable, while a locked DLL or qt.conf can be the asking process itself, which is the loop the guard already had. The open is retried once after 300 ms, and only for the files that refused, so a scanner holding a file for a moment cannot stop an install for nothing. The Windows probe check now covers that answer: a payload executable held by the asking process, so that it cannot be named, has to come back as in use rather than as free. Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de> * [Installer] Close running clients over a local socket The installer could only ask a running client to close with WM_CLOSE, which an instance hidden in the notification area or sitting on its own confirmation cannot answer usefully. Give every client a per-process local socket and have the holder probe ask it there first, falling back to WM_CLOSE for clients from before the socket. * [Installer] Offer to check again or update by hand when files stay locked Replace the two-answer guard prompt with force close, close by hand and check again, or cancel. Retry the automatic close while the set of holders changes, and point a cancelled run at the latest release so a user who does not want anything killed has a way forward that is not cancelling the update again. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
720adf5410
commit
bbc7e462ff
25 changed files with 897 additions and 130 deletions
|
|
@ -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.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)" }
|
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"
|
$empty = Join-Path $dir "empty"
|
||||||
New-Item -ItemType Directory -Force $empty | Out-Null
|
New-Item -ItemType Directory -Force $empty | Out-Null
|
||||||
$nothing = Invoke-Probe $empty
|
$nothing = Invoke-Probe $empty
|
||||||
|
|
@ -623,7 +646,7 @@ Start-Sleep -Seconds 120
|
||||||
if ($ignored.Code -ne 0) {
|
if ($ignored.Code -ne 0) {
|
||||||
throw "expected exit 0 for a program the payload does not install, got $($ignored.Code) : $($ignored.Output)"
|
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 {
|
} finally {
|
||||||
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
|
Remove-Item -Recurse -Force $dir -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,12 +57,16 @@ Var HolderFound
|
||||||
; whether the payload is free, while the abort dialog reports what survived.
|
; whether the payload is free, while the abort dialog reports what survived.
|
||||||
Var GuardExit
|
Var GuardExit
|
||||||
Var GuardOutput
|
Var GuardOutput
|
||||||
; Whether the guard has already asked Cockatrice to close by itself: 0 before the automatic
|
; How many times the guard has asked the payload programs to close by itself. Bounded, and
|
||||||
; close, 1 after it. The prompt that follows offers to end the instance rather than asking
|
; only repeated while the listing changed between rounds (see GuardAppsNotRunning).
|
||||||
; a second time (see GuardAppsNotRunning).
|
Var GuardCloseRounds
|
||||||
Var GuardCloseTried
|
; The listing the last automatic close was sent against, so a second round is spent only
|
||||||
; The question CockEndPrompt assembles: fixed sentences around the probe's line, and the
|
; when something actually exited and the rest is worth asking again. Empty before round one.
|
||||||
; result has to stay inside the characters one NSIS string holds.
|
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 GuardPrompt
|
||||||
Var WaitPid
|
Var WaitPid
|
||||||
Var LauncherExited
|
Var LauncherExited
|
||||||
|
|
@ -1029,11 +1033,14 @@ FunctionEnd
|
||||||
; Nothing is force-stopped without the user asking for it: Cockatrice may be holding
|
; 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
|
; 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
|
; 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
|
; a game. A close is a request, made automatically because that is what the user started
|
||||||
; the user started the installer to do, and only Cockatrice can answer one - the other
|
; the installer to do: the probe first asks every instance over its local socket, which
|
||||||
; two have no window. When the close is not answered the user is told which program is
|
; reaches a hidden window and an instance sitting on its own confirmation, and falls back
|
||||||
; in the way and is offered to end all three themselves - the last resort, with what it
|
; to WM_CLOSE for an older client. The automatic close is retried while the set of holders
|
||||||
; costs spelled out in the prompt.
|
; 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
|
; 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
|
; 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.
|
; 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."
|
!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
|
; 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
|
; this. Sets $GuardPrompt to the question the guard and the recovery path ask.
|
||||||
; recovery path all ask.
|
|
||||||
;
|
;
|
||||||
; One macro rather than three copies, because the wording is a promise: Yes ends the
|
; 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
|
; 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.
|
; 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
|
; 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
|
; only thing left that can free the files and No stops the run. The guard itself uses
|
||||||
; "try again" - the same question, word for word, with nothing changed in between was what
|
; CockEndRecheckPrompt instead, whose No checks again; this one stays for the recovery path,
|
||||||
; made an abort out of a stall.
|
; 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
|
; 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
|
; 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"
|
; @param cancelWord what No gives up, "installation" or "uninstallation"
|
||||||
!macro CockEndPrompt header listing cancelWord
|
!macro CockEndPrompt header listing cancelWord
|
||||||
StrCpy $GuardPrompt "${header}$\r$\n$\r$\n${listing}"
|
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
|
!macroend
|
||||||
|
|
||||||
; Emitted once for the installer and once for the uninstaller: the uninstaller
|
; 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.
|
; $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
|
; 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
|
; 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
|
; directory was given, 3 = no payload program was named but one refuses to be replaced, or
|
||||||
; runs from the directory either, "timeout" = it did not return in time).
|
; the Restart Manager call failed and no payload program runs from the directory either,
|
||||||
|
; "timeout" = it did not return in time).
|
||||||
Function ${prefix}RunHolderProbe
|
Function ${prefix}RunHolderProbe
|
||||||
Call ${prefix}ExtractHolderProbe
|
Call ${prefix}ExtractHolderProbe
|
||||||
; The directory travels in an environment variable, never on the command
|
; 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
|
; 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.
|
; 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
|
; Each instance is asked to close over the local socket it listens on, which is the same
|
||||||
; only in the notification area, since the request does not need a visible window and the
|
; request as clicking its close button and reaches one whose window is hidden in the
|
||||||
; notification-area instance is the one this update has no other way to reach. A visible
|
; notification area or is sitting on its own confirmation. A client from before that socket
|
||||||
; window is brought forward first. WM_CLOSE is answered, not obeyed - Cockatrice asks before
|
; is asked with WM_CLOSE on its own window instead, and a visible window is brought forward
|
||||||
; it quits while a card database update is running, a game is open or a deck is unsaved - and
|
; first. Either way the close is answered, not obeyed - Cockatrice asks before it quits while
|
||||||
; that question is only useful if it is in front of the installer instead of behind it.
|
; 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
|
; 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
|
; 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.
|
; that has nothing to report stays silent.
|
||||||
Function GuardAppsNotRunning
|
Function GuardAppsNotRunning
|
||||||
!insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)"
|
!insertmacro CockLog "guard: checking $GuardDir (launcherExited=$LauncherExited)"
|
||||||
StrCpy $GuardCloseTried 0
|
StrCpy $GuardCloseRounds 0
|
||||||
|
StrCpy $GuardRecheckCount 0
|
||||||
|
StrCpy $GuardLastHolders ""
|
||||||
guard_check:
|
guard_check:
|
||||||
Call RunHolderProbe
|
Call RunHolderProbe
|
||||||
${If} $GuardExit == "0"
|
${If} $GuardExit == "0"
|
||||||
|
|
@ -1198,26 +1237,40 @@ Function GuardAppsNotRunning
|
||||||
${EndIf}
|
${EndIf}
|
||||||
${If} $GuardExit == "1"
|
${If} $GuardExit == "1"
|
||||||
!insertmacro CockLog "guard: still in use by $GuardOutput"
|
!insertmacro CockLog "guard: still in use by $GuardOutput"
|
||||||
; Close it for the user first. The probe knows exactly which cockatrice.exe holds
|
; Close it for the user first. The probe asks every instance over its local socket
|
||||||
; these files, and asking it to close is the same request as clicking the window's
|
; and falls back to WM_CLOSE for an older client, so this is the same request as
|
||||||
; close button - the application still answers its own "really quit?" prompt, so
|
; clicking the window's close button: the application still answers its own "really
|
||||||
; nothing is lost. One attempt per guard call keeps the common case (the client was
|
; quit?" prompt, so nothing is lost. A second round is spent only when the listing
|
||||||
; still shutting down) from turning into a question, without hiding the case where
|
; changed, which means something exited and the rest is worth asking again; repeating
|
||||||
; the close did not help: the pass after it names what is still holding the files.
|
; the same request against the same processes cannot change anything.
|
||||||
${If} $GuardCloseTried = 0
|
${If} $GuardCloseRounds < 2
|
||||||
StrCpy $GuardCloseTried 1
|
${AndIf} $GuardOutput != $GuardLastHolders
|
||||||
!insertmacro CockLog "guard: asking cockatrice to close, then checking again"
|
StrCpy $GuardLastHolders $GuardOutput
|
||||||
|
IntOp $GuardCloseRounds $GuardCloseRounds + 1
|
||||||
|
!insertmacro CockLog "guard: asking the payload programs to close (round $GuardCloseRounds)"
|
||||||
Call CloseHolders
|
Call CloseHolders
|
||||||
Goto guard_check
|
Goto guard_check
|
||||||
${EndIf}
|
${EndIf}
|
||||||
DetailPrint "The install directory is in use by: $GuardOutput"
|
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
|
; The close ran and did not free the files. Force-closing them is what the installer
|
||||||
; can - and that is the one question, asked once. Its other answer stops the run
|
; can do (Yes); closing them by hand is what the user can do (No checks again, with
|
||||||
; instead of asking again: a second identical dialog, with nothing changed in between,
|
; the fresh listing); Cancel stops the run and the abort dialog offers the same
|
||||||
; is what made users cancel an update the installer could have finished by itself.
|
; update from the official download page. Without the middle answer a user who did
|
||||||
!insertmacro CockEndPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "installation"
|
; not want anything killed had no way forward but to abandon the update.
|
||||||
MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES guard_end
|
guard_ask:
|
||||||
!insertmacro CockLog "guard: cancelled by user"
|
!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 \
|
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."
|
"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;
|
; RemoveOldPayloadLeftovers runs with the pre-uninstall backup already taken;
|
||||||
|
|
@ -1249,25 +1302,27 @@ Function GuardAppsNotRunning
|
||||||
Call AbortInstall
|
Call AbortInstall
|
||||||
${EndIf}
|
${EndIf}
|
||||||
; Not an answer at all: the probe was not configured, the Restart Manager
|
; 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
|
; call failed and no payload program runs from the directory, a payload
|
||||||
; out. Carrying on from that is a decision, so it is asked for rather than
|
; program refuses to be replaced but no lookup could name it (it may run
|
||||||
; assumed - the stale-runtime check after the purge still stands between a
|
; under another account), or the probe timed out. Carrying on from that is a
|
||||||
; failed guard and a mismatched install, but it only fires after files have
|
; decision, so it is asked for rather than assumed - the stale-runtime check
|
||||||
; been replaced, and on an upgrade the launcher asked for nobody is watching
|
; after the purge still stands between a failed guard and a mismatched
|
||||||
; the installer's exit code. Retry asks the probe again (a Restart Manager
|
; install, but it only fires after files have been replaced, and on an
|
||||||
; that was busy answers the second time), Ignore installs anyway, Abort
|
; upgrade the launcher asked for nobody is watching the installer's exit
|
||||||
; cancels instead of writing a payload next to files the guard could not
|
; code. Retry asks the probe again (a Restart Manager that was busy answers
|
||||||
; rule out.
|
; 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"
|
!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 \
|
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
|
IDRETRY guard_check IDABORT guard_unknown_abort
|
||||||
!insertmacro CockLog "guard: continuing without an answer from the probe"
|
!insertmacro CockLog "guard: continuing without an answer from the probe"
|
||||||
Return
|
Return
|
||||||
guard_unknown_abort:
|
guard_unknown_abort:
|
||||||
StrCpy $AbortReason \
|
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
|
; Same reason as the cancel path above: this abort can be reached from the
|
||||||
; leftover-removal guard, after the backup was taken.
|
; leftover-removal guard, after the backup was taken.
|
||||||
Call RestoreUserDataAfterUninstall
|
Call RestoreUserDataAfterUninstall
|
||||||
|
|
@ -1292,7 +1347,18 @@ FunctionEnd
|
||||||
Function AbortInstall
|
Function AbortInstall
|
||||||
!insertmacro CockLog "ABORT: $AbortReason"
|
!insertmacro CockLog "ABORT: $AbortReason"
|
||||||
!insertmacro CockLog "full installer log: $InstallLogPath"
|
!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.
|
; 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
|
; 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
|
; 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
|
; 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
|
; 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
|
; 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
|
; is a handle taken in kernel mode, where there is no process to name; a payload executable
|
||||||
; codes logged above are the whole story, and the probe says so by naming nothing rather
|
; among the files then still refuses to be replaced, and the probe answers with that file
|
||||||
; than by failing.
|
; instead of naming nothing.
|
||||||
;
|
;
|
||||||
; $INSTDIR travels through the same COCKATRICE_GUARD_DIR environment variable the guard
|
; $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.
|
; 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"
|
${ElseIf} $HolderExit == "0"
|
||||||
StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory"
|
StrCpy $HolderOutput "nothing is holding them and no payload program is running from the directory"
|
||||||
${Else}
|
${Else}
|
||||||
StrCpy $HolderOutput "the holder probe could not run: $HolderOutput"
|
StrCpy $HolderOutput "the holder probe could not name what holds them: $HolderOutput"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
!insertmacro CockLog "holders: $HolderOutput"
|
!insertmacro CockLog "holders: $HolderOutput"
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
@ -1992,7 +2058,9 @@ Function un.EnsureAppsNotRunning
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
||||||
Function un.GuardAppsNotRunning
|
Function un.GuardAppsNotRunning
|
||||||
StrCpy $GuardCloseTried 0
|
StrCpy $GuardCloseRounds 0
|
||||||
|
StrCpy $GuardRecheckCount 0
|
||||||
|
StrCpy $GuardLastHolders ""
|
||||||
un_guard_check:
|
un_guard_check:
|
||||||
!insertmacro CockLogUn "uninst-guard: checking $GuardDir"
|
!insertmacro CockLogUn "uninst-guard: checking $GuardDir"
|
||||||
Call un.RunHolderProbe
|
Call un.RunHolderProbe
|
||||||
|
|
@ -2002,24 +2070,36 @@ Function un.GuardAppsNotRunning
|
||||||
${EndIf}
|
${EndIf}
|
||||||
${If} $GuardExit == "1"
|
${If} $GuardExit == "1"
|
||||||
!insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput"
|
!insertmacro CockLogUn "uninst-guard: still in use by $GuardOutput"
|
||||||
; Same convenience as in the installer: ask Cockatrice to close once, then report
|
; Same convenience as in the installer: ask every instance to close, repeat only
|
||||||
; what the close could not reach.
|
; while the set changed, then report what the close could not reach.
|
||||||
${If} $GuardCloseTried = 0
|
${If} $GuardCloseRounds < 2
|
||||||
StrCpy $GuardCloseTried 1
|
${AndIf} $GuardOutput != $GuardLastHolders
|
||||||
!insertmacro CockLogUn "uninst-guard: asking cockatrice to close, then checking again"
|
StrCpy $GuardLastHolders $GuardOutput
|
||||||
|
IntOp $GuardCloseRounds $GuardCloseRounds + 1
|
||||||
|
!insertmacro CockLogUn "uninst-guard: asking the payload programs to close (round $GuardCloseRounds)"
|
||||||
Call un.CloseHolders
|
Call un.CloseHolders
|
||||||
Goto un_guard_check
|
Goto un_guard_check
|
||||||
${EndIf}
|
${EndIf}
|
||||||
DetailPrint "The install directory is in use by: $GuardOutput"
|
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
|
; Same question and the same three answers as the installer's guard: force close,
|
||||||
; programs the payload installs, anything else stops the uninstallation. Asking again
|
; close by hand and check again, or abort. CockEndRecheckPrompt words it, so the two
|
||||||
; after a No only repeated the dialog. CockEndPrompt words it, so this copy and the
|
; copies promise the same thing.
|
||||||
; installer's promise the same thing.
|
!insertmacro CockEndRecheckPrompt "Cockatrice, Oracle or Servatrice is still using files of the installation:" $GuardOutput "uninstallation"
|
||||||
!insertmacro CockEndPrompt "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
|
||||||
MessageBox MB_YESNO|MB_ICONEXCLAMATION|MB_DEFBUTTON2 "$GuardPrompt" IDYES un_guard_end
|
!insertmacro CockLogUn "uninst-guard: cancelled by user after $GuardRecheckCount recheck(s)"
|
||||||
!insertmacro CockLogUn "uninst-guard: cancelled by user"
|
; 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
|
SetErrorLevel 1
|
||||||
Abort
|
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.
|
; Only reached through Yes: Abort does not return.
|
||||||
un_guard_end:
|
un_guard_end:
|
||||||
!insertmacro CockLogUn "uninst-guard: ending the payload programs on request, then checking again"
|
!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
|
; 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
|
; 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
|
; 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"
|
!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 \
|
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
|
IDRETRY un_guard_check IDABORT un_guard_unknown_abort
|
||||||
!insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe"
|
!insertmacro CockLogUn "uninst-guard: continuing without an answer from the probe"
|
||||||
Return
|
Return
|
||||||
|
|
|
||||||
|
|
@ -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_tag_filter_widget.cpp
|
||||||
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
|
src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp
|
||||||
src/interface/window_main.cpp
|
src/interface/window_main.cpp
|
||||||
|
src/installer_close_server.cpp
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/single_instance_manager.cpp
|
src/single_instance_manager.cpp
|
||||||
src/interface/widgets/tabs/abstract_tab_deck_editor.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/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.cpp
|
||||||
src/interface/widgets/utility/compact_push_button.h
|
src/interface/widgets/utility/compact_push_button.h
|
||||||
|
src/installer_close_server.h
|
||||||
src/single_instance_manager.h
|
src/single_instance_manager.h
|
||||||
src/client/url_scheme_event_filter.h
|
src/client/url_scheme_event_filter.h
|
||||||
src/interface/intents/intent_connect_to_server.cpp
|
src/interface/intents/intent_connect_to_server.cpp
|
||||||
|
|
|
||||||
81
cockatrice/src/installer_close_server.cpp
Normal file
81
cockatrice/src/installer_close_server.cpp
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#include "installer_close_server.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QLocalServer>
|
||||||
|
#include <QLocalSocket>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <libcockatrice/utility/installer_close_protocol.h>
|
||||||
|
#include <libcockatrice/utility/local_server_name.h>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
50
cockatrice/src/installer_close_server.h
Normal file
50
cockatrice/src/installer_close_server.h
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#ifndef COCKATRICE_INSTALLER_CLOSE_SERVER_H
|
||||||
|
#define COCKATRICE_INSTALLER_CLOSE_SERVER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -986,6 +986,30 @@ bool MainWindow::closeForUpdate()
|
||||||
return close();
|
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)
|
void MainWindow::changeEvent(QEvent *event)
|
||||||
{
|
{
|
||||||
if (event->type() == QEvent::LanguageChange) {
|
if (event->type() == QEvent::LanguageChange) {
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,17 @@ public:
|
||||||
*/
|
*/
|
||||||
bool closeForUpdate();
|
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:
|
protected:
|
||||||
void closeEvent(QCloseEvent *event) override;
|
void closeEvent(QCloseEvent *event) override;
|
||||||
void changeEvent(QEvent *event) override;
|
void changeEvent(QEvent *event) override;
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
#include "client/sound_engine.h"
|
#include "client/sound_engine.h"
|
||||||
#include "client/url_scheme_event_filter.h"
|
#include "client/url_scheme_event_filter.h"
|
||||||
#include "database/interface/settings_card_preference_provider.h"
|
#include "database/interface/settings_card_preference_provider.h"
|
||||||
|
#include "installer_close_server.h"
|
||||||
#include "interface/intents/intent.h"
|
#include "interface/intents/intent.h"
|
||||||
#include "interface/intents/intent_open_local_deck.h"
|
#include "interface/intents/intent_open_local_deck.h"
|
||||||
#include "interface/logger.h"
|
#include "interface/logger.h"
|
||||||
|
|
@ -431,6 +432,15 @@ int main(int argc, char *argv[])
|
||||||
|
|
||||||
MainWindow ui;
|
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
|
// 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
|
// 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
|
// auto-connect must not race against it (two connectToServer calls tear
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ set(UTILITY_HEADERS
|
||||||
libcockatrice/utility/cryptoutil.h
|
libcockatrice/utility/cryptoutil.h
|
||||||
libcockatrice/utility/debouncer.h
|
libcockatrice/utility/debouncer.h
|
||||||
libcockatrice/utility/expression.h
|
libcockatrice/utility/expression.h
|
||||||
|
libcockatrice/utility/installer_close_protocol.h
|
||||||
libcockatrice/utility/levenshtein.h
|
libcockatrice/utility/levenshtein.h
|
||||||
libcockatrice/utility/local_server_name.h
|
libcockatrice/utility/local_server_name.h
|
||||||
libcockatrice/utility/macros.h
|
libcockatrice/utility/macros.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
|
||||||
|
|
@ -445,6 +445,22 @@ if(WITH_CLIENT)
|
||||||
${CMAKE_SOURCE_DIR}/cockatrice/src
|
${CMAKE_SOURCE_DIR}/cockatrice/src
|
||||||
${CMAKE_SOURCE_DIR}/libcockatrice_utility
|
${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
|
# 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.
|
# no-op path (no sentry-native headers needed) regardless of WITH_CRASH_REPORTING.
|
||||||
cockatrice_add_test(
|
cockatrice_add_test(
|
||||||
|
|
|
||||||
101
tests/installer_close_server_test.cpp
Normal file
101
tests/installer_close_server_test.cpp
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
#include "installer_close_name.h"
|
||||||
|
#include "installer_close_server.h"
|
||||||
|
|
||||||
|
#include <QByteArray>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QLocalSocket>
|
||||||
|
#include <QString>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QtGlobal>
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
#include <libcockatrice/utility/installer_close_protocol.h>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
@ -6,11 +6,23 @@
|
||||||
# it links nothing from this project: no Qt, and a static C runtime because the installed
|
# 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.
|
# runtime is exactly what the purge may have failed to replace.
|
||||||
add_executable(
|
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)
|
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
|
# 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.
|
# NSIS.definitions.nsh, and that path points at the build root.
|
||||||
set_target_properties(
|
set_target_properties(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "close_holders.h"
|
#include "close_holders.h"
|
||||||
|
|
||||||
|
#include "installer_close_request.h"
|
||||||
#include "payload_paths.h"
|
#include "payload_paths.h"
|
||||||
#include "restart_manager_holders.h"
|
#include "restart_manager_holders.h"
|
||||||
|
|
||||||
|
|
@ -248,19 +249,21 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
|
||||||
search.processId = holder.processId;
|
search.processId = holder.processId;
|
||||||
EnumWindows(collectProcessWindow, reinterpret_cast<LPARAM>(&search));
|
EnumWindows(collectProcessWindow, reinterpret_cast<LPARAM>(&search));
|
||||||
CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows);
|
CloseTarget target = chooseCloseTarget(search.windows, askHiddenWindows);
|
||||||
if (target.main == nullptr) {
|
if (target.main == nullptr && !askHiddenWindows) {
|
||||||
// Nothing of the application's own to post a close to: every window left is one of
|
// A launcher with no window on screen has already answered its own close and is
|
||||||
// Qt's bookkeeping windows, or the only thing on screen is a question whose owner
|
// stuck in shutdown (see the header): asking it again cannot reach a user who is
|
||||||
// window is gone. Neither is a close the application would answer - a close to the
|
// not there, so it is named and the installer's wait ends it.
|
||||||
// notification-area window terminates without the confirmation - so the instance is
|
|
||||||
// named and the guard's end answer is what can free its files.
|
|
||||||
++result.withoutWindow;
|
++result.withoutWindow;
|
||||||
lines.push_back(processLabel(holder) + L" has no window of its own to close");
|
lines.push_back(processLabel(holder) + L" has no window of its own to close");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::wstring line = processLabel(holder);
|
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";
|
line += L" is hidden in the notification area";
|
||||||
} else {
|
} else {
|
||||||
if (target.toRaise == nullptr) {
|
if (target.toRaise == nullptr) {
|
||||||
|
|
@ -269,19 +272,40 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
|
||||||
line += bringForward(target.toRaise) ? L" brought forward" : L" could not be brought forward";
|
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
|
// Kept open across the wait so a pid reused after the process exits cannot be
|
||||||
// mistaken for the instance that was asked to close.
|
// mistaken for the instance that was asked to close.
|
||||||
const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId);
|
const HANDLE process = OpenProcess(SYNCHRONIZE, FALSE, holder.processId);
|
||||||
const DWORD openError = process == nullptr ? GetLastError() : ERROR_SUCCESS;
|
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.
|
// Read the error before CloseHandle, which is free to reset it.
|
||||||
const DWORD postError = GetLastError();
|
const DWORD postError = GetLastError();
|
||||||
if (process != nullptr) {
|
if (process != nullptr) {
|
||||||
|
|
@ -290,8 +314,9 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
|
||||||
lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) +
|
lines.push_back(line + L", and the close request could not be sent (win32 " + std::to_wstring(postError) +
|
||||||
L")");
|
L")");
|
||||||
continue;
|
continue;
|
||||||
}
|
} else {
|
||||||
line += L" and asked to close";
|
line += L" and asked to close";
|
||||||
|
}
|
||||||
++result.askedToClose;
|
++result.askedToClose;
|
||||||
if (process == nullptr) {
|
if (process == nullptr) {
|
||||||
line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")";
|
line += L", but its exit could not be watched (win32 " + std::to_wstring(openError) + L")";
|
||||||
|
|
@ -311,6 +336,10 @@ closeCockatriceHolders(const std::vector<Holder> &holders, unsigned long waitMil
|
||||||
const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0;
|
const bool exited = allExited || WaitForSingleObject(asked[index], 0) == WAIT_OBJECT_0;
|
||||||
if (exited) {
|
if (exited) {
|
||||||
lines[askedLines[index]] += L", exited after " + std::to_wstring(elapsed) + L" ms";
|
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 {
|
} else {
|
||||||
lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" +
|
lines[askedLines[index]] += L", still running after " + std::to_wstring(elapsed) + L" ms" +
|
||||||
describeWindowState(askedWindows[index], askedWasHidden[index]);
|
describeWindowState(askedWindows[index], askedWasHidden[index]);
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
*/
|
*/
|
||||||
struct CloseHoldersResult
|
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
|
unsigned int withoutWindow = 0; ///< holders with no window of their own to ask
|
||||||
std::wstring report; ///< one line, narrowed to ASCII when written
|
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
|
* asked to close. Oracle and Servatrice are left to the user - Oracle writes the card
|
||||||
* database in place - and so is every service.
|
* 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
|
* One thing happens per instance: it is asked to close over the local socket it listens on,
|
||||||
* request as clicking that window's close button - whether it is on screen or only in the
|
* which is the same request as clicking its close button and is the only way to reach one
|
||||||
* notification area, since the request does not need the window to be visible and the
|
* whose window is hidden in the notification area or is sitting on its own confirmation. A
|
||||||
* confirmation Cockatrice answers it with is a window Qt shows itself, painted, unlike one
|
* client from before that socket is asked with WM_CLOSE on its own window instead, whether
|
||||||
* restored from outside. A window that is on screen is brought forward first, because that
|
* the window is on screen or only in the notification area, since the request does not need
|
||||||
* confirmation would otherwise open behind the installer's prompt; nothing is shown from
|
* the window to be visible and the confirmation Cockatrice answers it with is a window Qt
|
||||||
* here. Qt's own bookkeeping windows are never the target - posting a close to the
|
* shows itself, painted, unlike one restored from outside. A window that is on screen is
|
||||||
* notification-area one terminates the instance without its confirmation - so a process
|
* brought forward first, because that confirmation would otherwise open behind the
|
||||||
* without a window of its own is named and left to the guard, which offers to end it.
|
* installer's prompt; nothing is shown from here. Qt's own bookkeeping windows are never the
|
||||||
* Stopping the process is never attempted here.
|
* 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
|
* 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
|
* exit, so the caller can measure the purge immediately afterwards instead of sleeping
|
||||||
|
|
|
||||||
12
tools/installer-holder-probe/installer_close_name.cpp
Normal file
12
tools/installer-holder-probe/installer_close_name.cpp
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
#include "installer_close_name.h"
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName)
|
||||||
|
{
|
||||||
|
std::wstring name = L"CockatriceInstallerClose-";
|
||||||
|
name += std::to_wstring(processId);
|
||||||
|
name += L"-";
|
||||||
|
name += userName;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
24
tools/installer-holder-probe/installer_close_name.h
Normal file
24
tools/installer-holder-probe/installer_close_name.h
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
#ifndef INSTALLER_CLOSE_NAME_H
|
||||||
|
#define INSTALLER_CLOSE_NAME_H
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The local-socket name a running instance listens on for the installer's close
|
||||||
|
* request.
|
||||||
|
*
|
||||||
|
* The name carries the process id, so every instance of the payload has its own socket - a
|
||||||
|
* second Cockatrice that was started independently, an Oracle and a Servatrice - and the
|
||||||
|
* installer can ask each one instead of guessing which is the primary. It also carries the
|
||||||
|
* account name, so one user's installer cannot reach another user's session.
|
||||||
|
*
|
||||||
|
* This is the same string the client builds with scopedLocalServerName() in
|
||||||
|
* libcockatrice_utility/local_server_name.h: `CockatriceInstallerClose-<pid>-<user>`. It is
|
||||||
|
* plain C++ so the probe and the client can be tested against each other off Windows.
|
||||||
|
*
|
||||||
|
* @param processId the id of the process that listens on the socket
|
||||||
|
* @param userName the account name scopedLocalServerName() appends
|
||||||
|
*/
|
||||||
|
std::wstring installerCloseSocketName(unsigned long processId, const std::wstring &userName);
|
||||||
|
|
||||||
|
#endif // INSTALLER_CLOSE_NAME_H
|
||||||
96
tools/installer-holder-probe/installer_close_request.cpp
Normal file
96
tools/installer-holder-probe/installer_close_request.cpp
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
#include "installer_close_request.h"
|
||||||
|
|
||||||
|
#include "installer_close_name.h"
|
||||||
|
|
||||||
|
// clang-format off
|
||||||
|
// windows.h has to come before anything that pulls in a Windows type, as everywhere in
|
||||||
|
// this tool.
|
||||||
|
#include <windows.h>
|
||||||
|
// clang-format on
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <libcockatrice/utility/installer_close_protocol.h>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/// How long a busy pipe may take before this gives up. The client answers in milliseconds;
|
||||||
|
/// the wait only covers a machine under load.
|
||||||
|
constexpr DWORD PIPE_WAIT_MILLISECONDS = 1000;
|
||||||
|
|
||||||
|
std::wstring environmentVariable(const wchar_t *name)
|
||||||
|
{
|
||||||
|
for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) {
|
||||||
|
std::vector<wchar_t> buffer(size);
|
||||||
|
const DWORD length = GetEnvironmentVariableW(name, buffer.data(), size);
|
||||||
|
if (length == 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
if (length < size) {
|
||||||
|
return std::wstring(buffer.data(), length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The account name scopedLocalServerName() appends: USER, then USERNAME, then the last
|
||||||
|
/// component of the profile path - the same order and the same fallbacks the client uses.
|
||||||
|
std::wstring currentUserName()
|
||||||
|
{
|
||||||
|
for (const wchar_t *name : {L"USER", L"USERNAME"}) {
|
||||||
|
const std::wstring value = environmentVariable(name);
|
||||||
|
if (!value.empty()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::wstring profile = environmentVariable(L"USERPROFILE");
|
||||||
|
const std::size_t separator = profile.find_last_of(L"\\/");
|
||||||
|
if (separator == std::wstring::npos) {
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
return profile.substr(separator + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The named pipe QLocalServer creates for the client's socket, or empty when the account
|
||||||
|
/// name could not be read and there is nothing to connect to.
|
||||||
|
std::wstring pipePath(unsigned long processId)
|
||||||
|
{
|
||||||
|
const std::wstring userName = currentUserName();
|
||||||
|
if (userName.empty()) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return L"\\\\.\\pipe\\" + installerCloseSocketName(processId, userName);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool requestInstallerClose(unsigned long processId)
|
||||||
|
{
|
||||||
|
const std::wstring path = pipePath(processId);
|
||||||
|
if (path.empty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A client that is still serving the previous connection answers ERROR_PIPE_BUSY; one
|
||||||
|
// retry covers that, while a client that is not listening at all (an older version, or
|
||||||
|
// one that has already exited) is answered with a plain failure and the caller falls
|
||||||
|
// back to WM_CLOSE.
|
||||||
|
for (int attempt = 0; attempt < 2; ++attempt) {
|
||||||
|
const HANDLE pipe = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, 0, nullptr);
|
||||||
|
if (pipe == INVALID_HANDLE_VALUE) {
|
||||||
|
if (GetLastError() == ERROR_PIPE_BUSY && WaitNamedPipeW(path.c_str(), PIPE_WAIT_MILLISECONDS) != FALSE) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
DWORD written = 0;
|
||||||
|
const BOOL ok =
|
||||||
|
WriteFile(pipe, INSTALLER_CLOSE_COMMAND, sizeof(INSTALLER_CLOSE_COMMAND) - 1, &written, nullptr);
|
||||||
|
CloseHandle(pipe);
|
||||||
|
// The request is in the pipe buffer, so the client reads it even though this handle
|
||||||
|
// is closed immediately. No acknowledgment is waited for: the caller only needs to
|
||||||
|
// know whether there was a listening instance to ask, and a hung client must not
|
||||||
|
// stall the guard.
|
||||||
|
return ok != FALSE && written == sizeof(INSTALLER_CLOSE_COMMAND) - 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
21
tools/installer-holder-probe/installer_close_request.h
Normal file
21
tools/installer-holder-probe/installer_close_request.h
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
#ifndef INSTALLER_CLOSE_REQUEST_H
|
||||||
|
#define INSTALLER_CLOSE_REQUEST_H
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Asks one running payload instance to close over its installer socket.
|
||||||
|
*
|
||||||
|
* A 3.1.3 client (and later) listens on a local socket named by
|
||||||
|
* installerCloseSocketName(). Connecting to it and writing one line is the same request as
|
||||||
|
* the window's close button, but it reaches an instance whose window is hidden in the
|
||||||
|
* notification area or is sitting on its own confirmation - the two states WM_CLOSE cannot
|
||||||
|
* get through. Older clients have no socket, so the caller still falls back to WM_CLOSE.
|
||||||
|
*
|
||||||
|
* Best effort: a failed connect or write is not an error. The caller has the window close
|
||||||
|
* and the guard's force-close answer left.
|
||||||
|
*
|
||||||
|
* @param processId the process to ask, as the Restart Manager named it
|
||||||
|
* @return true when the request was written to a listening instance
|
||||||
|
*/
|
||||||
|
bool requestInstallerClose(unsigned long processId);
|
||||||
|
|
||||||
|
#endif // INSTALLER_CLOSE_REQUEST_H
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
#include "close_holders.h"
|
#include "close_holders.h"
|
||||||
#include "holder_line.h"
|
#include "holder_line.h"
|
||||||
|
#include "payload_locks.h"
|
||||||
|
#include "payload_paths.h"
|
||||||
#include "restart_manager_holders.h"
|
#include "restart_manager_holders.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
@ -33,11 +35,12 @@ constexpr unsigned long END_WAIT_MILLISECONDS = 5000;
|
||||||
/// Exit codes the installer reads. "Something was found" is deliberately not 0, so an
|
/// 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.
|
/// 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
|
/// 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
|
/// to the user, which is what the report line next to the code spells out. 3 is for an
|
||||||
/// a lookup that failed and named nobody: one that failed but still named a payload program
|
/// answer that could not name what it found: a lookup that failed and named nobody, or the
|
||||||
/// running from the directory is answered with that process. 4 is only the launcher mode:
|
/// lookups naming nobody while a payload program refuses to be replaced, which is proof one
|
||||||
/// the process the installer waits on has no window a user could answer, which is the one
|
/// runs from the directory. 4 is only the launcher mode: the process the installer waits on
|
||||||
/// answer the installer acts on by itself instead of asking the user.
|
/// 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
|
enum ExitCode
|
||||||
{
|
{
|
||||||
EXIT_NO_HOLDERS = 0,
|
EXIT_NO_HOLDERS = 0,
|
||||||
|
|
@ -60,12 +63,15 @@ enum ExitCode
|
||||||
/// whether there was a window to ask at all, because an instance with
|
/// 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
|
/// 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
|
/// The guard's three modes answer from three questions that cover each other's blind spots:
|
||||||
/// Restart Manager names whoever has a payload file open, and every payload program running
|
/// 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
|
/// 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
|
/// 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
|
/// not always report those, and a payload executable that refuses to be written is running
|
||||||
/// file handles do not name is exactly the case that left a half-replaced install behind.
|
/// 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
|
/// 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.
|
/// instance of this payload, and the guard could neither close nor end it.
|
||||||
enum class Mode
|
enum class Mode
|
||||||
|
|
@ -109,6 +115,24 @@ void writeLine(const std::wstring &text)
|
||||||
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast<DWORD>(narrow.size()), &written, nullptr);
|
WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), narrow.data(), static_cast<DWORD>(narrow.size()), &written, nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The line for a payload executable that is in use while no lookup could name the program
|
||||||
|
/// holding it: the answer the guard shows, so it names the file the user has to free and
|
||||||
|
/// says that the program doing it is still to be closed by hand.
|
||||||
|
std::wstring inUseLine(const std::vector<std::wstring> &files)
|
||||||
|
{
|
||||||
|
std::wstring names;
|
||||||
|
for (const std::wstring &file : files) {
|
||||||
|
if (!names.empty()) {
|
||||||
|
names += L", ";
|
||||||
|
}
|
||||||
|
names += fileNameOf(file);
|
||||||
|
}
|
||||||
|
if (files.size() == 1) {
|
||||||
|
return names + L" is in use and the program holding it could not be identified";
|
||||||
|
}
|
||||||
|
return names + L" are in use and the programs holding them could not be identified";
|
||||||
|
}
|
||||||
|
|
||||||
std::wstring environmentVariable(const wchar_t *name)
|
std::wstring environmentVariable(const wchar_t *name)
|
||||||
{
|
{
|
||||||
for (DWORD size = MAX_PATH; size <= 32768U; size *= 2) {
|
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
|
// Nobody was named. That is not yet the answer that nothing holds the payload: a lookup
|
||||||
// a payload program running from the directory the installer still knows what to close,
|
// that failed only makes the probe unable to answer when it also named nobody, and a
|
||||||
// and that is worth more to the guard than the code of the query that missed it.
|
// payload executable that refuses to be replaced is running even when neither lookup
|
||||||
if (error != ERROR_SUCCESS && holders.empty()) {
|
// could name it.
|
||||||
|
if (holders.empty()) {
|
||||||
|
// Neither process lookup is proof that the payload is free: the Restart Manager does
|
||||||
|
// not report every mapping, and a program running under another account cannot be
|
||||||
|
// read from here, so "nobody was named" is not the answer "nothing is running". The
|
||||||
|
// file cannot be missed - a running image refuses the write the replacement needs -
|
||||||
|
// and an answer of "nothing is holding them" over that locked file is what sent
|
||||||
|
// updates into an uninstall that could only fail halfway. Reported as a check that
|
||||||
|
// could not name what it found, so the guard asks the user to close it and retry
|
||||||
|
// instead of replacing a payload that is still running.
|
||||||
|
const std::vector<std::wstring> inUse = payloadExecutablesInUse(installDir);
|
||||||
|
if (!inUse.empty()) {
|
||||||
|
writeLine(inUseLine(inUse));
|
||||||
|
return EXIT_PROBE_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error != ERROR_SUCCESS) {
|
||||||
writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error));
|
writeLine(L"the Restart Manager lookup failed with win32 error " + std::to_wstring(error));
|
||||||
return EXIT_PROBE_FAILED;
|
return EXIT_PROBE_FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (holders.empty()) {
|
|
||||||
// No output in the naming mode: the installer words the "nothing is holding them"
|
// 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,
|
// 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.
|
// because their line is logged verbatim next to what they were asked to do.
|
||||||
|
|
|
||||||
67
tools/installer-holder-probe/payload_locks.cpp
Normal file
67
tools/installer-holder-probe/payload_locks.cpp
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
#include "payload_locks.h"
|
||||||
|
|
||||||
|
#include "payload_paths.h"
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/// How long the second look waits before it re-tests the files that refused the write open.
|
||||||
|
/// Only those are re-tested, so the one answer costs this at most once: a scanner, or a
|
||||||
|
/// program that is only starting, holds a file without write sharing for a moment, and that
|
||||||
|
/// must not turn a free payload into a run the user has to answer.
|
||||||
|
constexpr DWORD RECHECK_DELAY_MILLISECONDS = 300;
|
||||||
|
|
||||||
|
/// Whether @p path refuses an open that asks to write it.
|
||||||
|
///
|
||||||
|
/// Windows opens a program's image, and every module loaded into it, with read and delete
|
||||||
|
/// sharing but not write sharing, so this is refused for as long as the program runs and by
|
||||||
|
/// nothing else that merely reads the file. A read-only file is skipped: it refuses a write
|
||||||
|
/// open for its own reason and would answer yes for a payload nobody is running, and the
|
||||||
|
/// purge clears that attribute before it deletes anything. Any other error is no answer at
|
||||||
|
/// all - access this probe does not have says nothing about whether the file is in use, and
|
||||||
|
/// still answering "in use" would stop installs that could have finished.
|
||||||
|
bool refusesWrite(const std::wstring &path)
|
||||||
|
{
|
||||||
|
const DWORD attributes = GetFileAttributesW(path.c_str());
|
||||||
|
if (attributes == INVALID_FILE_ATTRIBUTES ||
|
||||||
|
(attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_READONLY)) != 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||||
|
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||||
|
if (file == INVALID_HANDLE_VALUE) {
|
||||||
|
return GetLastError() == ERROR_SHARING_VIOLATION;
|
||||||
|
}
|
||||||
|
// Nothing was written: the open is the whole test, and closing the handle leaves the
|
||||||
|
// file exactly as it was.
|
||||||
|
CloseHandle(file);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<std::wstring> payloadExecutablesInUse(const std::wstring &installDir)
|
||||||
|
{
|
||||||
|
std::vector<std::wstring> refused;
|
||||||
|
for (const wchar_t *name : PAYLOAD_PROGRAMS) {
|
||||||
|
const std::wstring path = installDir + L"\\" + name;
|
||||||
|
if (refusesWrite(path)) {
|
||||||
|
refused.push_back(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (refused.empty()) {
|
||||||
|
return refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One re-test of the files that refused, and only those: the answer is shown to a user
|
||||||
|
// who then has to close a program and run the installer again, so it has to be the
|
||||||
|
// settled state rather than a moment in which something happened to hold the file.
|
||||||
|
Sleep(RECHECK_DELAY_MILLISECONDS);
|
||||||
|
std::vector<std::wstring> settled;
|
||||||
|
for (const std::wstring &path : refused) {
|
||||||
|
if (refusesWrite(path)) {
|
||||||
|
settled.push_back(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
36
tools/installer-holder-probe/payload_locks.h
Normal file
36
tools/installer-holder-probe/payload_locks.h
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
#ifndef PAYLOAD_LOCKS_H
|
||||||
|
#define PAYLOAD_LOCKS_H
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The payload executables in @p installDir that refuse to be written, as full paths:
|
||||||
|
* the programs that are running from it right now.
|
||||||
|
*
|
||||||
|
* The guard asks two questions and both can come back empty for a running instance: the
|
||||||
|
* Restart Manager does not reliably report a program's own image or the modules it mapped,
|
||||||
|
* and the image path of a process running under another account cannot be read from here,
|
||||||
|
* so the process is not named either. The file itself is the evidence neither question
|
||||||
|
* depends on: a program's image is opened without write sharing for as long as it runs, so
|
||||||
|
* the write access replacing it needs is refused for exactly that long, while a file that is
|
||||||
|
* merely being read still accepts it.
|
||||||
|
*
|
||||||
|
* An installer that answers "nothing is holding the payload" from two lookups that both
|
||||||
|
* missed is what sent updates into an uninstall that could only fail halfway, leaving the
|
||||||
|
* previous version half removed. This answer is what keeps that from being said: a payload
|
||||||
|
* executable that refuses the write is running, whether or not this process may name it.
|
||||||
|
*
|
||||||
|
* Only the programs the payload installs are tested, not every file the purge removes. A
|
||||||
|
* payload executable refusing the write proves that it is running; a mapped module belongs
|
||||||
|
* to a running executable, which is tested. A DLL or qt.conf on its own can be held by the
|
||||||
|
* program that is asking - the uninstaller runs in place from the directory it empties - and
|
||||||
|
* naming that as a holder is the loop the guard already had.
|
||||||
|
*
|
||||||
|
* @return one full path per executable that still refused after the re-test, in the order
|
||||||
|
* PAYLOAD_PROGRAMS names them; empty when every one of them can be written, or none
|
||||||
|
* is installed.
|
||||||
|
*/
|
||||||
|
std::vector<std::wstring> payloadExecutablesInUse(const std::wstring &installDir);
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
@ -6,10 +6,6 @@
|
||||||
|
|
||||||
namespace
|
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.
|
/// 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
|
/// Only the names above are ever compared against, and all of them are ASCII, so folding
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,18 @@
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief The file names of the programs the payload installs, in the order the guard names
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* The single list behind isPayloadProgram(), so the guard acts on exactly the programs it
|
||||||
|
* names, and so a payload program added later is closed, ended and tested for a lock
|
||||||
|
* without a second list to update. A payload executable that refuses to be written is
|
||||||
|
* running, which is what makes this list the evidence the two process lookups cannot
|
||||||
|
* provide - see payloadExecutablesInUse().
|
||||||
|
*/
|
||||||
|
inline constexpr const wchar_t *PAYLOAD_PROGRAMS[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe"};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief The last name in @p path: what follows its last separator, or @p path itself when
|
* @brief The last name in @p path: what follows its last separator, or @p path itself when
|
||||||
* it has none.
|
* it has none.
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,6 @@ constexpr int MAX_PLUGIN_DEPTH = 4;
|
||||||
/// only keeps a snapshot with a cycle in it from walking forever.
|
/// only keeps a snapshot with a cycle in it from walking forever.
|
||||||
constexpr int MAX_ASKER_LEVELS = 8;
|
constexpr int MAX_ASKER_LEVELS = 8;
|
||||||
|
|
||||||
/// The payload files the installer replaces by name; the DLLs are matched by mask below.
|
|
||||||
constexpr const wchar_t *PAYLOAD_FILES[] = {L"cockatrice.exe", L"oracle.exe", L"servatrice.exe", L"qt.conf"};
|
|
||||||
|
|
||||||
void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector<std::wstring> &files)
|
void collectFilesOf(const std::wstring &directory, int depth, int maxDepth, std::vector<std::wstring> &files)
|
||||||
{
|
{
|
||||||
if (files.size() >= MAX_PROBED_FILES) {
|
if (files.size() >= MAX_PROBED_FILES) {
|
||||||
|
|
@ -129,7 +126,12 @@ Holder holderOf(const RM_PROCESS_INFO &info)
|
||||||
std::vector<std::wstring> collectPayloadFiles(const std::wstring &installDir)
|
std::vector<std::wstring> collectPayloadFiles(const std::wstring &installDir)
|
||||||
{
|
{
|
||||||
std::vector<std::wstring> files;
|
std::vector<std::wstring> files;
|
||||||
for (const wchar_t *name : PAYLOAD_FILES) {
|
// The programs the payload installs, by name, plus the configuration file next to
|
||||||
|
// them: what the purge deletes by name, and therefore what an instance can have
|
||||||
|
// mapped.
|
||||||
|
std::vector<std::wstring> replaced(std::begin(PAYLOAD_PROGRAMS), std::end(PAYLOAD_PROGRAMS));
|
||||||
|
replaced.emplace_back(L"qt.conf");
|
||||||
|
for (const std::wstring &name : replaced) {
|
||||||
const std::wstring path = installDir + L"\\" + name;
|
const std::wstring path = installDir + L"\\" + name;
|
||||||
const DWORD attributes = GetFileAttributesW(path.c_str());
|
const DWORD attributes = GetFileAttributesW(path.c_str());
|
||||||
if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
|
if (attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue