From 8c7e39b6dca758badec6867ab6610fd5e8d93d1e Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 27 Sep 2026 22:20:53 +0200 Subject: fix: Windows installer and desktop app start and stop the node one way A 0.16 upgrade in service mode left the previous node running: setup's unelevated taskkill cannot reach session 0, and it ran in customInstall, which electron-builder inserts after the files are copied. The locked exe was not replaced, and the new app talked to the old node ("started but could not link", "No operator paired"). Installer (build/installer.nsh, build/stop-node.ps1): - customCheckAppRunning, which runs before uninstallOldVersion and extraction, stops the node with an embedded stop-node.ps1: control API, then schtasks /end, then Stop-Process, and refuses to half-upgrade if one survives. - An upgrade keeps the mode it finds (task, launcher, previous install), restores the sign-in launcher the old uninstaller deletes, and restarts the node the way that mode runs it. A silent upgrade of an "at sign-in" install used to end with no autostart and no node. - The uninstaller removes the task and firewall rules only on a real uninstall, not on an update. Desktop app (src/main.js): - Start, Stop, Restart and node:start go through the CLI's lifecycle verbs instead of a second implementation; a child spawned by Electron also held Electron's sockets after the app quit. - "Only while MeshBay is open" is a real mode: the app starts a provisioned node at launch and stops the one it started when it quits. - Switching modes stops the node first -- deleting a task does not end its instance, and a new service found the port taken -- keeps the firewall rules every mode needs, and starts the node again. A declined or unanswered UAC prompt restores the node instead of leaving it stopped, and says that nothing changed. - waiting_for_hub counts as a node that is up; linking waits for a node that answers, with a longer deadline, and reports a version mismatch. Packaging (packaging/win): - The service task gets no 72-hour limit, runs on battery and ignores a second start; service.ps1 status reports a stale registration so setup re-registers it; remove ends the running instance before deleting the task. - build-node-runtime.ps1 starts the frozen daemon in a throwaway profile (smoke-node-runtime.ps1) instead of only asking for --help. The mode that was "Off (start manually)" is labelled "Only while MeshBay is open" in all ten catalogues. Co-Authored-By: Claude Opus 5.5 --- packaging/win/build-node-runtime.ps1 | 7 +++ packaging/win/service-mode.ps1 | 37 ++++++++---- packaging/win/service.ps1 | 27 ++++++++- packaging/win/smoke-node-runtime.ps1 | 107 +++++++++++++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 11 deletions(-) create mode 100644 packaging/win/smoke-node-runtime.ps1 (limited to 'packaging/win') diff --git a/packaging/win/build-node-runtime.ps1 b/packaging/win/build-node-runtime.ps1 index 28c4061..371311b 100644 --- a/packaging/win/build-node-runtime.ps1 +++ b/packaging/win/build-node-runtime.ps1 @@ -198,6 +198,13 @@ if ($LASTEXITCODE -ne 0 -or $help -notmatch "meshbay-node") { if ($LASTEXITCODE -ne 0) { throw "frozen meshbay-node --help exited $LASTEXITCODE`n$help" } if ($help -notmatch "meshbay-node") { throw "frozen --help output looks wrong:`n$help" } +# --help imports the parser and nothing else; start the daemon itself. +Step "smoke test: the frozen daemon starts and answers" +$initPy = Join-Path $Repo "packages\meshbay-node\src\meshbay_node\__init__.py" +$expect = [regex]::Match((Get-Content -LiteralPath $initPy -Raw), '__version__\s*=\s*"([^"]+)"').Groups[1].Value +if (-not $expect) { throw "cannot read __version__ from $initPy" } +& (Join-Path $WinDir "smoke-node-runtime.ps1") -Exe $exe -ExpectVersion $expect + $mb = (Get-ChildItem $OutDir -Recurse | Measure-Object Length -Sum).Sum / 1MB Write-Host "" Write-Host ("OK node-runtime ready at {0} ({1:N0} MB)" -f $OutDir, $mb) -ForegroundColor Green diff --git a/packaging/win/service-mode.ps1 b/packaging/win/service-mode.ps1 index 2cb61b4..21794da 100644 --- a/packaging/win/service-mode.ps1 +++ b/packaging/win/service-mode.ps1 @@ -24,31 +24,48 @@ #> [CmdletBinding()] param( - [ValidateSet("install", "remove")] + # install: the boot task + the firewall rules, then start the node. + # remove: the boot task only -- switching to "at sign-in" or "only while + # MeshBay is open" from the Node page. The firewall rules serve + # every mode; removing them here left a node that silently + # accepted no connections (found by switching modes on a real + # install). + # uninstall: the boot task and the firewall rules -- the uninstaller. + [ValidateSet("install", "remove", "uninstall")] [string]$Action = "install" ) $here = $PSScriptRoot $log = Join-Path $env:TEMP "meshbay-firewall.log" -$firewallAction = if ($Action -eq "install") { "add" } else { "remove" } $failed = $false "[{0}] service-mode {1}" -f (Get-Date -Format s), $Action | Add-Content $log +# Elevated, so this reaches a node in any session. The desktop app has already +# asked it to stop properly; this only catches one that did not. Before +# install, a node still running would hold the control API's port and the +# service's own node would quit at once; before remove, deleting the task does +# not end its running instance, which would run on with nothing to stop it. +Get-Process -Name meshbay-node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + +$serviceAction = if ($Action -eq "install") { "install" } else { "remove" } try { - & (Join-Path $here "service.ps1") $Action + & (Join-Path $here "service.ps1") $serviceAction } catch { - " service $Action failed: $_" | Add-Content $log + " service $serviceAction failed: $_" | Add-Content $log $failed = $true } -try { - & (Join-Path $here "firewall.ps1") $firewallAction -} -catch { - " firewall $firewallAction failed: $_" | Add-Content $log - $failed = $true +if ($Action -ne "remove") { + $firewallAction = if ($Action -eq "install") { "add" } else { "remove" } + try { + & (Join-Path $here "firewall.ps1") $firewallAction + } + catch { + " firewall $firewallAction failed: $_" | Add-Content $log + $failed = $true + } } # Register-ScheduledTask with -Trigger AtStartup does exactly that -- it does diff --git a/packaging/win/service.ps1 b/packaging/win/service.ps1 index 1f84a29..a46bb19 100644 --- a/packaging/win/service.ps1 +++ b/packaging/win/service.ps1 @@ -78,15 +78,28 @@ switch ($Action) { $taskAction = New-ScheduledTaskAction -Execute $node $bootTrigger = New-ScheduledTaskTrigger -AtStartup $taskPrincipal = New-ScheduledTaskPrincipal -UserId $user -LogonType S4U -RunLevel Limited + # Task Scheduler's defaults end a task after 72 hours, never start it on + # battery and stop it when the cable comes out. Kept identical to + # meshbay_node.platform.SERVICE_TASK_SETTINGS. + $taskSettings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -MultipleInstances IgnoreNew -StartWhenAvailable Register-ScheduledTask -TaskName $TASK_NAME -Action $taskAction -Trigger $bootTrigger ` - -Principal $taskPrincipal -Force -ErrorAction Stop | Out-Null + -Principal $taskPrincipal -Settings $taskSettings -Force -ErrorAction Stop | Out-Null Write-Host "service: installed ($user, runs at boot)" } "remove" { + # Deleting a task does not end its running instance. + & schtasks /end /tn $TASK_NAME 2>$null | Out-Null & schtasks /delete /tn $TASK_NAME /f 2>$null | Out-Null Write-Host "service: removed" } "status" { + # Exit 0: installed and current. 1: not installed. 2: installed but + # stale -- it runs another executable than this install's (an older + # install dir, a dev venv), or it still has the 72-hour / battery + # defaults. The installer re-registers a stale task; reading all of this + # needs no admin. $out = & schtasks /query /tn $TASK_NAME /fo list 2>$null if ($LASTEXITCODE -ne 0) { Write-Output "NOT_INSTALLED" @@ -94,6 +107,18 @@ switch ($Action) { } $line = $out | Select-String "^Status:" $state = if ($line) { ($line -replace "^Status:\s*", "").Trim() } else { "unknown" } + $task = Get-ScheduledTask -TaskName $TASK_NAME -ErrorAction SilentlyContinue + $stale = $true + if ($task) { + $exe = ([string]$task.Actions[0].Execute).Trim('"') + $s = $task.Settings + $stale = ($exe -ne $node) -or ($s.ExecutionTimeLimit -ne "PT0S") ` + -or $s.DisallowStartIfOnBatteries -or $s.StopIfGoingOnBatteries + } + if ($stale) { + Write-Output "INSTALLED_STALE:$state" + exit 2 + } Write-Output "INSTALLED:$state" exit 0 } diff --git a/packaging/win/smoke-node-runtime.ps1 b/packaging/win/smoke-node-runtime.ps1 new file mode 100644 index 0000000..bfcabac --- /dev/null +++ b/packaging/win/smoke-node-runtime.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Start a frozen meshbay-node as a daemon and check it answers as the version + it claims to be. + +.DESCRIPTION + `meshbay-node --help` imports the parser and nothing else, so it passes for + a bundle that cannot start the daemon at all. This starts the real daemon, + in a throwaway profile (its own LOCALAPPDATA, an unused port, a hub URL + nothing listens on -- it never touches a real hub or the developer's own + node), waits for its control API, and checks: + - /api/status answers, with the expected version; + - the Windows log file was written (a service-mode daemon has no console, + and this file is the only place its errors go). + + Also useful against an installed build: + smoke-node-runtime.ps1 -Exe "$env:LOCALAPPDATA\Programs\MeshBay\resources\node-runtime\meshbay-node.exe" -ExpectVersion 0.16.0 + +.PARAMETER Exe + The meshbay-node.exe to start. + +.PARAMETER ExpectVersion + The version /api/status must report. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string]$Exe, + [Parameter(Mandatory = $true)][string]$ExpectVersion, + [int]$TimeoutSeconds = 45 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$profileDir = Join-Path ([IO.Path]::GetTempPath()) ("meshbay-smoke-" + [guid]::NewGuid().ToString("N")) +$meshbay = Join-Path $profileDir "meshbay" +New-Item -ItemType Directory -Force $meshbay | Out-Null + +# A free loopback port, so a node already running here on 18000 is untouched. +$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) +$listener.Start() +$port = $listener.LocalEndpoint.Port +$listener.Stop() + +$bytes = New-Object byte[] 32 +[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes) +$unlock = Join-Path $meshbay "unlock.key" +Set-Content -Encoding ascii -LiteralPath $unlock ([Convert]::ToBase64String($bytes)) +$unlockToml = $unlock.Replace([char]92, [char]47) +Set-Content -Encoding ascii -LiteralPath (Join-Path $meshbay "node.toml") @" +[hub] +url = "http://127.0.0.1:1" +username = "smoke" + +[node] +quic_enabled = false +ui_port = $port + +[keystore] +unlock_file = "$unlockToml" +"@ + +$oldLocal = $env:LOCALAPPDATA +$env:LOCALAPPDATA = $profileDir +$stderr = Join-Path $profileDir "stderr.txt" +$proc = $null +try { + $proc = Start-Process -FilePath $Exe -PassThru -WindowStyle Hidden ` + -RedirectStandardError $stderr -RedirectStandardOutput (Join-Path $profileDir "stdout.txt") + $env:LOCALAPPDATA = $oldLocal + + $tokenFile = Join-Path $meshbay "data\ui-token" + $status = $null + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ((Get-Date) -lt $deadline -and -not $proc.HasExited) { + if (Test-Path -LiteralPath $tokenFile) { + try { + $token = (Get-Content -LiteralPath $tokenFile -Raw).Trim() + $status = Invoke-RestMethod "http://127.0.0.1:$port/api/status?t=$token" -TimeoutSec 3 + break + } catch { } + } + Start-Sleep -Milliseconds 500 + } + + $tail = if (Test-Path -LiteralPath $stderr) { (Get-Content -LiteralPath $stderr -Tail 30) -join "`n" } else { "" } + if ($proc.HasExited) { + throw "the frozen daemon exited (code $($proc.ExitCode)) before its control API answered:`n$tail" + } + if ($null -eq $status) { + throw "the frozen daemon's control API did not answer within ${TimeoutSeconds}s:`n$tail" + } + if ($status.version -ne $ExpectVersion) { + throw "the frozen daemon reports version '$($status.version)', expected '$ExpectVersion'" + } + $log = Join-Path $meshbay "state\node.log" + if (-not (Test-Path -LiteralPath $log) -or (Get-Item -LiteralPath $log).Length -eq 0) { + throw "the frozen daemon wrote no log file at $log" + } + Write-Host "OK daemon answered: version $($status.version), status $($status.status), log $log" -ForegroundColor Green +} +finally { + $env:LOCALAPPDATA = $oldLocal + if ($proc -and -not $proc.HasExited) { Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue } + if ($proc) { $proc.WaitForExit(10000) | Out-Null } + Remove-Item -LiteralPath $profileDir -Recurse -Force -ErrorAction SilentlyContinue +} -- cgit v1.2.3