From 0d082ab4d19412079516b281aff76e8c3f70d9ba Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 17:42:30 +0200 Subject: fix(packaging): setup ends a command-line client run from its install A `meshbay-cli chat --follow` left open holds node-runtime's DLLs, so the previous version's uninstaller failed ("Failed to uninstall old application files"), a silent update included, and left the node it had stopped not running. stop-node.ps1 now ends a meshbay-cli.exe run from this install (-InstallDir, setup's $INSTDIR), and no other copy. Found through CIM, not Get-Process: setup runs the 32-bit PowerShell, which reads no path for a 64-bit process, and the first version of the stop matched nothing. The test runs the script in that PowerShell. Checked live: an update over a running follower now ends it, replaces every file and restarts the node. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-client/build/installer.nsh | 6 +-- packages/meshbay-client/build/stop-node.ps1 | 27 +++++++++++- packages/meshbay-node/tests/test_packaging_win.py | 50 ++++++++++++++++++++++- 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/meshbay-client/build/installer.nsh b/packages/meshbay-client/build/installer.nsh index 2337088..ce1e1ea 100644 --- a/packages/meshbay-client/build/installer.nsh +++ b/packages/meshbay-client/build/installer.nsh @@ -95,11 +95,11 @@ Var pid InitPluginsDir File "/oname=$PLUGINSDIR\mb-stop-node.ps1" "${BUILD_RESOURCES_DIR}\stop-node.ps1" mb_stop_node: - DetailPrint "Stopping the MeshBay node..." - nsExec::Exec `"${MB_PWSH}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\mb-stop-node.ps1"` + DetailPrint "Stopping the MeshBay node and command-line client..." + nsExec::Exec `"${MB_PWSH}" -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "$PLUGINSDIR\mb-stop-node.ps1" -InstallDir "$INSTDIR"` Pop $0 ${If} $0 != 0 - MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "The MeshBay node is still running and holds files that setup must replace. Stop it (meshbay-node service stop, or end meshbay-node.exe in Task Manager), then click Retry." /SD IDCANCEL IDRETRY mb_stop_node + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "The MeshBay node or command-line client is still running and holds files that setup must replace. Stop it (meshbay-node service stop, or end meshbay-node.exe and meshbay-cli.exe in Task Manager), then click Retry." /SD IDCANCEL IDRETRY mb_stop_node Quit ${EndIf} !insertmacro IS_POWERSHELL_AVAILABLE diff --git a/packages/meshbay-client/build/stop-node.ps1 b/packages/meshbay-client/build/stop-node.ps1 index cfaf2f8..dcb0ea6 100644 --- a/packages/meshbay-client/build/stop-node.ps1 +++ b/packages/meshbay-client/build/stop-node.ps1 @@ -10,9 +10,34 @@ # 2. Task Scheduler for a background-service node (the owner may end the task; # taskkill from here gets "Access is denied" in its session). # 3. taskkill for anything left in this session. -# Exit 0 once no meshbay-node.exe is left, 1 otherwise. +# +# Before all that, the command-line client (-InstallDir, setup's $INSTDIR): it +# runs from the same node-runtime folder and holds its Python DLLs, so one left +# open -- a `chat --follow`, a `stream` -- made the previous version's +# uninstaller fail ("Failed to uninstall old application files"), silent +# updates included. It is ended like the app is; only copies of this install. +# Exit 0 once neither is left, 1 otherwise. +param([string]$InstallDir = "") $ErrorActionPreference = "SilentlyContinue" +if ($InstallDir) { + $runtime = Join-Path $InstallDir "resources\node-runtime\" + # Through CIM, not Get-Process: setup runs the 32-bit PowerShell ($SYSDIR is + # SysWOW64 there), which reads no path for a 64-bit process -- the filter + # matched nothing and the update failed as before. + function CliLeft { + @(Get-CimInstance Win32_Process -Filter "Name='meshbay-cli.exe'" ` + -ErrorAction SilentlyContinue | Where-Object { + $_.ExecutablePath -and + $_.ExecutablePath.StartsWith($runtime, [StringComparison]::OrdinalIgnoreCase) + }) + } + CliLeft | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + $deadline = (Get-Date).AddSeconds(10) + while ((CliLeft).Count -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 } + if ((CliLeft).Count) { exit 1 } +} + function NodeLeft { [bool](Get-Process -Name meshbay-node -ErrorAction SilentlyContinue) } function WaitGone([int]$Seconds) { $deadline = (Get-Date).AddSeconds($Seconds) diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index ed8104f..6ee4891 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -285,7 +285,7 @@ def test_the_installer_asks_the_node_to_stop_before_forcing_it(): src = STOP_NODE_PS1.read_text(encoding="utf-8") i_api = src.index("/api/shutdown") i_task = src.index("schtasks /end /tn \"MeshBay Node\"") - i_kill = src.index("Stop-Process -Force") + i_kill = src.index("Stop-Process -Force", src.index("Get-Process -Name meshbay-node ")) assert i_api < i_task < i_kill, ( "graceful through the control API, then Task Scheduler, then taskkill") assert "ui_port" in src and r"data\ui-token" in src @@ -425,6 +425,54 @@ def _dummy_node(tmp_path, name): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +@pytest.mark.skipif(sys.platform != "win32", reason="runs the installer's PowerShell for real") +def test_setup_ends_the_command_line_client_of_this_install_only(tmp_path): + """A `meshbay-cli chat --follow` left open holds node-runtime's DLLs, and the + previous version's uninstaller then failed the update, silent ones too + ("Failed to uninstall old application files", seen live). Setup ends a + client run from its own install, and no other copy of the same name.""" + import shutil + import subprocess + + name = "mbclistoptest" + install = tmp_path / "MeshBay" + runtime = install / "resources" / "node-runtime" + runtime.mkdir(parents=True) + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + procs = [] + for where in (runtime, elsewhere): + exe = where / f"{name}.exe" + shutil.copy(r"C:\Windows\System32\PING.EXE", exe) + procs.append(subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + ours, other = procs + script = (STOP_NODE_PS1.read_text(encoding="utf-8") + .replace('"MeshBay Node"', '"MeshBay Node stop-test"') + .replace("meshbay-node", "mbnodestoptest-none") + .replace("meshbay-cli", name)) + copy = tmp_path / "stop-node-test.ps1" + copy.write_text(script, encoding="utf-8") + # The 32-bit PowerShell, as setup runs it ($SYSDIR is SysWOW64 in a 32-bit + # installer): from there Get-Process reads no path of a 64-bit process, and + # the first version of this stop matched nothing, live. + windir = os.environ.get("SystemRoot", r"C:\Windows") + pwsh = os.path.join(windir, "SysWOW64", "WindowsPowerShell", "v1.0", "powershell.exe") + if not os.path.exists(pwsh): + pwsh = shutil.which("powershell") + try: + r = subprocess.run([pwsh, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + str(copy), "-InstallDir", str(install)], + capture_output=True, text=True, timeout=90) + assert r.returncode == 0, r.stdout + r.stderr + assert ours.wait(timeout=10) is not None + assert other.poll() is None, "a copy outside this install was ended" + finally: + for p in procs: + if p.poll() is None: + p.kill() + + @pytest.mark.skipif(sys.platform != "win32", reason="runs the installer's PowerShell for real") def test_setup_stops_a_node_that_answers_through_its_control_api(tmp_path): """The graceful path: the script finds port and token where the node keeps -- cgit v1.2.3