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 --- packages/meshbay-client/build/installer.nsh | 123 ++++++++-- packages/meshbay-client/build/stop-node.ps1 | 45 ++++ packages/meshbay-client/src/main.js | 339 ++++++++++++++++------------ 3 files changed, 347 insertions(+), 160 deletions(-) create mode 100644 packages/meshbay-client/build/stop-node.ps1 (limited to 'packages/meshbay-client') 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 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(); }); -- cgit v1.2.3