diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-27 22:20:53 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-27 22:20:53 +0200 |
| commit | 8c7e39b6dca758badec6867ab6610fd5e8d93d1e (patch) | |
| tree | 1d9eaa2e549484adc7b2343eedf1edd741226414 /packages | |
| parent | 7662484cae8e74b7d9aa383bd6cd0dad4690aadc (diff) | |
| download | meshbay-8c7e39b6dca758badec6867ab6610fd5e8d93d1e.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
14 files changed, 760 insertions, 239 deletions
diff --git a/packages/meshbay-client/build/installer.nsh b/packages/meshbay-client/build/installer.nsh index 3157f22..ef9c82a 100644 --- a/packages/meshbay-client/build/installer.nsh +++ b/packages/meshbay-client/build/installer.nsh @@ -55,7 +55,55 @@ !macroend !macro customInit + ; What this machine already runs, before the previous version's uninstaller + ; deletes the sign-in launcher: an upgrade keeps the mode it finds, instead + ; of defaulting to "background service" -- which a silent upgrade cannot even + ; set up (no elevation), so an "at sign-in" install came out of one with no + ; autostart at all and its node stopped (found upgrading a real install). + ; A fresh install still defaults to the service. StrCpy $MB_AutoMode "2" + nsExec::Exec 'schtasks /query /tn "MeshBay Node"' + Pop $0 + ${If} $0 == 0 + StrCpy $MB_AutoMode "2" + ${ElseIf} ${FileExists} "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs" + StrCpy $MB_AutoMode "1" + ${ElseIf} ${FileExists} "$INSTDIR\${APP_EXECUTABLE_FILENAME}" + StrCpy $MB_AutoMode "0" + ${EndIf} +!macroend + +; ── stop the node before a single file is touched ───────────────────────── +; electron-builder inserts customCheckAppRunning in place of its own +; app-running check, which it runs before uninstallOldVersion and before the +; files are extracted (installSection.nsh); customInstall only runs after both. +; A service-mode daemon lives in the task's S4U logon session, where an +; unelevated taskkill gets "Access is denied". Left running, it keeps +; meshbay-node.exe and its DLLs locked, their copy fails, and electron-builder's +; last-resort extract ignores the failure. build/stop-node.ps1 asks the node to +; stop through its own control API first -- any session, no elevation, a proper +; shutdown -- then Task Scheduler, then taskkill. It is embedded and run from +; the plugins directory: the installed copy of anything may be what is being +; replaced. + +; Defining customCheckAppRunning makes allowOnlyOneInstallerInstance.nsh skip +; these two, which its own _CHECK_APP_RUNNING (inserted below) still needs. +!include "getProcessInfo.nsh" +Var pid + +!macro customCheckAppRunning + 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"` + 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 + Quit + ${EndIf} + !insertmacro IS_POWERSHELL_AVAILABLE + !insertmacro _CHECK_APP_RUNNING !macroend ; ── the autostart choice, as a radio page ───────────────────────────────── @@ -123,9 +171,8 @@ !macroend !macro customInstall - ; resources\node-runtime\meshbay-node.exe is about to be overwritten; a - ; daemon still running from a previous version holds the file open. - nsExec::Exec 'taskkill /IM meshbay-node.exe /F' + ; The node was stopped by customCheckAppRunning, before the files were + ; copied -- by the time this runs they already have been. ; Add the daemon dir to the per-user PATH (HKCU\Environment). WordAdd is a ; stock NSIS macro over a ';'-delimited list -- it is a no-op if the entry is @@ -146,9 +193,12 @@ ${If} $MB_AutoMode == "2" ; Background service: the boot-time Scheduled Task AND the firewall ; rules, in ONE elevation (service-mode.ps1 does both). Skip it only - ; when the task already exists and the rules are already there. + ; when the task is already there and current and so are the rules. A + ; stale task (2: another executable, or the 72-hour / battery defaults + ; of an older setup) is registered again -- the owner cannot change it + ; without elevation. nsExec::Exec '"${MB_PWSH}" -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service.ps1" status' - Pop $R1 ; 0 = task installed + Pop $R1 ; 0 = installed and current, 1 = absent, 2 = stale ${If} $R1 == 0 ${AndIf} $R0 == 0 Goto mb_auto_done @@ -159,28 +209,58 @@ Goto mb_auto_done ${EndIf} - ; Modes 0 and 1: no scheduled task. One elevation for the firewall rules, - ; and only if one is actually missing. + ; Modes 0 and 1. A boot task left from an earlier "background service" + ; choice would start the node a second time, at boot and at sign-in: take + ; it out (service-mode.ps1 remove keeps the firewall rules every mode needs). + nsExec::Exec 'schtasks /query /tn "MeshBay Node"' + Pop $R1 + ${If} $R1 == 0 + ExecShellWait "runas" "${MB_PWSH}" \ + '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service-mode.ps1" -Action remove' \ + SW_HIDE + ${EndIf} + ; One elevation for the firewall rules, and only if one is actually missing. ${If} $R0 != 0 ExecShellWait "runas" "${MB_PWSH}" \ '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\firewall.ps1" add' \ SW_HIDE ${EndIf} - ; Mode 1 also drops the per-user sign-in launcher (no admin -- it is just - ; a .vbs in this account's Startup folder). Idempotent, so a repeat run is - ; harmless. meshbay_node.platform.service_install() removes this itself if - ; the user later switches to service mode from the Node page. - ${If} $MB_AutoMode == "1" - nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart install' - ${EndIf} - mb_auto_done: ${EndIf} + + ; The sign-in launcher as the mode wants it -- silent installs too: during an + ; upgrade the previous version's uninstaller has just deleted it. No admin, + ; idempotent; `autostart install` refuses while a boot task exists. + ${If} $MB_AutoMode == "1" + nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart install' + ${Else} + nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart remove' + ${EndIf} + Pop $R2 + + ; Start the node customCheckAppRunning stopped, the way this mode runs it, + ; rather than leave it down until the next boot or sign-in. Only a node that + ; has been set up: a fresh install's has no account yet, and node:start + ; provisions and starts it. Mode 0 is the app's to start (runAfterFinish). + ${If} ${FileExists} "$LOCALAPPDATA\meshbay\node.toml" + ${If} $MB_AutoMode == "2" + nsExec::Exec 'schtasks /query /tn "MeshBay Node"' + Pop $R2 + ${If} $R2 == 0 + nsExec::Exec 'schtasks /run /tn "MeshBay Node"' + Pop $R2 + ${EndIf} + ${ElseIf} $MB_AutoMode == "1" + nsExec::Exec '"${MB_NODE_BIN}\meshbay-node.exe" autostart start' + Pop $R2 + ${EndIf} + ${EndIf} !macroend !macro customUnInstall - nsExec::Exec 'taskkill /IM meshbay-node.exe /F' + ; The node is already stopped: the uninstaller runs customCheckAppRunning + ; (un.checkAppRunning) before this. ; Take our entry back out of PATH, leaving the rest of it alone. ReadRegStr $0 HKCU "Environment" "Path" @@ -196,17 +276,22 @@ ; default-No; a silent uninstall skips it entirely. customUnInstall runs ; before the files are removed, so service-mode.ps1 is still there. ${IfNot} ${Silent} + ${AndIfNot} ${isUpdated} MessageBox MB_YESNO|MB_ICONQUESTION \ "Remove MeshBay's Windows Firewall rules and its boot-time service task, if you set one up? This needs one administrator confirmation. Both are harmless if left." \ /SD IDNO IDNO mb_keep_privileged ExecShellWait "runas" "${MB_PWSH}" \ - '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service-mode.ps1" -Action remove' \ + '-NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\resources\service-mode.ps1" -Action uninstall' \ SW_HIDE mb_keep_privileged: ${EndIf} ; meshbay_node.platform.autostart_install() -- if the user picked "at sign-in" ; (here, or later in the client), this points wscript at the binary we are - ; about to delete, and would error at every sign-in. - Delete "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs" + ; about to delete, and would error at every sign-in. Not in an upgrade: the + ; new version is about to take this path's place, and deleting the launcher + ; here is what left upgraded "at sign-in" installs with no autostart at all. + ${IfNot} ${isUpdated} + Delete "$APPDATA\Microsoft\Windows\Start Menu\Programs\Startup\MeshBay Node.vbs" + ${EndIf} !macroend diff --git a/packages/meshbay-client/build/stop-node.ps1 b/packages/meshbay-client/build/stop-node.ps1 new file mode 100644 index 0000000..cfaf2f8 --- /dev/null +++ b/packages/meshbay-client/build/stop-node.ps1 @@ -0,0 +1,45 @@ +# Stop every MeshBay node on this machine before setup touches its files, or +# before the uninstaller removes them. Embedded in the installer and run from +# its plugins directory: the installed copy of anything may be the one being +# replaced. +# +# 1. Ask the node through its own control API (/api/shutdown, loopback, per-run +# token): the only stop that reaches a node in any session with no +# elevation, and the one that lets it shut down properly -- WebRTC sessions +# closed, transcodes stopped. +# 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. +$ErrorActionPreference = "SilentlyContinue" + +function NodeLeft { [bool](Get-Process -Name meshbay-node -ErrorAction SilentlyContinue) } +function WaitGone([int]$Seconds) { + $deadline = (Get-Date).AddSeconds($Seconds) + while ((NodeLeft) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 250 } + -not (NodeLeft) +} + +if (-not (NodeLeft)) { exit 0 } + +$cfg = Join-Path $env:LOCALAPPDATA "meshbay" +$port = 18000 +$toml = Join-Path $cfg "node.toml" +if (Test-Path $toml) { + $m = Select-String -Path $toml -Pattern '^\s*ui_port\s*=\s*(\d+)' | Select-Object -First 1 + if ($m) { $port = [int]$m.Matches[0].Groups[1].Value } +} +$tokenFile = Join-Path $cfg "data\ui-token" +if (Test-Path $tokenFile) { + $token = (Get-Content $tokenFile -Raw).Trim() + try { + Invoke-WebRequest -UseBasicParsing -Method Post -TimeoutSec 5 ` + -Uri "http://127.0.0.1:$port/api/shutdown?t=$token" | Out-Null + if (WaitGone 20) { exit 0 } + } catch { } +} + +& schtasks /end /tn "MeshBay Node" 2>&1 | Out-Null +Get-Process -Name meshbay-node -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +if (WaitGone 20) { exit 0 } +exit 1 diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index c263d2c..9116d1a 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -88,6 +88,15 @@ function meshbayDataDir() { return path.join(os.homedir(), '.local', 'share', 'meshbay'); } +// Kept in step with meshbay_node.platform.log_file(). Windows only: elsewhere +// the daemon logs to journald. +function nodeLogHint() { + if (process.platform === 'win32') { + return path.join(process.env.LOCALAPPDATA || os.homedir(), 'meshbay', 'state', 'node.log'); + } + return 'journalctl --user -u meshbay-node'; +} + // The policy, sent as a header on every response. // // Not a <meta> tag: `frame-ancestors` is ignored there — Chromium says so in @@ -542,6 +551,11 @@ let trayTimer = null; // there already do what hiding to an indicator does elsewhere. const trayOS = () => process.platform === 'linux' || process.platform === 'win32'; let nodeService = null; // assigned by registerBridge() +// Whether this app started the node that runs now, outside service mode: in +// the installer's "only while MeshBay is open" mode that is the node it stops +// when it quits. +let nodeStartedByApp = false; +let nodeWithApp = null; // assigned by registerBridge() const TRAY_FALLBACK = { show: 'Show MeshBay', quit: 'Quit', @@ -1251,27 +1265,47 @@ function registerBridge() { try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ } } - // Prefers a graceful stop: `autostart stop` now tries CTRL_BREAK_EVENT - // against the pid autostart_run() recorded first (meshbay_node.platform. - // autostart_end()), which daemon.py's SIGBREAK handler turns into a real - // _shutdown() -- closed WebRTC sessions, killed ffmpeg -- before that same - // function falls back to a hard `taskkill /F` itself. Keeping the - // graceful-then-forceful logic in that one place, rather than this - // function *also* going straight to taskkill, is what actually fixed it: - // two independent hard-kill call sites would still bypass shutdown one of - // the times. Only genuinely falls back to taskkill here when the binary - // cannot even be located. - async function killNodeProcesses() { + // Windows: starting, stopping and restarting the node is the CLI's, and only + // the CLI's (meshbay_node/cli/lifecycle.py) -- one implementation behind + // every front door, the Node page, the tray, node:start and a terminal + // alike. It stops through the node's own control API first (graceful, and + // the only thing that reaches a service node in session 0 without + // elevation), then Task Scheduler, then taskkill; it starts the node with + // nothing of this process inherited (a child of Electron held Electron's + // sockets after the app quit); and it reports what actually answered. + // Two implementations had drifted: this file ended the service with + // schtasks first -- a TerminateProcess -- and "stopped" a node it could not + // reach while saying it had. + async function winNodeCli(args, timeoutMs = 120000) { + // await, not .then: findNodeBinary() returns the bundled path as a plain + // string in a packaged build and a promise otherwise -- `.then` on it made + // every start and stop fail in the installed app, and only there. const bin = await findNodeBinary(); + if (!bin) return { ok: false, out: 'meshbay-node not found' }; return new Promise((resolve) => { - if (bin) { - execFile(bin, ['autostart', 'stop'], () => resolve()); - } else { - execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve()); - } + execFile(bin, args, { windowsHide: true, timeout: timeoutMs }, + (err, stdout, stderr) => resolve({ + ok: !err, out: `${stdout || ''}${stderr || ''}`.trim(), + })); }); } + async function killNodeProcesses() { + const r = await winNodeCli(['autostart', 'stop']); + if (!r.ok) console.error('[node] stop:', r.out); + return r; + } + + // Start (or restart) through the CLI and return what answered, or throw + // with the CLI's own words and where the log is. + async function winNodeStartVia(args) { + const r = await winNodeCli(args); + if (!r.ok) { + throw new Error(`${r.out || 'the node did not start'}\nIts log: ${nodeLogHint()}`); + } + return r.out; + } + // ── Windows: the opt-in Scheduled Task "service mode" ────────────────────── // Set up once, elevated, at install time (build/installer.nsh + packaging/win // /service.ps1 + /service-mode.ps1) or via `meshbay-node service install` @@ -1292,17 +1326,47 @@ function registerBridge() { }); } - function winServiceTaskRun() { - return new Promise((resolve) => { - execFile('schtasks', ['/run', '/tn', WIN_SERVICE_TASK], () => resolve()); - }); + // The installer's three choices, read back from what they leave behind: the + // boot task, the sign-in launcher, or neither -- "only while MeshBay is open". + async function winStartupMode() { + if ((await winServiceTaskStatus()).installed) return 'service'; + if (winAutostartInstalled()) return 'signin'; + return 'open'; } - function winServiceTaskEnd() { - return new Promise((resolve) => { - execFile('schtasks', ['/end', '/tn', WIN_SERVICE_TASK], () => resolve()); - }); + function nodeProvisioned() { + try { + return /^\s*username\s*=\s*"[^"]+"/m.test(fs.readFileSync(nodeConfigPath(), 'utf8')); + } catch { return false; } + } + + // "Only while MeshBay is open" meant nothing: nothing started the node with + // the app, so after a reboot a group stayed offline with MeshBay open until + // someone pressed Start; and nothing stopped it at Quit. Found by testing + // each mode of a real install. A node not yet set up (no account in + // node.toml) is left alone -- node:start provisions and starts it. + async function winStartNodeWithApp() { + if (process.platform !== 'win32' || !hasBundledNode() || !nodeProvisioned()) return; + if ((await winStartupMode()) !== 'open' || await probeNode()) return; + try { + await winNodeStartVia(['autostart', 'start']); + nodeStartedByApp = true; + } catch (err) { + console.error('[node] start with the app:', err.message); + } } + nodeWithApp = { start: winStartNodeWithApp }; + + let nodeStopAtQuitDone = false; + app.on('before-quit', (event) => { + if (process.platform !== 'win32' || nodeStopAtQuitDone || !nodeStartedByApp) return; + event.preventDefault(); // before-quit waits for no promise + nodeStopAtQuitDone = true; + winStartupMode() + .then((mode) => (mode === 'open' ? killNodeProcesses() : null)) + .catch((err) => console.error('[node] stop at quit:', err.message)) + .finally(() => app.quit()); + }); // ── Windows: switching INTO or OUT OF service mode after install ─────────── // build/installer.nsh's mode question is effectively one-shot: it skips @@ -1351,79 +1415,20 @@ function registerBridge() { execFile(MB_PWSH, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', elevator, '-Target', MB_PWSH, '-TargetArgs', targetArgs], - (err) => { - if (err) { - reject(new Error('Elevation was declined, or the operation failed.')); - return; + (err, _stdout, stderr) => { + if (!err) { + resolve(); + } else if (/cancel/i.test(String(stderr))) { + // Also what an unanswered prompt becomes after two minutes. + reject(new Error('The administrator prompt was declined — nothing was changed.')); + } else { + reject(new Error('Switching the startup mode failed. Details: ' + + path.join(os.tmpdir(), 'meshbay-firewall.log'))); } - resolve(); }); }); } - // A daemon that crashes immediately (a port already in use -- reproduced - // live: a second node instance found 18000 taken by the first -- a corrupt - // config, antivirus interference) used to fail silently: stdio was - // 'ignore', so its stderr was thrown away, and the only failure path left - // was the caller's waitForNode() timing out after a generic 60s ("did not - // start within 60s"). The real reason was sitting on stderr the whole time, - // just never read. This watches for a few seconds -- long enough for any - // startup crash, reproduced consistently well under one second -- and - // rejects with the daemon's own tail of stderr if it exits in that window. - // If it survives the window, stdio is released and it is left fully - // detached, same as before this existed. - const NODE_CRASH_WATCH_MS = 2500; - - function spawnNodeDetachedWatched(bin, args = []) { - return new Promise((resolve, reject) => { - const child = spawn(bin, args, { - detached: true, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, - }); - let stderr = ''; - let settled = false; - child.stderr.on('data', (d) => { stderr += d.toString(); }); - // spawn() failures (bad path, a stale PATH entry, antivirus - // interference) land on the ChildProcess as an 'error' event, - // asynchronously -- with no listener, Node rethrows it as an uncaught - // exception and takes the whole main process down with it. - child.on('error', (err) => { - if (settled) return; - settled = true; - reject(err); - }); - child.on('exit', (code, signal) => { - if (settled) return; - settled = true; - // By lines (last 8) at first cut the actual OSError -- a real crash - // captured live logged the bind failure, then two separate uvicorn/ - // asyncio tracebacks *after* it, which pushed it out of a short tail. - // Character-bounded instead: Python's own daemon rarely writes more - // than a couple of screens on a startup crash, so keeping the last - // stretch of raw text is far more likely to still include the one - // line that actually says what went wrong than guessing a line count. - let tail = stderr.trim(); - if (tail.length > 4000) tail = `…${tail.slice(-4000)}`; - reject(new Error( - `meshbay-node exited immediately (code ${code}${signal ? `, signal ${signal}` : ''})` - + (tail ? `:\n${tail}` : ''))); - }); - setTimeout(() => { - if (settled) return; - settled = true; - child.stdout.destroy(); - child.stderr.destroy(); - child.unref(); - resolve(); - }, NODE_CRASH_WATCH_MS); - }); - } - - async function spawnNodeDetached() { - const bin = await findNodeBinary(); - if (!bin) throw new Error('meshbay-node not found on PATH'); - await spawnNodeDetachedWatched(bin); - } - async function waitForNode(deadline) { while (Date.now() < deadline) { const p = await probeNode(); @@ -1450,10 +1455,10 @@ function registerBridge() { while (Date.now() < deadline) { last = await probeNode(); if (last && last.status === 'running') return last; + // Whatever it is waiting for: a node backing off a 429 still needs its + // key linked before its next attempt can succeed. if (last && !linked && opts && opts.token && opts.hubUrl - && last.pk_node_ed25519 - && (last.status === 'waiting_for_node_key' - || last.status === 'waiting_for_account')) { + && last.pk_node_ed25519 && last.status !== 'starting') { try { const r = await fetch(`${opts.hubUrl}/v1/users/me/node_key`, { method: 'PUT', @@ -1591,10 +1596,13 @@ function registerBridge() { async function nodeServiceStop() { if (process.platform === 'win32') { - const svc = await winServiceTaskStatus(); - if (svc.installed) await winServiceTaskEnd(); - await killNodeProcesses(); // graceful-then-forceful; also the - // belt-and-suspenders in case /end left the process running + await killNodeProcesses(); + nodeStartedByApp = false; + // Said only once nothing answers: this used to report success about a + // service node it could not reach from this session. + if (await probeNode()) { + throw new Error(`the node could not be stopped. Its log: ${nodeLogHint()}`); + } return { stopped: true }; } if (process.platform !== 'linux') { @@ -1614,16 +1622,12 @@ function registerBridge() { async function nodeServiceRestart() { if (process.platform === 'win32') { - const svc = await winServiceTaskStatus(); - if (svc.installed) await winServiceTaskEnd(); - await killNodeProcesses(); - if (svc.installed) { - await winServiceTaskRun(); - } else { - await spawnNodeDetached(); - } - const p = await waitForNode(Date.now() + 30000); - if (!p) throw new Error('node did not come back up within 30s'); + // The CLI waits for the *new* instance: this used to report the one + // that was still shutting down, answering on the port for a moment. + await winNodeStartVia(['restart-daemon']); + if ((await winStartupMode()) !== 'service') nodeStartedByApp = true; + const p = await probeNode(); + if (!p) throw new Error(`the node did not come back up. Its log: ${nodeLogHint()}`); return { restarted: true, ...p }; } if (process.platform !== 'linux') { @@ -1643,6 +1647,10 @@ function registerBridge() { ipcMain.handle('node:autostart', async (_e, action) => { if (process.platform !== 'win32') return { supported: false }; if (action === 'install') { + // Both would start the node: at boot, then again at sign-in. + if ((await winServiceTaskStatus()).installed) { + throw new Error('the node already runs as a background service'); + } const bin = await findNodeBinary(); if (!bin) throw new Error('meshbay-node not found on PATH'); winAutostartInstall(bin); @@ -1663,8 +1671,43 @@ function registerBridge() { if (action !== 'install' && action !== 'remove') { throw new Error(`unknown service-mode action: ${action}`); } - await winElevateServiceMode(action); + // Stop the running node first, from here, unelevated: its own control API + // reaches it in any session. Otherwise removing the service left its node + // running in session 0 with nothing left that could stop it, and + // installing it started a second node that found the port taken and quit, + // leaving the old one in charge -- both found by switching modes on a real + // install. + const wasRunning = Boolean(await probeNode()); + await killNodeProcesses(); + try { + await winElevateServiceMode(action); + } catch (err) { + // Declined, timed out or failed: the mode is what it was, and so must + // the node be. It used to stay stopped -- a "No" to the prompt took the + // groups offline. restart-daemon starts it however this machine is now + // set up, which after a failure is how it was. + if (wasRunning) { + try { + await winNodeStartVia(['restart-daemon']); + } catch (e) { + console.error('[node] restart after a refused mode switch:', e.message); + } + } + throw err; + } const svc = await winServiceTaskStatus(); + if (action === 'install') { + nodeStartedByApp = false; + } else if (wasRunning) { + // Up again in this session: in the mode being switched to, the node + // runs while the app is open, or from the next sign-in on. + try { + await winNodeStartVia(['autostart', 'start']); + nodeStartedByApp = true; + } catch (err) { + console.error('[node] restart after leaving service mode:', err.message); + } + } return { supported: true, installed: svc.installed }; }); @@ -1680,11 +1723,17 @@ function registerBridge() { { signal: AbortSignal.timeout(3000) }); if (!r.ok) return null; const status = await r.json(); - const READY = ['running', 'waiting_for_node_key', 'waiting_for_account', 'starting']; + // Every state of a daemon that is up. 'waiting_for_hub' is a node backing + // off a 429 or a hub restart; leaving it out made that node count as no + // node at all, so node:start never linked it and reported "started but + // could not link" (reproduced against a local hub, 2026-09-26). + const READY = ['running', 'waiting_for_node_key', 'waiting_for_account', + 'waiting_for_hub', 'starting']; if (!READY.includes(status.status)) return null; _nodeToken = token; _nodePort = port; - return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status }; + return { pk_node_ed25519: status.pk_node_ed25519 || '', status: status.status, + version: status.version || '' }; } catch { return null; } } @@ -1739,37 +1788,43 @@ function registerBridge() { if (process.platform === 'win32') { if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username); - const svc = await winServiceTaskStatus(); - if (svc.installed) { - // A service-mode daemon runs under the task's own S4U logon session, - // not this (interactive) one -- killNodeProcesses()'s taskkill and - // CTRL_BREAK both target it by image name/pid from here, and both - // fail with "Access is denied" across that session boundary - // (confirmed live 2026-09-14: an already-elevated `schtasks /end` - // succeeds against the exact same pid taskkill just refused). - // Silently, too -- killNodeProcesses() never surfaces the failure, - // so a stuck instance was never actually replaced: re-running the - // task below is then a no-op too, since Windows still considers it - // Running (default "do not start a new instance" policy). Task Scheduler can - // stop what it started; go through it, the way nodeServiceStop/ - // nodeServiceRestart already correctly do, instead of reaching past it. - await winServiceTaskEnd(); - await winServiceTaskRun(); - } else { - await killNodeProcesses(); // clear a crash-looping one (same session) - await spawnNodeDetached(); + // Restarted, not merely started: provisionNode() may just have pointed + // the node at another hub or account, which it only reads at start. + // The CLI stops whatever runs (in any session, gracefully first), starts + // it the way this machine is set up -- the service task, or a process of + // its own with nothing of the app's inherited -- and waits for the new + // instance to answer. + await winNodeStartVia(['restart-daemon']); + if ((await winStartupMode()) !== 'service') nodeStartedByApp = true; + const p = await waitForNode(Date.now() + 15000); + if (!p) throw new Error('the node did not start. Its log: ' + + `${nodeLogHint()}`); + // An upgrade that could not replace a running node's files leaves the + // previous version's node behind, and it cannot speak this app's + // protocol; everything after this point would fail for no stated reason. + if (app.isPackaged && p.version && p.version !== app.getVersion()) { + throw new Error( + `the node that answered is version ${p.version}, but this app is ` + + `${app.getVersion()}. Its files were not replaced, or the ` + + 'background service runs another copy: stop the node ' + + '(meshbay-node service stop) and run the installer again.'); } - const p = await waitForNode(Date.now() + 60000); - if (!p) throw new Error('the node did not start within 60s — run it from a ' - + 'terminal (`meshbay-node`) to see why'); // Up, but almost never 'running' on a first launch: link the node key to // the hub account and wait for the daemon to authenticate. Without this // it stays at 'waiting_for_account' and nothing here ever tells the hub // about the node. const ready = p.status === 'running' ? p - : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 45000); - if (!ready || ready.status !== 'running') { + // 90s: a node caught in the hub's per-minute sign-in limit waits out + // the rest of that minute plus its 10s back-off before trying again. + : await linkNodeKeyAndAwaitRunning(opts, Date.now() + 90000); + if (!ready) { + // It answered once and then stopped answering: it is not running, and + // saying "started but could not link" sent the reader after the link. + throw new Error('the node started, then stopped responding. Its log: ' + + `${nodeLogHint()}`); + } + if (ready.status !== 'running') { // "Link Node" is on the Settings page, not this one -- pointing here // at the Node page sent whoever read this hunting for a control that // is not on it (reproduced live 2026-09-14). @@ -1857,9 +1912,9 @@ function registerBridge() { detached: true, stdio: 'ignore', }); - // Same reason as spawnNodeDetached(): an unhandled 'error' event here - // would crash the whole main process instead of letting the polling - // loop below report "never came up". + // spawn() failures arrive as an 'error' event: unhandled, it would crash + // the whole main process instead of letting the polling loop below + // report "never came up". child.on('error', (err) => console.error('[node] failed to start:', err.message)); child.unref(); } @@ -1878,9 +1933,9 @@ function registerBridge() { } // Daemon is up but stuck on hub auth — link the key so it can proceed. + // Whatever it is waiting for, 'waiting_for_hub' included (see probeNode). if (!keyLinked && opts && opts.token && result.pk_node_ed25519 && - (result.status === 'waiting_for_node_key' || - result.status === 'waiting_for_account')) { + result.status !== 'starting') { try { const lr = await fetch( `${opts.hubUrl}/v1/users/me/node_key`, { @@ -2115,6 +2170,8 @@ if (!app.requestSingleInstanceLock()) { registerBridge(); if (trayOS()) ensureTray(); createWindow(); + // Not awaited: the window does not wait for the node. + if (nodeWithApp) nodeWithApp.start(); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 7bd5169..41a56be 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -910,7 +910,7 @@ export default { 'node.service_restarting': 'Wird neu gestartet…', 'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.', 'node.startup_mode_label': 'Automatisch starten:', - 'node.startup_mode_off': 'Aus (manuell starten)', + 'node.startup_mode_off': 'Nur solange MeshBay geöffnet ist', 'node.startup_mode_signin': 'Bei der Anmeldung', 'node.startup_mode_service': 'Als Hintergrunddienst (startet beim Booten)', 'node.startup_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 0c9cb19..7c06815 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1006,7 +1006,7 @@ export default { 'node.service_restarting': 'Restarting…', 'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.', 'node.startup_mode_label': 'Start automatically:', - 'node.startup_mode_off': 'Off (start manually)', + 'node.startup_mode_off': 'Only while MeshBay is open', 'node.startup_mode_signin': 'At sign-in', 'node.startup_mode_service': 'As a background service (starts at boot)', 'node.startup_mode_updating': 'Switching mode — check for an administrator prompt…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 1399a83..939b96c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -904,7 +904,7 @@ export default { 'node.service_restarting': 'Reiniciando…', 'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.', 'node.startup_mode_label': 'Iniciar automáticamente:', - 'node.startup_mode_off': 'Desactivado (iniciar manualmente)', + 'node.startup_mode_off': 'Solo mientras MeshBay está abierto', 'node.startup_mode_signin': 'Al iniciar sesión', 'node.startup_mode_service': 'Como servicio en segundo plano (se inicia al arrancar)', 'node.startup_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index ae278d9..c546d4e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -907,7 +907,7 @@ export default { 'node.service_restarting': 'Redémarrage…', 'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.', 'node.startup_mode_label': 'Démarrer automatiquement :', - 'node.startup_mode_off': 'Désactivé (démarrage manuel)', + 'node.startup_mode_off': 'Uniquement quand MeshBay est ouvert', 'node.startup_mode_signin': 'À l\'ouverture de session', 'node.startup_mode_service': 'Comme service en arrière-plan (démarre au boot)', 'node.startup_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index f0fb0d1..893a563 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -906,7 +906,7 @@ export default { 'node.service_restarting': 'Riavvio…', 'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.', 'node.startup_mode_label': 'Avvia automaticamente:', - 'node.startup_mode_off': 'Disattivato (avvio manuale)', + 'node.startup_mode_off': 'Solo mentre MeshBay è aperto', 'node.startup_mode_signin': 'All\'accesso', 'node.startup_mode_service': 'Come servizio in background (si avvia all\'avvio del sistema)', 'node.startup_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 560332d..1a03c52 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -894,7 +894,7 @@ export default { 'node.service_restarting': '再起動中…', 'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。', 'node.startup_mode_label': '自動的に開始:', - 'node.startup_mode_off': 'オフ(手動で開始)', + 'node.startup_mode_off': 'MeshBay が開いている間のみ', 'node.startup_mode_signin': 'サインイン時', 'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)', 'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index adb94c6..8ae0ab3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -908,7 +908,7 @@ export default { 'node.service_restarting': 'Herstarten…', 'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.', 'node.startup_mode_label': 'Automatisch starten:', - 'node.startup_mode_off': 'Uit (handmatig starten)', + 'node.startup_mode_off': 'Alleen zolang MeshBay open is', 'node.startup_mode_signin': 'Bij aanmelden', 'node.startup_mode_service': 'Als achtergrondservice (start bij het opstarten)', 'node.startup_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index fb5a4ed..a2cc5bb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -926,7 +926,7 @@ export default { 'node.service_restarting': 'Ponowne uruchamianie…', 'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.', 'node.startup_mode_label': 'Uruchamiaj automatycznie:', - 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)', + 'node.startup_mode_off': 'Tylko gdy MeshBay jest otwarty', 'node.startup_mode_signin': 'Przy logowaniu', 'node.startup_mode_service': 'Jako usługa w tle (uruchamia się przy starcie systemu)', 'node.startup_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 7bd11f6..2b98298 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -905,7 +905,7 @@ export default { 'node.service_restarting': 'Reiniciando…', 'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.', 'node.startup_mode_label': 'Iniciar automaticamente:', - 'node.startup_mode_off': 'Desativado (iniciar manualmente)', + 'node.startup_mode_off': 'Somente enquanto o MeshBay estiver aberto', 'node.startup_mode_signin': 'Ao entrar na sessão', 'node.startup_mode_service': 'Como serviço em segundo plano (inicia na inicialização)', 'node.startup_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index 5509332..4ac583a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -882,7 +882,7 @@ export default { 'node.service_restarting': '正在重启…', 'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。', 'node.startup_mode_label': '自动启动:', - 'node.startup_mode_off': '关闭(手动启动)', + 'node.startup_mode_off': '仅在 MeshBay 打开时', 'node.startup_mode_signin': '登录时', 'node.startup_mode_service': '作为后台服务(开机时启动)', 'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…', diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index aa77c29..cc9f8cc 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -10,8 +10,11 @@ node-runtime artifact slipping into git, the autostart seam between the NSIS uninstaller and meshbay_node.platform drifting apart. """ +import inspect import json +import os import re +import sys from pathlib import Path import pytest @@ -196,7 +199,6 @@ def test_the_uninstaller_clears_the_autostart_launcher(): nsh = NSH.read_text(encoding="utf-8") assert "!macro customUnInstall" in nsh - assert "taskkill /IM meshbay-node.exe /F" in nsh # The tail platform.py builds, made NSIS-relative ($APPDATA == %APPDATA%). tail = plat._startup_vbs() @@ -208,10 +210,244 @@ def test_the_uninstaller_clears_the_autostart_launcher(): "changed and installer.nsh was not updated") -def test_customInstall_stops_a_running_daemon_before_overwriting_it(): +# ── an upgrade must replace the node it is upgrading ─────────────────────── +# +# The test this replaced asserted a `taskkill` in customInstall, under the name +# "stops a running daemon before overwriting it". Both halves were false: +# electron-builder runs customInstall AFTER it has copied the files, and an +# unelevated taskkill cannot reach a service-mode daemon (S4U session, "Access +# is denied"). The copy of the locked meshbay-node.exe failed, electron-builder's +# last-resort extract ignored the failure, and an upgraded install went on +# running the previous version's node -- whose code is embedded in that exe -- +# against the new client and hub. These read electron-builder's own template +# rather than restating what it was assumed to do. + +EB_NSIS = CLIENT / "node_modules" / "app-builder-lib" / "templates" / "nsis" + + +def _eb_template(rel: str) -> str: + path = EB_NSIS / rel + if not path.exists(): + pytest.skip("electron-builder is not installed (npm ci in packages/meshbay-client)") + return path.read_text(encoding="utf-8") + + +def test_electron_builder_checks_for_running_apps_before_it_copies_anything(): + section = _eb_template("installSection.nsh") + i_check = section.index("!insertmacro CHECK_APP_RUNNING") + i_uninstall_old = section.index("!insertmacro uninstallOldVersion") + i_copy = section.index("!insertmacro installApplicationFiles") + i_custom = section.index("!insertmacro customInstall") + assert i_check < i_uninstall_old < i_copy < i_custom, ( + "electron-builder's install order changed: the node must be stopped " + "before uninstallOldVersion and installApplicationFiles, and " + "customInstall is only reached after both") + + uninstaller = _eb_template("uninstaller.nsh") + section_body = uninstaller.split('Section "un.', 1)[1] + assert section_body.index("call un.checkAppRunning") < \ + section_body.index("!insertmacro customUnInstall") + + +def test_electron_builder_hands_the_running_app_check_to_customCheckAppRunning(): + helper = _eb_template("include/allowOnlyOneInstallerInstance.nsh") + check = helper.split("!macro CHECK_APP_RUNNING", 1)[1].split("!macroend", 1)[0] + assert "!insertmacro customCheckAppRunning" in check + # ...and defining it drops what its own check needs; installer.nsh supplies them. + guarded = helper.split("!ifmacrondef customCheckAppRunning", 1)[1].split("!endif", 1)[0] + assert '!include "getProcessInfo.nsh"' in guarded + assert "Var pid" in guarded + + +STOP_NODE_PS1 = CLIENT / "build" / "stop-node.ps1" + + +def test_the_node_is_stopped_before_any_file_is_copied(): nsh = NSH.read_text(encoding="utf-8") - body = _macro_body(nsh, "customInstall") - assert "taskkill /IM meshbay-node.exe /F" in body + check = _macro_body(nsh, "customCheckAppRunning") + # Embedded and run from the plugins dir: the installed copy may be the one + # being replaced. + assert r'"${BUILD_RESOURCES_DIR}\stop-node.ps1"' in check + assert r'-File "$PLUGINSDIR\mb-stop-node.ps1"' in check + assert check.index("InitPluginsDir") < check.index("File ") + # A node that will not stop fails the install loudly, never half-upgrades it. + assert "Quit" in check and "/SD IDCANCEL" in check + # electron-builder's own "close MeshBay" check still runs, with its prerequisites. + assert check.index("IS_POWERSHELL_AVAILABLE") < check.index("_CHECK_APP_RUNNING") + assert '!include "getProcessInfo.nsh"' in nsh + assert re.search(r"^Var pid\s*$", nsh, re.M) + + +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") + 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 + + +def test_no_taskkill_is_left_to_pretend_it_stops_the_node(): + nsh = NSH.read_text(encoding="utf-8") + for macro in ("customInstall", "customUnInstall"): + assert "taskkill" not in _macro_body(nsh, macro), ( + f"{macro} runs after the files are touched, and taskkill cannot reach " + "a service-mode daemon anyway") + + +def test_setup_starts_the_service_node_it_just_installed(): + nsh = NSH.read_text(encoding="utf-8") + install = _macro_body(nsh, "customInstall") + tail = install.split("mb_auto_done:", 1)[1] + run = 'schtasks /run /tn "MeshBay Node"' + assert run in tail, "the node stopped before the copy must be started again" + # Outside the ${IfNot} ${Silent} block: a silent upgrade needs its node too. + assert tail.index("${EndIf}") < tail.index(run) + last_if = tail.rindex("${If} $MB_AutoMode", 0, tail.index(run)) + assert tail[last_if:].startswith('${If} $MB_AutoMode == "2"') + + +def test_a_stale_service_task_is_registered_again(): + src = (WIN / "service.ps1").read_text(encoding="utf-8") + status = src.split('"status" {', 1)[1].split('"run" {', 1)[0] + assert "exit 2" in status and "INSTALLED_STALE" in status + for probe in ("$exe -ne $node", '"PT0S"', "DisallowStartIfOnBatteries", + "StopIfGoingOnBatteries"): + assert probe in status, f"service.ps1 status does not check {probe}" + install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall") + assert "${If} $R1 == 0" in install, "only a current task (0) may skip the elevation" + + +def test_the_service_task_is_not_ended_after_72_hours_or_on_battery(): + from meshbay_node import platform as plat + + flags = ("-ExecutionTimeLimit ([TimeSpan]::Zero)", "-AllowStartIfOnBatteries", + "-DontStopIfGoingOnBatteries", "-MultipleInstances IgnoreNew") + ps1 = (WIN / "service.ps1").read_text(encoding="utf-8") + install = ps1.split('"install" {', 1)[1].split('"remove" {', 1)[0] + assert "-Settings $taskSettings" in install + for flag in flags: + assert flag in install, f"service.ps1 install lacks {flag}" + assert flag in plat.SERVICE_TASK_SETTINGS, f"platform.py lacks {flag}" + assert "-Settings $s" in inspect.getsource(plat.service_install) + + +def test_a_service_restart_waits_for_the_old_instance_before_starting_one(): + """/end returns before the task leaves Running; a /run in that window is + dropped (MultipleInstances IgnoreNew) and leaves no node at all. And the + restart reports the new instance, not the one still shutting down.""" + from meshbay_node.cli import lifecycle + stop = inspect.getsource(lifecycle._stop_node) + i_end = stop.index("service_end()") + assert 'service_state().lower() == "running"' in stop[i_end:] + start = inspect.getsource(lifecycle._start_and_confirm) + assert start.index("_stop_node(cfg)") < start.index("since = time.time()") \ + < start.index("service_run()") + assert "_await_daemon(cfg, since)" in start + + +def test_node_start_refuses_a_node_of_another_version(): + main_js = MAIN_JS.read_text(encoding="utf-8") + body = main_js.split("ipcMain.handle('node:start'", 1)[1] + body = body[:body.index("process.platform !== 'linux'")] + assert "p.version !== app.getVersion()" in body + assert body.index("waitForNode(") < body.index("p.version !== app.getVersion()") \ + < body.index("linkNodeKeyAndAwaitRunning(") + # A node that stopped answering is not reported as "started but not linked". + assert "if (!ready)" in body and "nodeLogHint()" in body + + +def test_the_build_starts_the_frozen_daemon_not_only_its_help(): + build = (WIN / "build-node-runtime.ps1").read_text(encoding="utf-8") + assert "smoke-node-runtime.ps1" in build + smoke = (WIN / "smoke-node-runtime.ps1").read_text(encoding="utf-8") + assert "/api/status" in smoke and "$status.version -ne $ExpectVersion" in smoke + assert "state\\node.log" in smoke + # Never the developer's node or a real hub. + assert "$env:LOCALAPPDATA = $profileDir" in smoke + assert 'url = "http://127.0.0.1:1"' in smoke + + +def _run_stop_node(tmp_path, dummy_name, localappdata): + """stop-node.ps1 as setup runs it, with the process and task names swapped + for ones that belong to this test -- the real ones would stop the + developer's own node.""" + import shutil + import subprocess + + script = (STOP_NODE_PS1.read_text(encoding="utf-8") + .replace('"MeshBay Node"', '"MeshBay Node stop-test"') + .replace("meshbay-node", dummy_name)) + assert "meshbay-node" not in script + copy = tmp_path / "stop-node-test.ps1" + copy.write_text(script, encoding="utf-8") + pwsh = (shutil.which("powershell") + or r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe") + env = {**os.environ, "LOCALAPPDATA": str(localappdata)} + return subprocess.run([pwsh, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(copy)], + capture_output=True, text=True, timeout=90, env=env) + + +def _dummy_node(tmp_path, name): + import shutil + import subprocess + exe = tmp_path / f"{name}.exe" + shutil.copy(r"C:\Windows\System32\PING.EXE", exe) + return subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +@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 + them, POSTs /api/shutdown, and waits for the process to go -- here a stand-in + that exits when asked, as the daemon does.""" + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + + proc = _dummy_node(tmp_path, "mbstopgraceful") + asked = [] + + class Api(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + asked.append(self.path) + self.send_response(200) + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"{}") + proc.kill() # "shuts down" + + def log_message(self, *a): + pass + + server = HTTPServer(("127.0.0.1", 0), Api) + threading.Thread(target=server.serve_forever, daemon=True).start() + home = tmp_path / "home" / "meshbay" + (home / "data").mkdir(parents=True) + (home / "node.toml").write_text(f"[node]\nui_port = {server.server_address[1]}\n", + encoding="utf-8") + (home / "data" / "ui-token").write_text("tok", encoding="utf-8") + try: + r = _run_stop_node(tmp_path, "mbstopgraceful", tmp_path / "home") + assert r.returncode == 0, r.stdout + r.stderr + assert asked == ["/api/shutdown?t=tok"] + finally: + server.shutdown() + if proc.poll() is None: + proc.kill() + + +@pytest.mark.skipif(sys.platform != "win32", reason="runs the installer's PowerShell for real") +def test_setup_forces_a_node_that_does_not_answer(tmp_path): + proc = _dummy_node(tmp_path, "mbstopforced") + try: + r = _run_stop_node(tmp_path, "mbstopforced", tmp_path / "nothing-here") + assert r.returncode == 0, r.stdout + r.stderr + assert proc.wait(timeout=10) is not None + finally: + if proc.poll() is None: + proc.kill() # ── the autostart choice + the one-time elevated firewall step ────────────── @@ -330,11 +566,59 @@ def test_the_uninstaller_offers_to_remove_everything_privileged_default_no(): uninstall = _macro_body(nsh, "customUnInstall") assert "${IfNot} ${Silent}" in uninstall + assert "${AndIfNot} ${isUpdated}" in uninstall, "not while an upgrade replaces it" assert "/SD IDNO" in uninstall, "the uninstall prompt should default to No" - assert 'service-mode.ps1" -Action remove' in uninstall + # uninstall, not remove: remove keeps the firewall rules (a mode switch). + assert 'service-mode.ps1" -Action uninstall' in uninstall assert 'ExecShellWait "runas"' in uninstall +def test_an_upgrade_keeps_the_sign_in_launcher(): + """The previous version's uninstaller runs during an upgrade; deleting the + launcher there left upgraded "at sign-in" installs with no autostart at all.""" + uninstall = _macro_body(NSH.read_text(encoding="utf-8"), "customUnInstall") + i_guard = uninstall.index("${IfNot} ${isUpdated}") + assert i_guard < uninstall.index("MeshBay Node.vbs") + + +def test_setup_preselects_the_mode_this_machine_already_runs(): + init = _macro_body(NSH.read_text(encoding="utf-8"), "customInit") + i_task = init.index('schtasks /query /tn "MeshBay Node"') + i_vbs = init.index("MeshBay Node.vbs") + i_prev = init.index("$INSTDIR\\${APP_EXECUTABLE_FILENAME}") + assert i_task < i_vbs < i_prev + assert 'StrCpy $MB_AutoMode "1"' in init[i_vbs:i_prev] + assert 'StrCpy $MB_AutoMode "0"' in init[i_prev:] + + +def test_setup_leaves_the_launcher_and_the_node_as_the_mode_wants_them(): + install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall") + tail = install.split("mb_auto_done:", 1)[1] + silent_end = tail.index("${EndIf}") + # Outside ${IfNot} ${Silent}: a silent upgrade needs them as much. + assert silent_end < tail.index("autostart install") < tail.index("autostart remove") + start = tail.split('${FileExists} "$LOCALAPPDATA\\meshbay\\node.toml"', 1)[1] + assert 'schtasks /run /tn "MeshBay Node"' in start + assert 'meshbay-node.exe" autostart start' in start + + +def test_choosing_another_mode_takes_out_a_leftover_boot_task(): + install = _macro_body(NSH.read_text(encoding="utf-8"), "customInstall") + modes01 = install.split("; Modes 0 and 1.", 1)[1].split("mb_auto_done:", 1)[0] + assert 'service-mode.ps1" -Action remove' in modes01 + + +def test_leaving_service_mode_keeps_the_firewall_rules(): + """Switching from the Node page removed them too, and the node silently + stopped accepting connections.""" + src = (WIN / "service-mode.ps1").read_text(encoding="utf-8") + assert '[ValidateSet("install", "remove", "uninstall")]' in src + fw = src.split('if ($Action -ne "remove")', 1) + assert len(fw) == 2 and "firewall.ps1" in fw[1], "remove must skip the firewall step" + # Before the task goes, or a service node runs on with nothing to stop it. + assert src.index("Stop-Process -Force") < src.index('"service.ps1") $serviceAction') + + # ── service mode itself (packaging/win/service.ps1, service-mode.ps1) ────── def test_service_ps1_and_service_mode_ps1_are_extraresources(): @@ -494,18 +778,47 @@ def test_the_help_smoke_test_joins_multiline_output_before_matching(): assert '(& $exe --help 2>&1) -join' in body -def test_main_js_drives_the_service_task_for_all_three_actions(): - """The hard requirement: Start/Stop/Restart from the Node page must - control the Scheduled Task when service mode is active, not just spawn a - detached process that has nothing to do with it.""" - main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") - for handler in ("node:service-stop", "node:service-restart", "node:start"): - body = main_js.split(f"ipcMain.handle('{handler}'", 1)[1] - body = body[:body.index("ipcMain.handle(")] - assert "winServiceTaskStatus" in body, f"{handler} never checks for the service task" +def _fn_body(src: str, signature: str) -> str: + return src.split(signature, 1)[1].split("\n }\n", 1)[0] + + +def test_every_start_stop_and_restart_goes_through_the_cli(): + """One implementation behind every front door. main.js kept its own copy: + it ended the service with schtasks first (a TerminateProcess), started nodes + as children of Electron (which inherited Electron's sockets), and reported a + node it could not reach from its session as stopped.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + assert "winNodeCli(['autostart', 'stop'])" in _fn_body( + main_js, "async function killNodeProcesses()") + assert "killNodeProcesses()" in _fn_body(main_js, "async function nodeServiceStop()") + assert "winNodeStartVia(['restart-daemon'])" in _fn_body( + main_js, "async function nodeServiceRestart()") + start = main_js.split("ipcMain.handle('node:start'", 1)[1] + start = start.split("process.platform !== 'linux'", 1)[0] + assert "winNodeStartVia(['restart-daemon'])" in start + for gone in ("spawnNodeDetached", "winServiceTaskEnd", "winServiceTaskRun"): + assert gone not in main_js, gone + # The CLI stops gracefully first, whatever the session, then forces. + from meshbay_node.cli import lifecycle + stop = inspect.getsource(lifecycle._stop_node) + assert stop.index("request_graceful_stop") < stop.index("service_end()") \ + < stop.index("autostart_end()") -def test_node_start_provisions_before_it_ever_touches_the_service_task(): +def test_nothing_treats_find_node_binary_as_always_a_promise(): + """It returns the bundled path as a plain string in a packaged build: a + `.then` on it failed every start and stop in the installed app only -- + found by launching the installed app, invisible from a dev run.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + assert not re.search(r"findNodeBinary\(\)\s*\.\s*then", main_js) + + +def test_a_stop_that_leaves_the_node_answering_says_so(): + stop = _fn_body(MAIN_JS.read_text(encoding="utf-8"), "async function nodeServiceStop()") + assert "if (await probeNode())" in stop and "throw new Error" in stop + + +def test_node_start_provisions_before_it_starts_anything(): """ On a fresh install with service mode chosen, the Scheduled Task exists before anything is provisioned (node.toml is written by the wizard, not @@ -525,11 +838,10 @@ def test_node_start_provisions_before_it_ever_touches_the_service_task(): body = body[:body.index("ipcMain.handle(")] provision_at = body.index("provisionNode(") - service_check_at = body.index("winServiceTaskStatus") - assert provision_at < service_check_at, ( - "node:start checks the service task before provisioning — a fresh " - "install's first Start would run/query the daemon before node.toml " - "exists for it to read") + start_at = body.index("winNodeStartVia(") + assert provision_at < start_at, ( + "node:start starts the node before provisioning — a fresh install's " + "first Start would run the daemon before node.toml exists for it to read") def test_node_start_links_the_node_key_on_windows_not_only_linux(): @@ -556,7 +868,29 @@ def test_node_start_links_the_node_key_on_windows_not_only_linux(): helper = main_js.split("async function linkNodeKeyAndAwaitRunning", 1)[1] helper = helper[:2000] assert "/v1/users/me/node_key" in helper - assert "waiting_for_account" in helper and "waiting_for_node_key" in helper + # Linked whatever the node is waiting for -- a node backing off a 429 + # ('waiting_for_hub') needs its key as much as one at 'waiting_for_account'. + assert "last.status !== 'starting'" in helper + + +def test_a_node_backing_off_the_hub_is_still_a_node(): + """'waiting_for_hub' (429 or hub restart) was missing from probeNode's list, + so node:start treated a live node as absent, never linked it, and said + "started but could not link" -- reproduced against a local hub.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + probe = main_js.split("async function probeNode()", 1)[1][:1500] + ready = probe.split("const READY = [", 1)[1].split("];", 1)[0] + for status in ("running", "waiting_for_node_key", "waiting_for_account", + "waiting_for_hub", "starting"): + assert f"'{status}'" in ready, status + daemon_py = (ROOT / "packages" / "meshbay-node" / "src" / "meshbay_node" + / "daemon.py").read_text(encoding="utf-8") + daemon_states = set(re.findall(r'self\._state\["status"\] = "(\w+)"', daemon_py)) + not_a_node = {"stopping"} # shutting down: nothing to link or wait for + assert daemon_states - not_a_node <= {s.strip(" '\n") for s in ready.split(",")}, ( + "the daemon has a status probeNode does not recognise") + wizard = (HUB_STATIC / "create-group-page.js").read_text(encoding="utf-8") + assert "'waiting_for_hub'" in wizard def test_firewall_ps1_targets_both_executables_and_is_idempotent(): @@ -1159,36 +1493,13 @@ def test_main_js_calls_ensure_node_path_on_every_launch(): "must be both defined and called") -def test_node_start_surfaces_an_immediate_daemon_crash_instead_of_a_60s_timeout(): - """ - Reproduced live: a daemon that exits within ~1s (a port already bound, - reproduced with a second instance colliding on 127.0.0.1:18000) used to - be indistinguishable from one that simply never started -- spawn()'s - stdio was 'ignore', discarding the exact stderr line that named the real - problem, and waitForNode()'s 60s generic timeout was the only failure - path left. spawnNodeDetachedWatched watches for an early exit and - rejects with the daemon's own tail of stderr instead. - """ +def test_a_node_that_fails_to_start_is_reported_with_its_own_words_and_log(): + """A daemon that exits at once used to look like one that never started. + The CLI reports what happened; the app passes that on with where the log + is -- the only place a node with no console writes why.""" src = MAIN_JS.read_text(encoding="utf-8") - assert "function spawnNodeDetachedWatched(" in src - body = src.split("function spawnNodeDetachedWatched(", 1)[1].split("\n }", 1)[0] - assert "stdio: ['ignore', 'pipe', 'pipe']" in body - assert "exited immediately" in body - assert "NODE_CRASH_WATCH_MS" in body - # The tail must be bounded by length, not by a line count -- a real - # capture had the actual OSError line pushed out by two uvicorn/asyncio - # tracebacks that followed it, which a short "last N lines" cut before - # this was fixed to bound by characters instead. - assert "split(/\\r?\\n/).slice(" not in body, ( - "a line-count tail can cut the one line that names the real error " - "-- bound by characters instead (reproduced live, see the comment " - "above this constant)") - assert "4000" in body - - async_fn = src.split("async function spawnNodeDetached()", 1)[1].split("\n }", 1)[0] - assert "spawnNodeDetachedWatched" in async_fn, ( - "spawnNodeDetached must actually use the watched spawn, not the old " - "fire-and-forget one") + body = _fn_body(src, "async function winNodeStartVia(args)") + assert "r.out" in body and "nodeLogHint()" in body and "throw new Error" in body def test_setup_welcome_hints_at_the_node_startup_choice(): @@ -1265,25 +1576,48 @@ def test_node_start_ends_a_service_mode_daemon_via_task_scheduler_not_taskkill() until a reboot. nodeServiceStop/nodeServiceRestart already route through winServiceTaskEnd() first for exactly this reason -- node:start must too. """ + # Now the CLI's (test_every_start_stop_and_restart_goes_through_the_cli): + # its stop reaches a session-0 node through the node's own control API, and + # still ends the task when that does not answer. + from meshbay_node.cli import lifecycle + stop = inspect.getsource(lifecycle._stop_node) + assert "request_graceful_stop" in stop and "service_end()" in stop + + +def test_switching_modes_stops_the_node_first_and_brings_it_back(): + """Removing the service left its node running in session 0, unstoppable; + installing it started a second node that found the port taken and quit.""" main_js = MAIN_JS.read_text(encoding="utf-8") - body = main_js.split("ipcMain.handle('node:start'", 1)[1] - body = body[:body.index("ipcMain.handle(")] - win_branch = body.split("process.platform === 'win32'", 1)[1] - win_branch = win_branch[:win_branch.index("process.platform !== 'linux'")] + body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0] + assert body.index("killNodeProcesses()") < body.index("winElevateServiceMode(action)") + after = body.split("winElevateServiceMode(action)", 1)[1] + assert "wasRunning" in after and "winNodeStartVia(['autostart', 'start'])" in after + autostart = main_js.split("ipcMain.handle('node:autostart'", 1)[1] + autostart = autostart.split("ipcMain.handle(", 1)[0] + assert "winServiceTaskStatus()).installed" in autostart, ( + "the sign-in launcher must refuse while the boot task exists") + - svc_installed = win_branch.split("if (svc.installed) {", 1)[1] - svc_installed = svc_installed[:svc_installed.index("} else {")] - assert "winServiceTaskEnd" in svc_installed, ( - "the service-mode branch of node:start never calls winServiceTaskEnd() " - "-- a stuck S4U-session daemon can't be reached by killNodeProcesses() " - "(Access is denied, confirmed live) so it never actually gets replaced") - assert svc_installed.index("winServiceTaskEnd") < svc_installed.index("winServiceTaskRun"), ( - "winServiceTaskEnd() must run before winServiceTaskRun() -- ending " - "second would stop the fresh instance right after starting it") +def test_a_declined_prompt_leaves_the_node_as_it_was(): + """The node is stopped before the prompt; a "No" -- or a prompt nobody + answered for two minutes -- used to leave it stopped, groups offline, with + the mode unchanged. Found by letting the prompt time out on a real install.""" + main_js = MAIN_JS.read_text(encoding="utf-8") + body = main_js.split("ipcMain.handle('node:service-mode'", 1)[1].split("ipcMain.handle(", 1)[0] + guarded = body.split("await winElevateServiceMode(action);", 1)[1] + catch = guarded.split("} catch (err) {", 1)[1].split("throw err;", 1)[0] + assert "wasRunning" in catch and "winNodeStartVia(['restart-daemon'])" in catch + elevate = _fn_body(main_js, "function winElevateServiceMode(action)") + assert "/cancel/i.test(" in elevate, "a decline is not reported as a failure" - svc_else = win_branch.split("} else {", 1)[1] - svc_else = svc_else[:svc_else.index("const p = await waitForNode")] - assert "killNodeProcesses" in svc_else, ( - "the non-service branch (Startup mode / only-while-open) should still " - "use killNodeProcesses() -- that daemon runs in this same session, " - "where taskkill/CTRL_BREAK actually work") + +def test_only_while_open_starts_with_the_app_and_stops_at_quit(): + main_js = MAIN_JS.read_text(encoding="utf-8") + start = _fn_body(main_js, "async function winStartNodeWithApp()") + assert "winStartupMode()) !== 'open'" in start and "nodeProvisioned()" in start + assert "winNodeStartVia(['autostart', 'start'])" in start + ready = main_js.split("app.whenReady().then(", 1)[1] + assert ready.index("createWindow();") < ready.index("nodeWithApp.start()") + quit_ = main_js.split("app.on('before-quit', (event) => {", 1)[1].split("\n });", 1)[0] + assert "!nodeStartedByApp" in quit_ and "event.preventDefault()" in quit_ + assert "mode === 'open' ? killNodeProcesses()" in quit_ |