From 6d06ae2bcf8c33c2d897bc81bdc8d7ce2e710576 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Thu, 17 Sep 2026 20:29:47 +0200 Subject: [PATCH 1/4] [Windows] Close running instances and purge stale runtime DLLs during update --- cmake/NSIS.template.in | 164 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index b3cbcece8..198e91989 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -16,6 +16,7 @@ Var ReinstallMode !include LogicLib.nsh !include FileFunc.nsh !include MUI2.nsh +!include nsExec.nsh !include x64.nsh !define MUI_ABORTWARNING @@ -131,6 +132,13 @@ ${EndIf} ; Now that $PortableMode reflects reality, commit InstDir into the correct slot Call SetModeDestinationFromInstdir +; Make sure no application instance is still running (and holding file locks) +; before the previous version is uninstalled or new files are installed. +; On a silent update (/R /S) running processes are asked to close gracefully +; and waited for, then force-closed only on timeout. On interactive installs +; the user is prompted to close them instead. +Call EnsureAppsNotRunning + ${If} $ReinstallMode = 1 ${AndIf} $PortableMode = 0 Call AutoUninstallIfNeeded @@ -144,6 +152,9 @@ ${If} ${NSIS_IS_64_BIT} == 1 SetRegView 64 ${EndIf} +; Ensure no application instance is still running before removing files. +Call un.EnsureAppsNotRunning + FunctionEnd Function RequireAdmin @@ -199,6 +210,140 @@ ${EndIf} FunctionEnd +; --- Running instance handling --- +; Cockatrice, Oracle and Servatrice must not be running while files are +; replaced or deleted. A still-running process holds locks on its .exe and +; Qt runtime DLLs, so a silent upgrade could otherwise end up with a mix of +; old and new Qt DLLs next to the new executable, failing with +; "The procedure entry point X could not be located in the dynamic link +; library ...Qt6Network.dll" on the next start. + +Function IsAppRunning + ; usage: set $R1 to the image name, call this, result in $R0 (1 = running, 0 = not running) + nsExec::Exec 'cmd /c tasklist /FI "IMAGENAME eq $R1" /FO CSV /NH | findstr /I "$R1" >nul' + Pop $R0 + ${If} $R0 = 0 + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function WaitForAppToClose + ; usage: set $R1 to the image name + Call IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + + ${If} ${Silent} + ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit + DetailPrint "Closing $R1 ..." + nsExec::Exec 'cmd /c taskkill /IM $R1' + Pop $R2 + StrCpy $R8 0 + ck_wait_loop: + Sleep 500 + IntOp $R8 $R8 + 1 + Call IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R8 < 60 + Goto ck_wait_loop + ${EndIf} + ; give up waiting, force close + DetailPrint "Force closing $R1 ..." + nsExec::Exec 'cmd /c taskkill /F /IM $R1' + Pop $R2 + Sleep 500 + ${Else} + ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL ck_abort_install + Call IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + Goto ck_wait_prompt + ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +Function EnsureAppsNotRunning + StrCpy $R1 "cockatrice.exe" + Call WaitForAppToClose + StrCpy $R1 "oracle.exe" + Call WaitForAppToClose + StrCpy $R1 "servatrice.exe" + Call WaitForAppToClose +FunctionEnd + +; Uninstaller copies of the same routines (the uninstaller gets its own +; function set compiled in, it cannot call the installer functions). +Function un.IsAppRunning + nsExec::Exec 'cmd /c tasklist /FI "IMAGENAME eq $R1" /FO CSV /NH | findstr /I "$R1" >nul' + Pop $R0 + ${If} $R0 = 0 + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function un.WaitForAppToClose + Call un.IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + + ${If} ${Silent} + DetailPrint "Closing $R1 ..." + nsExec::Exec 'cmd /c taskkill /IM $R1' + Pop $R2 + StrCpy $R8 0 + un_ck_wait_loop: + Sleep 500 + IntOp $R8 $R8 + 1 + Call un.IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R8 < 60 + Goto un_ck_wait_loop + ${EndIf} + DetailPrint "Force closing $R1 ..." + nsExec::Exec 'cmd /c taskkill /F /IM $R1' + Pop $R2 + Sleep 500 + ${Else} + un_ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL un_ck_abort_install + Call un.IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + Goto un_ck_wait_prompt + un_ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +Function un.EnsureAppsNotRunning + StrCpy $R1 "cockatrice.exe" + Call un.WaitForAppToClose + StrCpy $R1 "oracle.exe" + Call un.WaitForAppToClose + StrCpy $R1 "servatrice.exe" + Call un.WaitForAppToClose +FunctionEnd + Function PortableModePageCreate ${If} $ReinstallMode = 1 @@ -318,6 +463,25 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" RMDir "$INSTDIR" ${EndIf} +; Belt and braces: the old uninstaller may have already run in the /R path, so +; ensure no application instance is still holding file locks, then remove any +; runtime DLLs left over from older versions. A mismatched Qt/OpenSSL set next +; to the new executable is what causes "The procedure entry point X could not be +; located in the dynamic link library ...Qt6Network.dll" after an update. +Call EnsureAppsNotRunning + +${If} $PortableMode = 0 + RMDir /r "$INSTDIR\Plugins" + Delete "$INSTDIR\Qt*.dll" + Delete "$INSTDIR\libcrypto*.dll" + Delete "$INSTDIR\libssl*.dll" + Delete "$INSTDIR\zlib*.dll" + Delete "$INSTDIR\libmysql.dll" + Delete "$INSTDIR\icu*.dll" + Delete "$INSTDIR\libeay32.dll" + Delete "$INSTDIR\ssleay32.dll" +${EndIf} + @CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@ @CPACK_NSIS_FULL_INSTALL@ From 1b7f9c0de43c6dc7a4a29cf910a6a54326ee4649 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 20 Sep 2026 20:59:08 +0200 Subject: [PATCH 2/4] [Windows] Scope running-instance handling to install dir and fix NSIS build - Drop !include nsExec.nsh: nsExec is a plugin DLL, not a header, so makensis aborts before NSIS can build the installer. - Match processes by image name and executable path under $INSTDIR via PowerShell, then close (WM_CLOSE) and force-stop only those PIDs, so an unrelated oracle.exe (Oracle DB) is never killed on a silent /R update. - Gate the stale-runtime-DLL purge on $INSTDIR\cockatrice.exe existing, so a first-time install can't recursively delete an unrelated Plugins directory. --- cmake/NSIS.template.in | 56 ++++++++++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 198e91989..f3f172693 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -16,7 +16,6 @@ Var ReinstallMode !include LogicLib.nsh !include FileFunc.nsh !include MUI2.nsh -!include nsExec.nsh !include x64.nsh !define MUI_ABORTWARNING @@ -217,10 +216,16 @@ FunctionEnd ; old and new Qt DLLs next to the new executable, failing with ; "The procedure entry point X could not be located in the dynamic link ; library ...Qt6Network.dll" on the next start. +; +; Processes are matched by image name AND by their executable path living +; under $INSTDIR, so unrelated processes that merely share an image name +; (e.g. the Oracle DB instance "oracle.exe") are never touched. +; usage: set $R2 to the base image name (without extension, e.g. "cockatrice", +; as accepted by Get-Process -Name), call this, result in $R0 +; (1 = running from $INSTDIR, 0 = not running) Function IsAppRunning - ; usage: set $R1 to the image name, call this, result in $R0 (1 = running, 0 = not running) - nsExec::Exec 'cmd /c tasklist /FI "IMAGENAME eq $R1" /FO CSV /NH | findstr /I "$R1" >nul' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"' Pop $R0 ${If} $R0 = 0 StrCpy $R0 1 @@ -229,8 +234,20 @@ Function IsAppRunning ${EndIf} FunctionEnd +Function CloseMatchingApps + ; gracefully ask every matching instance to close (sends WM_CLOSE) + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function ForceCloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"' + Pop $R3 +FunctionEnd + Function WaitForAppToClose - ; usage: set $R1 to the image name + ; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the + ; base image name (e.g. "cockatrice") Call IsAppRunning ${If} $R0 = 0 Return @@ -239,8 +256,7 @@ Function WaitForAppToClose ${If} ${Silent} ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit DetailPrint "Closing $R1 ..." - nsExec::Exec 'cmd /c taskkill /IM $R1' - Pop $R2 + Call CloseMatchingApps StrCpy $R8 0 ck_wait_loop: Sleep 500 @@ -255,8 +271,7 @@ Function WaitForAppToClose ${EndIf} ; give up waiting, force close DetailPrint "Force closing $R1 ..." - nsExec::Exec 'cmd /c taskkill /F /IM $R1' - Pop $R2 + Call ForceCloseMatchingApps Sleep 500 ${Else} ck_wait_prompt: @@ -275,17 +290,20 @@ FunctionEnd Function EnsureAppsNotRunning StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" Call WaitForAppToClose StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" Call WaitForAppToClose StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" Call WaitForAppToClose FunctionEnd ; Uninstaller copies of the same routines (the uninstaller gets its own ; function set compiled in, it cannot call the installer functions). Function un.IsAppRunning - nsExec::Exec 'cmd /c tasklist /FI "IMAGENAME eq $R1" /FO CSV /NH | findstr /I "$R1" >nul' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"' Pop $R0 ${If} $R0 = 0 StrCpy $R0 1 @@ -294,6 +312,16 @@ Function un.IsAppRunning ${EndIf} FunctionEnd +Function un.CloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function un.ForceCloseMatchingApps + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"' + Pop $R3 +FunctionEnd + Function un.WaitForAppToClose Call un.IsAppRunning ${If} $R0 = 0 @@ -302,8 +330,7 @@ Function un.WaitForAppToClose ${If} ${Silent} DetailPrint "Closing $R1 ..." - nsExec::Exec 'cmd /c taskkill /IM $R1' - Pop $R2 + Call un.CloseMatchingApps StrCpy $R8 0 un_ck_wait_loop: Sleep 500 @@ -317,8 +344,7 @@ Function un.WaitForAppToClose Goto un_ck_wait_loop ${EndIf} DetailPrint "Force closing $R1 ..." - nsExec::Exec 'cmd /c taskkill /F /IM $R1' - Pop $R2 + Call un.ForceCloseMatchingApps Sleep 500 ${Else} un_ck_wait_prompt: @@ -337,10 +363,13 @@ FunctionEnd Function un.EnsureAppsNotRunning StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" Call un.WaitForAppToClose StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" Call un.WaitForAppToClose StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" Call un.WaitForAppToClose FunctionEnd @@ -471,6 +500,7 @@ ${EndIf} Call EnsureAppsNotRunning ${If} $PortableMode = 0 +${AndIf} ${FileExists} "$INSTDIR\cockatrice.exe" RMDir /r "$INSTDIR\Plugins" Delete "$INSTDIR\Qt*.dll" Delete "$INSTDIR\libcrypto*.dll" From de47ddfcae6f5a5298bc97795868eb42cb32d124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Thu, 17 Sep 2026 20:31:13 +0200 Subject: [PATCH 3/4] [Client] Exit deterministically when launching the update installer --- .../src/interface/widgets/dialogs/dlg_update.cpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 7cf58d3e0..b7d6c5296 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -5,11 +5,13 @@ #include "../client/network/update/client/release_channel.h" #include "../interface/window_main.h" +#include #include #include #include #include #include +#include #include #include #include @@ -240,8 +242,14 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) // Try to open the installer. If it opens, quit Cockatrice if (process.startDetached()) { - QMetaObject::invokeMethod(static_cast(parent()), "close", Qt::QueuedConnection); qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice"; + // Close the main window synchronously so settings are saved and file locks are released + // before the NSIS installer (already launched) starts replacing files, then quit the + // application for real in case the close was suppressed (e.g. by a pending prompt). + if (auto *window = qobject_cast(parent())) { + window->close(); + } + QTimer::singleShot(0, qApp, &QCoreApplication::quit); close(); } else { setLabel(tr("Error")); From 97c0de876d9dc5c2b84d51cc91e3826e9096d059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 20 Sep 2026 21:07:43 +0200 Subject: [PATCH 4/4] [Client] Only quit when the main window close is accepted Wait for MainWindow::close() to be accepted before quitting after the update installer is launched. When the close is vetoed (a running card DB update, open games, or an unsaved deck), keep running and tell the user the installer is already waiting, instead of exiting over their answer. Also fix the comment so it does not claim settings are saved on the vetoed path. --- .../interface/widgets/dialogs/dlg_update.cpp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index b7d6c5296..46151481c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -243,13 +243,22 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) // Try to open the installer. If it opens, quit Cockatrice if (process.startDetached()) { qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice"; - // Close the main window synchronously so settings are saved and file locks are released - // before the NSIS installer (already launched) starts replacing files, then quit the - // application for real in case the close was suppressed (e.g. by a pending prompt). + // Close the main window synchronously so file locks are released before the NSIS installer + // (already launched) starts replacing files. This also flushes settings and shuts down the + // tabs, but only when the close is actually accepted: MainWindow may veto it for a running + // card DB update, an open game, or an unsaved deck. In that case keep running so the user + // can resolve the blocker, and tell them the installer is already waiting. if (auto *window = qobject_cast(parent())) { - window->close(); + if (window->close()) { + QTimer::singleShot(0, qApp, &QCoreApplication::quit); + } else { + QMessageBox::warning(this, tr("Update"), + tr("The update installer is already running and will finish the update once " + "Cockatrice closes. Cockatrice is still busy, so it stays open for now.")); + } + } else { + QTimer::singleShot(0, qApp, &QCoreApplication::quit); } - QTimer::singleShot(0, qApp, &QCoreApplication::quit); close(); } else { setLabel(tr("Error"));