diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-05 13:06:55 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-05 13:06:55 +0200 |
| commit | e89a57bb97b5a0d624e8d490b6b8aa38ba140817 (patch) | |
| tree | 3e51dfb630510508cdab16f0b0205772816f4896 /packages | |
| parent | 7601991ccb1d75637c055062c38b1852eeef9700 (diff) | |
| download | meshbay-e89a57bb97b5a0d624e8d490b6b8aa38ba140817.tar.gz | |
fix(win): graceful shutdown, one startup-mode control, and a stray-\r bug
Windows-only changes, all found by actually running the previous session's
work rather than by review alone:
- CTRL_CLOSE_EVENT/LOGOFF/SHUTDOWN handler (platform.py, ctypes
SetConsoleCtrlHandler) so closing a console window, signing off, or a
system shutdown runs the daemon's real _shutdown() instead of Windows
just ending the process — closing WebRTC sessions and any in-flight
ffmpeg transcode instead of orphaning it. `taskkill /F` itself stays
uncatchable (like SIGKILL), so autostart_run() now spawns with
CREATE_NEW_PROCESS_GROUP instead of DETACHED_PROCESS and autostart_end()
tries CTRL_BREAK_EVENT against the recorded pid first, falling back to
the hard kill only if that doesn't stop it in time.
- Replaced the Node page's two independent autostart/service-mode toggles
with one "start automatically" select (off / at sign-in / as a
background service). The old pair let both be active at once — starting
the daemon twice, at boot and at sign-in — and their layout broke
wrapping inside .node-service's flex row. The new control always removes
whichever mechanism is active before installing the target; platform.py's
service_install() does the same on the CLI side. The "background
service" option disables itself (with a hint pointing at the CLI) when
running unpackaged, since service-mode.ps1/service.ps1/firewall.ps1 all
assume an installed build's layout — verified live rather than assumed
by actually running those scripts unelevated.
- findNodeBinary() no longer bakes a stray \r into resolved paths. Found by
rebooting after enabling per-user autostart: where.exe listed two
matches, and stdout.trim().split('\n')[0] only strips the whole string's
ends, leaving line one's own trailing \r attached — which landed inside
the Startup .vbs's quoted path and broke it with "Unterminated string
constant" at boot. Fixed by splitting on \r?\n and trimming every line.
- Dependency audit for the Windows installer (docs/WINDOWS-PORT.md): no
VC++ Redistributable needed, confirmed by inspecting the built
node-runtime's actual import table rather than assuming. New
docs/windows-build.md: a concise clone-to-installer build guide.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'packages')
16 files changed, 558 insertions, 103 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index dd7f38e..e996829 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -813,7 +813,16 @@ function registerBridge() { const cmd = process.platform === 'win32' ? 'where.exe' : 'which'; return new Promise((resolve) => { execFile(cmd, ['meshbay-node'], (err, stdout) => { - resolve(err ? null : stdout.trim().split('\n')[0]); + if (err) { resolve(null); return; } + // where.exe/which can list more than one match on PATH, and each + // line keeps its own trailing \r on Windows -- `stdout.trim()` only + // strips the ends of the *whole* string, so with 2+ matches a stray + // \r stayed glued to the end of the first line. That \r then landed + // inside the quoted path this function's caller writes into the + // Startup .vbs, breaking VBScript's parser with "Unterminated + // string constant" the next time Windows tried to run it at sign-in. + const first = stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean); + resolve(first || null); }); }); } @@ -844,9 +853,24 @@ function registerBridge() { try { fs.rmSync(WIN_STARTUP_VBS, { force: true }); } catch { /* not there */ } } - function killNodeProcesses() { + // 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() { + const bin = await findNodeBinary(); return new Promise((resolve) => { - execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve()); + if (bin) { + execFile(bin, ['autostart', 'stop'], () => resolve()); + } else { + execFile('taskkill', ['/IM', 'meshbay-node.exe', '/F'], () => resolve()); + } }); } @@ -897,7 +921,16 @@ function registerBridge() { return new Promise((resolve, reject) => { const script = path.join(process.resourcesPath, 'service-mode.ps1'); if (!fs.existsSync(script)) { - reject(new Error('service-mode.ps1 not found — only available in an installed build')); + // service-mode.ps1 is an extraResource -- only present once installed + // (package.json build.win.extraResources); nothing under `npm start`. + // The Node page already disables the "background service" option + // when node:service-status reports canElevate: false, so this should + // only ever be reached if that guard is bypassed somehow -- keep the + // message actionable regardless. + reject(new Error( + 'Switching to a background service needs an installed build. For ' + + 'local testing, run "meshbay-node service install" from an ' + + 'elevated PowerShell instead.')); return; } // Start-Process -Verb RunAs is the one UAC prompt; -Wait -PassThru hands @@ -992,6 +1025,12 @@ function registerBridge() { installed: true, activeState: running ? 'active' : 'inactive', subState: svc.state, + // Whether switching startup mode can actually elevate right now — + // service-mode.ps1 is an extraResource, only present in a packaged + // build. Already installed here, so removing it always works + // regardless; this only gates the Node page offering to switch + // *into* service mode. + canElevate: app.isPackaged, }; } // Per-user Startup mode. `installed` used to be winAutostartInstalled(), @@ -1008,6 +1047,7 @@ function registerBridge() { autostart: winAutostartInstalled(), activeState: p ? 'active' : 'inactive', subState: p ? 'running' : '', + canElevate: app.isPackaged, }; } if (process.platform !== 'linux') return { supported: false }; @@ -1038,8 +1078,8 @@ function registerBridge() { if (process.platform === 'win32') { const svc = await winServiceTaskStatus(); if (svc.installed) await winServiceTaskEnd(); - await killNodeProcesses(); // hard kill — no CTRL_CLOSE handler yet; - // also the belt-and-suspenders in case /end left the process running + await killNodeProcesses(); // graceful-then-forceful; also the + // belt-and-suspenders in case /end left the process running return { stopped: true }; } if (process.platform !== 'linux') { 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 bb81fee..49b5f9e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -678,11 +678,13 @@ export default { 'node.service_stopping': 'Wird angehalten…', 'node.service_restart': 'Neu starten', 'node.service_restarting': 'Wird neu gestartet…', - 'node.autostart_label': 'Automatisch bei der Anmeldung starten', - 'node.autostart_updating': 'Wird aktualisiert…', 'node.service_mode_hint': 'Läuft als Hintergrunddienst — startet beim Booten, vor der Anmeldung.', - 'node.service_mode_label': 'Als Hintergrunddienst ausführen (startet beim Booten, vor der Anmeldung)', - 'node.service_mode_updating': 'Modus wird gewechselt — achten Sie auf eine Administrator-Eingabeaufforderung…', + 'node.startup_mode_label': 'Automatisch starten:', + 'node.startup_mode_off': 'Aus (manuell starten)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'Der Hintergrunddienst-Modus erfordert eine installierte Version. Führen Sie zum lokalen Testen "meshbay-node service install" in einer PowerShell mit Administratorrechten aus.', 'node.not_operator': 'Ihr Node konnte nicht erreicht werden. Stellen Sie sicher, dass er läuft.', 'node.offline': 'Node ist offline', 'node.retry': 'Erneut versuchen', 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 2817432..7ea8989 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -734,11 +734,13 @@ export default { 'node.service_stopping': 'Stopping…', 'node.service_restart': 'Restart', 'node.service_restarting': 'Restarting…', - 'node.autostart_label': 'Start automatically at sign-in', - 'node.autostart_updating': 'Updating…', 'node.service_mode_hint': 'Running as a background service — it starts at boot, before sign-in.', - 'node.service_mode_label': 'Run as a background service (starts at boot, before sign-in)', - 'node.service_mode_updating': 'Switching mode — check for an administrator prompt…', + 'node.startup_mode_label': 'Start automatically:', + 'node.startup_mode_off': 'Off (start manually)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'Background service mode needs an installed build. For local testing, run "meshbay-node service install" from an elevated PowerShell.', 'node.offline': 'Node is offline', 'node.no_groups': 'No groups configured on this node.', 'node.retry': 'Retry', 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 0f3ce21..6109d4f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -674,11 +674,13 @@ export default { 'node.service_stopping': 'Deteniendo…', 'node.service_restart': 'Reiniciar', 'node.service_restarting': 'Reiniciando…', - 'node.autostart_label': 'Iniciar automáticamente al iniciar sesión', - 'node.autostart_updating': 'Actualizando…', 'node.service_mode_hint': 'Se ejecuta como servicio en segundo plano — se inicia al arrancar, antes de iniciar sesión.', - 'node.service_mode_label': 'Ejecutar como servicio en segundo plano (se inicia al arrancar, antes de iniciar sesión)', - 'node.service_mode_updating': 'Cambiando de modo — compruebe si aparece un aviso de administrador…', + 'node.startup_mode_label': 'Iniciar automáticamente:', + 'node.startup_mode_off': 'Desactivado (iniciar manualmente)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'El modo de servicio en segundo plano requiere una versión instalada. Para pruebas locales, ejecute "meshbay-node service install" desde una PowerShell con privilegios de administrador.', 'node.not_operator': 'No se pudo contactar con su node. Asegúrese de que esté en ejecución.', 'node.offline': 'Node sin conexión', 'node.retry': 'Reintentar', 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 c3c4846..926f64a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -677,11 +677,13 @@ export default { 'node.service_stopping': 'Arrêt…', 'node.service_restart': 'Redémarrer', 'node.service_restarting': 'Redémarrage…', - 'node.autostart_label': 'Démarrer automatiquement à l\'ouverture de session', - 'node.autostart_updating': 'Mise à jour…', 'node.service_mode_hint': 'Fonctionne comme service en arrière-plan — démarre au boot, avant l\'ouverture de session.', - 'node.service_mode_label': 'Exécuter comme service en arrière-plan (démarre au boot, avant l\'ouverture de session)', - 'node.service_mode_updating': 'Changement de mode — vérifiez une invite d\'administrateur…', + 'node.startup_mode_label': 'Démarrer automatiquement :', + 'node.startup_mode_off': 'Désactivé (démarrage manuel)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'Le mode service en arrière-plan nécessite une version installée. Pour un test local, exécutez "meshbay-node service install" depuis un PowerShell administrateur.', 'node.not_operator': 'Impossible de joindre votre node. Vérifiez qu\'il est en cours d\'exécution.', 'node.offline': 'Node hors ligne', 'node.retry': 'Réessayer', 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 f39ab3c..f7011ec 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -676,11 +676,13 @@ export default { 'node.service_stopping': 'Arresto…', 'node.service_restart': 'Riavvia', 'node.service_restarting': 'Riavvio…', - 'node.autostart_label': 'Avvia automaticamente all\'accesso', - 'node.autostart_updating': 'Aggiornamento…', 'node.service_mode_hint': 'In esecuzione come servizio in background — si avvia all\'avvio del sistema, prima dell\'accesso.', - 'node.service_mode_label': 'Esegui come servizio in background (si avvia all\'avvio del sistema, prima dell\'accesso)', - 'node.service_mode_updating': 'Cambio modalità — controlli se compare una richiesta di amministratore…', + 'node.startup_mode_label': 'Avvia automaticamente:', + 'node.startup_mode_off': 'Disattivato (avvio manuale)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'La modalità servizio in background richiede una build installata. Per test locali, eseguire "meshbay-node service install" da un PowerShell con privilegi di amministratore.', 'node.not_operator': 'Impossibile raggiungere il suo node. Si assicuri che sia in esecuzione.', 'node.offline': 'Node non in linea', 'node.retry': 'Riprova', 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 1544940..592f939 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -664,11 +664,13 @@ export default { 'node.service_stopping': '停止中…', 'node.service_restart': '再起動', 'node.service_restarting': '再起動中…', - 'node.autostart_label': 'サインイン時に自動的に開始する', - 'node.autostart_updating': '更新中…', 'node.service_mode_hint': 'バックグラウンドサービスとして実行中 — サインインより前、起動時に開始します。', - 'node.service_mode_label': 'バックグラウンドサービスとして実行する(サインインより前、起動時に開始)', - 'node.service_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…', + 'node.startup_mode_label': '自動的に開始:', + 'node.startup_mode_off': 'オフ(手動で開始)', + 'node.startup_mode_signin': 'サインイン時', + 'node.startup_mode_service': 'バックグラウンドサービスとして(起動時に開始)', + 'node.startup_mode_updating': 'モードを切り替え中 — 管理者の確認ダイアログをご確認ください…', + 'node.startup_mode_service_unavailable_hint': 'バックグラウンドサービスモードにはインストール済みのビルドが必要です。ローカルでテストする場合は、管理者権限の PowerShell で "meshbay-node service install" を実行してください。', 'node.not_operator': 'node に接続できませんでした。node が実行中であることをご確認ください。', 'node.offline': 'Node はオフラインです', 'node.retry': '再試行', 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 19feec2..eba234e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -678,11 +678,13 @@ export default { 'node.service_stopping': 'Stoppen…', 'node.service_restart': 'Herstarten', 'node.service_restarting': 'Herstarten…', - 'node.autostart_label': 'Automatisch starten bij aanmelden', - 'node.autostart_updating': 'Bijwerken…', 'node.service_mode_hint': 'Actief als achtergrondservice — start bij het opstarten, vóór het aanmelden.', - 'node.service_mode_label': 'Uitvoeren als achtergrondservice (start bij het opstarten, vóór het aanmelden)', - 'node.service_mode_updating': 'Modus wijzigen — let op een beheerdersprompt…', + 'node.startup_mode_label': 'Automatisch starten:', + 'node.startup_mode_off': 'Uit (handmatig starten)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'Achtergrondservice-modus vereist een geïnstalleerde build. Voer voor lokaal testen "meshbay-node service install" uit vanuit een PowerShell met beheerdersrechten.', 'node.not_operator': 'Uw node is niet bereikbaar. Controleer of hij draait.', 'node.offline': 'Node is offline', 'node.retry': 'Opnieuw proberen', 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 512c4f3..999e689 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -696,11 +696,13 @@ export default { 'node.service_stopping': 'Zatrzymywanie…', 'node.service_restart': 'Uruchom ponownie', 'node.service_restarting': 'Ponowne uruchamianie…', - 'node.autostart_label': 'Uruchamiaj automatycznie przy logowaniu', - 'node.autostart_updating': 'Aktualizowanie…', 'node.service_mode_hint': 'Działa jako usługa w tle — uruchamia się przy starcie systemu, przed zalogowaniem.', - 'node.service_mode_label': 'Uruchom jako usługę w tle (uruchamia się przy starcie systemu, przed zalogowaniem)', - 'node.service_mode_updating': 'Zmiana trybu — proszę sprawdzić, czy pojawiło się okno uprawnień administratora…', + 'node.startup_mode_label': 'Uruchamiaj automatycznie:', + 'node.startup_mode_off': 'Wyłączone (uruchamianie ręczne)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'Tryb usługi w tle wymaga zainstalowanej wersji. Aby przetestować lokalnie, uruchom "meshbay-node service install" w PowerShell z uprawnieniami administratora.', 'node.not_operator': 'Nie udało się połączyć z Pana/Pani node. Upewnij się, że działa.', 'node.offline': 'Node jest niedostępny', 'node.retry': 'Ponów', 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 8e33d74..79d2079 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 @@ -675,11 +675,13 @@ export default { 'node.service_stopping': 'Parando…', 'node.service_restart': 'Reiniciar', 'node.service_restarting': 'Reiniciando…', - 'node.autostart_label': 'Iniciar automaticamente ao entrar na sessão', - 'node.autostart_updating': 'Atualizando…', 'node.service_mode_hint': 'Em execução como serviço em segundo plano — inicia na inicialização, antes do login.', - 'node.service_mode_label': 'Executar como serviço em segundo plano (inicia na inicialização, antes do login)', - 'node.service_mode_updating': 'Alternando modo — verifique se aparece um aviso de administrador…', + 'node.startup_mode_label': 'Iniciar automaticamente:', + 'node.startup_mode_off': 'Desativado (iniciar manualmente)', + '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…', + 'node.startup_mode_service_unavailable_hint': 'O modo de serviço em segundo plano requer uma versão instalada. Para testes locais, execute "meshbay-node service install" em um PowerShell com privilégios de administrador.', 'node.not_operator': 'Não foi possível alcançar seu node. Verifique se ele está em execução.', 'node.offline': 'Node está off-line', 'node.retry': 'Tentar novamente', 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 299dc4f..d67c34b 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 @@ -651,11 +651,13 @@ export default { 'node.service_stopping': '正在停止…', 'node.service_restart': '重启', 'node.service_restarting': '正在重启…', - 'node.autostart_label': '登录时自动启动', - 'node.autostart_updating': '正在更新…', 'node.service_mode_hint': '以后台服务方式运行 — 在开机时启动,早于登录。', - 'node.service_mode_label': '以后台服务方式运行(在开机时启动,早于登录)', - 'node.service_mode_updating': '正在切换模式 — 请留意管理员权限提示…', + 'node.startup_mode_label': '自动启动:', + 'node.startup_mode_off': '关闭(手动启动)', + 'node.startup_mode_signin': '登录时', + 'node.startup_mode_service': '作为后台服务(开机时启动)', + 'node.startup_mode_updating': '正在切换模式 — 请留意管理员权限提示…', + 'node.startup_mode_service_unavailable_hint': '后台服务模式需要已安装的版本。如需本地测试,请在具有管理员权限的 PowerShell 中运行 "meshbay-node service install"。', 'node.not_operator': '无法连接到您的 node。请确保它正在运行。', 'node.offline': 'Node 已离线', 'node.retry': '重试', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index 2a0b0d7..ec73128 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -47,21 +47,35 @@ function NodeServicePanel({ onChanged }) { } }, [refresh, onChanged]); - const toggleAutostart = useCallback(() => { - act('autostart', () => (info && info.autostart - ? platform.node.autostart.remove() - : platform.node.autostart.install())); - }, [act, info]); + // "off" / "signin" / "service" -- derived from the status payload, no new + // backend field needed: mode/autostart already distinguish all three. + const startupMode = (i) => { + if (!i) return 'off'; + if (i.mode === 'service') return i.mode; + return i.autostart ? 'signin' : 'off'; + }; // Switching mode itself — the installer's own choice is effectively one-shot // (it skips the question once the firewall rules exist for any reason, and // per-user mode sets those up on its own with no Scheduled Task), so this is // the only way back in if service mode was declined, or out if it is no // longer wanted. One elevation, task + firewall together, same script. - const toggleServiceMode = useCallback(() => { - act('serviceMode', () => (info && info.mode === 'service' - ? platform.node.serviceMode.remove() - : platform.node.serviceMode.install())); + // + // The two mechanisms are mutually exclusive by construction here: never + // both installed at once, which would start the daemon twice (once at + // boot via the Scheduled Task, again at sign-in via the Startup .vbs). + // Always remove whichever one is currently active before installing the + // target, so every transition -- not just the two that used to be + // separate toggles -- keeps that invariant. + const changeStartupMode = useCallback((target) => { + const current = startupMode(info); + if (target === current) return; + act('startupMode', async () => { + if (current === 'service') await platform.node.serviceMode.remove(); + else if (current === 'signin') await platform.node.autostart.remove(); + if (target === 'service') await platform.node.serviceMode.install(); + else if (target === 'signin') await platform.node.autostart.install(); + }); }, [act, info]); if (!platform.node.service.available) return null; @@ -79,44 +93,53 @@ function NodeServicePanel({ onChanged }) { const label = info.installed ? t('node.service_state_' + stateKey) : t('node.service_not_installed'); + // Own row, below the status/actions card rather than a further item + // crammed into its flex-wrap line -- that (plus two independent toggles + // for what is really one choice) is what made this a mess before. + const showStartupRow = (platform.node.autostart.available + || platform.node.serviceMode.available) && typeof info.mode === 'string'; + return html` - <div class="node-service"> - <div class="node-service-status"> - <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span> - <span>${label}</span> - </div> - ${err && html`<div class="error-msg">${err}</div>`} - <div class="node-service-actions"> - <button class="btn btn-small btn-secondary" disabled=${!!busy || running} - onClick=${() => act('start', () => platform.node.start())}> - ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button> - ${info.installed && html` - <button class="btn btn-small btn-secondary" disabled=${!!busy || !running} - onClick=${() => act('stop', () => platform.node.service.stop())}> - ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button> - <button class="btn btn-small btn-secondary" disabled=${!!busy} - onClick=${() => act('restart', () => platform.node.service.restart())}> - ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button> + <div> + <div class="node-service"> + <div class="node-service-status"> + <span class="presence presence-${dot}" title="${label}" aria-label="${label}"></span> + <span>${label}</span> + </div> + ${err && html`<div class="error-msg">${err}</div>`} + <div class="node-service-actions"> + <button class="btn btn-small btn-secondary" disabled=${!!busy || running} + onClick=${() => act('start', () => platform.node.start())}> + ${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button> + ${info.installed && html` + <button class="btn btn-small btn-secondary" disabled=${!!busy || !running} + onClick=${() => act('stop', () => platform.node.service.stop())}> + ${busy === 'stop' ? t('node.service_stopping') : t('node.service_stop')}</button> + <button class="btn btn-small btn-secondary" disabled=${!!busy} + onClick=${() => act('restart', () => platform.node.service.restart())}> + ${busy === 'restart' ? t('node.service_restarting') : t('node.service_restart')}</button> + `} + </div> + ${info.mode === 'service' && html` + <p class="node-hint">${t('node.service_mode_hint')}</p> `} </div> - ${info.mode === 'service' && html` - <p class="node-hint">${t('node.service_mode_hint')}</p> - `} - ${platform.node.autostart.available && typeof info.autostart === 'boolean' && html` - <label class="toggle-switch ${busy ? 'toggle-switch-disabled' : ''}"> - <input type="checkbox" checked=${info.autostart} disabled=${!!busy} - onChange=${toggleAutostart} /> - <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> - ${' '}${busy === 'autostart' ? t('node.autostart_updating') : t('node.autostart_label')} - </label> - `} - ${platform.node.serviceMode.available && typeof info.mode === 'string' && html` - <label class="toggle-switch ${busy ? 'toggle-switch-disabled' : ''}"> - <input type="checkbox" checked=${info.mode === 'service'} disabled=${!!busy} - onChange=${toggleServiceMode} /> - <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> - ${' '}${busy === 'serviceMode' ? t('node.service_mode_updating') : t('node.service_mode_label')} - </label> + ${showStartupRow && html` + <div class="settings-row"> + <span class="settings-label">${t('node.startup_mode_label')}</span> + <select class="settings-select" disabled=${!!busy} + value=${startupMode(info)} + onChange=${(e) => changeStartupMode(e.target.value)}> + <option value="off">${t('node.startup_mode_off')}</option> + <option value="signin">${t('node.startup_mode_signin')}</option> + <option value="service" disabled=${!info.canElevate}> + ${t('node.startup_mode_service')}</option> + </select> + </div> + ${busy === 'startupMode' && html` + <p class="settings-hint">${t('node.startup_mode_updating')}</p>`} + ${!info.canElevate && html` + <p class="settings-hint">${t('node.startup_mode_service_unavailable_hint')}</p>`} `} </div>`; } diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index b932c16..ea13680 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -678,9 +678,18 @@ class NodeDaemon: # 12. Wait for shutdown stop_event = asyncio.Event() loop = asyncio.get_event_loop() + console_shutdown_done = None if sys.platform == "win32": - for sig in (signal.SIGINT, signal.SIGTERM): + # SIGBREAK: CTRL_BREAK_EVENT, how platform.autostart_end() asks + # a per-user-mode daemon to stop gracefully instead of only + # ever taskkill /F. + for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGBREAK): signal.signal(sig, lambda *_: stop_event.set()) + # CTRL_CLOSE/LOGOFF/SHUTDOWN reach no Python signal at all -- + # see platform.install_console_close_handler for why this is + # a separate mechanism rather than another signal.signal() line. + from meshbay_node.platform import install_console_close_handler + console_shutdown_done = install_console_close_handler(loop, stop_event) else: for sig in (signal.SIGINT, signal.SIGTERM): loop.add_signal_handler(sig, stop_event.set) @@ -694,6 +703,13 @@ class NodeDaemon: await stop_event.wait() await self._shutdown() + if console_shutdown_done is not None: + # Releases the console-control handler's blocking wait (see + # platform.install_console_close_handler) so it can return and + # let Windows actually end the process for CTRL_CLOSE/LOGOFF/ + # SHUTDOWN, now that cleanup is genuinely done rather than just + # started. + console_shutdown_done.set() async def _reload_config(self) -> None: """ diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py index 9b18618..981d466 100644 --- a/packages/meshbay-node/src/meshbay_node/platform.py +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -1,12 +1,18 @@ """Platform-specific paths and tool resolution for meshbay-node.""" import asyncio +import logging import os import shutil +import signal import subprocess import sys +import threading +import time from pathlib import Path +log = logging.getLogger(__name__) + # ── Console ────────────────────────────────────────────────────────────────── @@ -213,6 +219,24 @@ def _startup_vbs() -> Path: / "Startup" / "MeshBay Node.vbs") +def _pid_file() -> Path: + """Where autostart_run() records the pid it spawned, for autostart_end() + to signal later -- possibly from a different process (a new Electron + session, or a fresh CLI invocation), so this cannot be an in-memory + handle.""" + return state_dir() / "node.pid" + + +def _pid_is_meshbay_node(pid: int) -> bool: + """True if `pid` is currently running *and* is meshbay-node.exe. Guards + against a stale pidfile whose pid Windows has since handed to an + unrelated process -- autostart_end() would otherwise send CTRL_BREAK_EVENT + to whatever that is instead.""" + r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH"], + capture_output=True, text=True) + return "meshbay-node.exe" in r.stdout.lower() + + def _node_exe() -> str | None: """Best guess at the meshbay-node launcher: PATH first, then next to the interpreter (a venv's Scripts/ dir, or a bundled runtime), then argv[0].""" @@ -260,22 +284,76 @@ def autostart_remove() -> None: def autostart_run() -> None: - """Start the daemon now, detached and windowless. Raises RuntimeError if - the launcher cannot be located.""" + """Start the daemon now, windowless. Raises RuntimeError if the launcher + cannot be located.""" if not autostart_supported(): raise RuntimeError("autostart is Windows-only") exe = _node_exe() if not exe: raise RuntimeError("cannot locate the meshbay-node launcher") - subprocess.Popen([exe], creationflags=0x00000008 | 0x08000000, # DETACHED | NO_WINDOW - close_fds=True) + # CREATE_NEW_PROCESS_GROUP, not DETACHED_PROCESS: still no visible window + # (CREATE_NO_WINDOW), but the child keeps a console object of its own and + # becomes the root of its own process group -- what autostart_end() needs + # to target it with CTRL_BREAK_EVENT instead of only ever a hard taskkill. + # DETACHED_PROCESS has no console at all, so nothing could be signalled. + proc = subprocess.Popen([exe], creationflags=0x00000200 | 0x08000000, + close_fds=True) + try: + pid_file = _pid_file() + pid_file.parent.mkdir(parents=True, exist_ok=True) + pid_file.write_text(str(proc.pid), encoding="utf-8") + except OSError: + pass # best effort -- autostart_end() falls back to taskkill by image name + + +# How long autostart_end() waits for a graceful CTRL_BREAK_EVENT stop before +# giving up and force-killing. A chosen grace period, not an OS-enforced one +# (unlike the ~5 s Windows itself allows a CTRL_CLOSE/LOGOFF/SHUTDOWN handler, +# see install_console_close_handler below -- CTRL_BREAK carries no such ceiling). +_GRACEFUL_STOP_TIMEOUT_SECS = 5.0 def autostart_end() -> None: - """Stop any running daemon (hard: there is no CTRL_CLOSE handler yet).""" - if autostart_supported(): - subprocess.run(["taskkill", "/IM", "meshbay-node.exe", "/F"], - capture_output=True) + """ + Stop the running daemon. + + Tries a graceful stop first: CTRL_BREAK_EVENT to the pid autostart_run() + recorded. Because that process is the root of its own group + (CREATE_NEW_PROCESS_GROUP), daemon.py's own SIGBREAK handler turns this + into the same stop_event.set() SIGINT/SIGTERM already use, running the + real _shutdown() -- closes WebRTC sessions, kills any in-flight ffmpeg + transcode. Falls back to a hard `taskkill /F`, by image name, when there + is no pidfile, the recorded process is already gone, or it does not exit + within the grace period -- same as before this existed, just no longer + the only path. `taskkill /F` itself is TerminateProcess and cannot be made + graceful; nothing can catch it, on any OS. + """ + if not autostart_supported(): + return + pid_file = _pid_file() + try: + pid = int(pid_file.read_text(encoding="utf-8").strip()) + except (OSError, ValueError): + pid = None + if pid is not None and not _pid_is_meshbay_node(pid): + pid = None # stale pidfile -- Windows may have reused the pid since + if pid is not None: + try: + os.kill(pid, signal.CTRL_BREAK_EVENT) + except OSError: + pid = None # already gone, or never existed + else: + deadline = time.monotonic() + _GRACEFUL_STOP_TIMEOUT_SECS + while time.monotonic() < deadline: + if not _pid_is_meshbay_node(pid): + pid_file.unlink(missing_ok=True) + return + time.sleep(0.2) + log.warning("pid %d did not exit within %.1fs of CTRL_BREAK_EVENT, " + "falling back to taskkill /F", pid, _GRACEFUL_STOP_TIMEOUT_SECS) + pid_file.unlink(missing_ok=True) + subprocess.run(["taskkill", "/IM", "meshbay-node.exe", "/F"], + capture_output=True) # ── Service mode (Windows, opt-in at install time) ─────────────────────────── @@ -340,9 +418,17 @@ def service_install(exe: str | None = None) -> None: Register the boot-time Scheduled Task. Needs admin — raises RuntimeError with schtasks' own message on failure, which is "Access is denied." when not elevated. + + Removes the per-user Startup launcher first, if present: the two + mechanisms are mutually exclusive by design (both installed would start + the daemon twice, once at boot and again at sign-in), and this is a + separate front door from the Node page's own startup-mode selector (which + enforces the same thing on its side) -- the CLI (`meshbay-node service + install`) must not be able to leave that invariant broken. """ if not service_supported(): raise RuntimeError("service mode is Windows-only") + autostart_remove() exe = exe or _node_exe() if not exe: raise RuntimeError( @@ -374,3 +460,70 @@ def service_end() -> None: """Stop the running instance, if any. No admin needed.""" if service_supported(): _schtasks("/end", "/tn", TASK_NAME) + + +# ── Console close / logoff / shutdown handler (Windows) ────────────────────── +# +# CPython's own console handler claims CTRL_C_EVENT and CTRL_BREAK_EVENT -- +# delivered as SIGINT/SIGBREAK, handled in daemon.py's win32 signal block -- +# but returns "not handled" for CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT and +# CTRL_SHUTDOWN_EVENT: there is no Python signal for any of the three. Without +# a handler of our own, Windows just ends the process for these -- no +# _shutdown(), no closed WebRTC sessions, no killed ffmpeg. Covers: closing +# the console window of an interactively-run `meshbay-node run`, user logoff, +# system shutdown. Does NOT cover `taskkill /F` -- TerminateProcess is +# uncatchable on any OS, the same as SIGKILL; see autostart_end() for how the +# Node page's Stop button avoids relying on it instead. + +_CONSOLE_HANDLER_REFS: list = [] # ctypes callbacks must be kept referenced or they may be freed + +CTRL_CLOSE_EVENT = 2 +CTRL_LOGOFF_EVENT = 5 +CTRL_SHUTDOWN_EVENT = 6 + + +def install_console_close_handler( + loop: asyncio.AbstractEventLoop, stop_event: asyncio.Event, +) -> "threading.Event | None": + """ + Register the handler. Returns a threading.Event the caller must set once + its own graceful shutdown has actually finished -- daemon.py does this + right after `await self._shutdown()` -- or None off-Windows, or if + registration itself failed (logged, not raised: losing this is a + regression, refusing to start the daemon over it would not be). + + MSDN: for these three events the process is ended "after the process + returns from the handler function, or after 5 seconds, whichever occurs + first" -- so the handler, which Windows runs on a thread of its own and + never the main one, blocks here instead of returning immediately, and + nudges the asyncio loop the thread-safe way since it is not the loop's + own thread. The wait is capped just under that ceiling so the process + still exits by itself if cleanup runs long, rather than the OS treating an + unresponsive handler as a hang. + """ + if sys.platform != "win32": + return None + import ctypes + from ctypes import wintypes + + handled = {CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT} + shutdown_done = threading.Event() + handler_type = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD) + + def _handler(ctrl_type: int) -> bool: + if ctrl_type not in handled: + return False # not ours -- let Python's own handler or the default action take it + log.info("Console control event %d (close/logoff/shutdown) -- shutting down", ctrl_type) + loop.call_soon_threadsafe(stop_event.set) + shutdown_done.wait(4.5) + return True + + handler_ref = handler_type(_handler) + if not ctypes.windll.kernel32.SetConsoleCtrlHandler(handler_ref, True): + log.warning("SetConsoleCtrlHandler failed (%s) -- closing the console window, " + "logging off or shutting down will not run a clean shutdown; " + "SIGINT/SIGTERM/SIGBREAK are unaffected", + ctypes.WinError()) + return None + _CONSOLE_HANDLER_REFS.append(handler_ref) + return shutdown_done diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 21aaf29..0cf6367 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -71,6 +71,25 @@ def test_the_node_runtime_is_carried_as_an_extraresource(): "extraResources puts it") +def test_find_node_binary_strips_stray_cr_from_multiline_where_output(): + """ + where.exe/which can list more than one match on PATH, and each line + keeps its own trailing \\r on Windows. `stdout.trim().split('\\n')[0]` + only strips the ends of the *whole* string, so with 2+ matches a stray + \\r stayed glued to the end of the first line -- which then landed + inside the quoted path written into the Startup .vbs and broke + VBScript's parser with "Unterminated string constant" the next time + Windows ran it at sign-in. Reproduced live 2026-09-05 (this user's own + machine has both a dev venv and an installed build on PATH) and fixed + by splitting on \\r?\\n and trimming each candidate line individually. + """ + main_js = (CLIENT / "src" / "main.js").read_text(encoding="utf-8") + assert "stdout.split(/\\r?\\n/)" in main_js, ( + "findNodeBinary must split where.exe/which output on \\r?\\n and " + "trim each line, not a single stdout.trim() over the whole blob") + assert "stdout.trim().split('\\n')[0]" not in main_js + + def test_firewall_helper_is_carried_as_an_extraresource(): """packaging/win/firewall.ps1 must ride into resources/, at the fixed path installer.nsh invokes it from ($INSTDIR\\resources\\firewall.ps1).""" diff --git a/packages/meshbay-node/tests/test_platform.py b/packages/meshbay-node/tests/test_platform.py index 7042afb..91713be 100644 --- a/packages/meshbay-node/tests/test_platform.py +++ b/packages/meshbay-node/tests/test_platform.py @@ -164,14 +164,26 @@ def test_autostart_install_refuses_off_windows(monkeypatch): plat.autostart_install(exe="/usr/bin/meshbay-node") -def test_autostart_run_launches_the_resolved_exe_detached(win_startup, monkeypatch): +def test_autostart_run_launches_the_resolved_exe_windowless_and_records_its_pid( + win_startup, monkeypatch, tmp_path): monkeypatch.setattr(plat, "_node_exe", lambda: r"C:\x\meshbay-node.exe") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) # state_dir() -> pidfile location calls = {} - monkeypatch.setattr(plat.subprocess, "Popen", - lambda argv, **kw: calls.update(argv=argv, kw=kw)) + + def fake_popen(argv, **kw): + calls.update(argv=argv, kw=kw) + return Mock(pid=4242) + + monkeypatch.setattr(plat.subprocess, "Popen", fake_popen) plat.autostart_run() assert calls["argv"] == [r"C:\x\meshbay-node.exe"] - assert calls["kw"]["creationflags"] & 0x08000000 # CREATE_NO_WINDOW + flags = calls["kw"]["creationflags"] + assert flags & 0x08000000 # CREATE_NO_WINDOW + assert flags & 0x00000200 # CREATE_NEW_PROCESS_GROUP + assert not flags & 0x00000008 # not DETACHED_PROCESS -- that has no + # console at all, so CTRL_BREAK_EVENT + # would have nothing to signal + assert plat._pid_file().read_text(encoding="utf-8") == "4242" def test_autostart_run_refuses_off_windows(monkeypatch): @@ -180,6 +192,178 @@ def test_autostart_run_refuses_off_windows(monkeypatch): plat.autostart_run() +# ── Graceful stop (CTRL_BREAK_EVENT + taskkill fallback) ──────────────────── +# +# autostart_end() references signal.CTRL_BREAK_EVENT, which genuinely does not +# exist in the `signal` module off Windows -- monkeypatching sys.platform +# cannot manufacture it, unlike the pure-Python behaviour tested above. Skip +# rather than mock around it, matching test_configure_event_loop_selector_opt_in. + +@pytest.mark.skipif(sys.platform != "win32", + reason="signal.CTRL_BREAK_EVENT exists only on win32") +def test_autostart_end_stops_gracefully_when_ctrl_break_is_enough(monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + plat._pid_file().parent.mkdir(parents=True, exist_ok=True) + plat._pid_file().write_text("4242", encoding="utf-8") + + kill_calls = [] + monkeypatch.setattr(plat.os, "kill", lambda pid, sig: kill_calls.append((pid, sig))) + # Alive (our exe) on the pre-signal check, gone by the first poll after -- + # a plain constant can't tell those two calls apart. + seen = {"n": 0} + + def fake_check(pid): + seen["n"] += 1 + return seen["n"] == 1 + + monkeypatch.setattr(plat, "_pid_is_meshbay_node", fake_check) + run_calls = [] + monkeypatch.setattr(plat.subprocess, "run", + lambda argv, **kw: run_calls.append(argv)) + + plat.autostart_end() + + assert kill_calls == [(4242, plat.signal.CTRL_BREAK_EVENT)] + assert run_calls == [] # no taskkill needed + assert not plat._pid_file().exists() + + +@pytest.mark.skipif(sys.platform != "win32", + reason="signal.CTRL_BREAK_EVENT exists only on win32") +def test_autostart_end_falls_back_to_taskkill_when_the_pid_never_exits( + monkeypatch, tmp_path): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + plat._pid_file().parent.mkdir(parents=True, exist_ok=True) + plat._pid_file().write_text("4242", encoding="utf-8") + + monkeypatch.setattr(plat.os, "kill", lambda pid, sig: None) + monkeypatch.setattr(plat, "_pid_is_meshbay_node", lambda pid: True) # never exits + monkeypatch.setattr(plat.time, "sleep", lambda s: None) # don't really wait + clock = iter([0.0, 1.0, 6.0]) # deadline = 0.0 + 5.0; third read is past it + monkeypatch.setattr(plat.time, "monotonic", lambda: next(clock)) + run_calls = [] + monkeypatch.setattr(plat.subprocess, "run", + lambda argv, **kw: run_calls.append(argv)) + + plat.autostart_end() + + assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]] + assert not plat._pid_file().exists() + + +def test_autostart_end_falls_back_to_taskkill_without_a_pidfile(monkeypatch, tmp_path): + """No CTRL_BREAK_EVENT dependency here -- there is no pid to signal, so + this one runs everywhere, same as the pre-existing behaviour it replaces.""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + run_calls = [] + monkeypatch.setattr(plat.subprocess, "run", + lambda argv, **kw: run_calls.append(argv)) + plat.autostart_end() + assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]] + + +def test_autostart_end_ignores_a_stale_pid_reused_by_another_process(monkeypatch, tmp_path): + """The recorded pid is alive but is not meshbay-node.exe -- Windows reused + it after the daemon exited. Must not send CTRL_BREAK_EVENT to whatever + that is; falls straight to taskkill (by image name, so harmless here).""" + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("LOCALAPPDATA", str(tmp_path)) + plat._pid_file().parent.mkdir(parents=True, exist_ok=True) + plat._pid_file().write_text("4242", encoding="utf-8") + + monkeypatch.setattr(plat, "_pid_is_meshbay_node", lambda pid: False) + kill_calls = [] + monkeypatch.setattr(plat.os, "kill", lambda pid, sig: kill_calls.append((pid, sig))) + run_calls = [] + monkeypatch.setattr(plat.subprocess, "run", + lambda argv, **kw: run_calls.append(argv)) + + plat.autostart_end() + + assert kill_calls == [] # never signalled the reused pid + assert run_calls == [["taskkill", "/IM", "meshbay-node.exe", "/F"]] + assert not plat._pid_file().exists() + + +def test_autostart_end_is_a_noop_off_windows(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + run_calls = [] + monkeypatch.setattr(plat.subprocess, "run", + lambda argv, **kw: run_calls.append(argv)) + plat.autostart_end() + assert run_calls == [] + + +# ── Console close / logoff / shutdown handler ─────────────────────────────── + +def test_install_console_close_handler_is_a_noop_off_windows(monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + loop = Mock() + stop_event = Mock() + assert plat.install_console_close_handler(loop, stop_event) is None + + +@pytest.mark.skipif(sys.platform != "win32", + reason="ctypes.windll/wintypes exist only on win32") +def test_install_console_close_handler_registers_and_the_callback_sets_stop_event(): + import asyncio as _asyncio + + loop = _asyncio.new_event_loop() + try: + stop_event = _asyncio.Event() + shutdown_done = plat.install_console_close_handler(loop, stop_event) + assert shutdown_done is not None + # Drive the registered handler directly rather than actually closing a + # console window -- exercises the same code path SetConsoleCtrlHandler + # would invoke, without needing a live console to close. + handler = plat._CONSOLE_HANDLER_REFS[-1] + shutdown_done.set() # so the handler's bounded wait returns immediately + # ctypes marshals the WINFUNCTYPE's BOOL restype back as a plain int + # (1/0), not a Python bool, when called directly like this. + assert handler(plat.CTRL_CLOSE_EVENT) + loop.run_until_complete(_asyncio.sleep(0)) # let call_soon_threadsafe land + assert stop_event.is_set() + # An event this handler does not own (CTRL_C_EVENT) is left unhandled + # so Python's own console handler (or the default action) gets it. + assert not handler(0) + finally: + loop.close() + + +# ── Service mode ───────────────────────────────────────────────────────────── + +def test_service_install_removes_the_startup_launcher_first(win_startup, monkeypatch): + """The two mechanisms are mutually exclusive by design -- both installed + would start the daemon twice, once at boot and again at sign-in. This is + the CLI's own front door to that invariant, separate from (but agreeing + with) the Node page's startup-mode selector.""" + plat.autostart_install(exe=r"C:\x\meshbay-node.exe") + assert win_startup.exists() + + monkeypatch.setattr(plat, "_current_user", lambda: "DOMAIN\\user") + calls = [] + monkeypatch.setattr( + plat, "_schtasks", + lambda *args: calls.append(args) or Mock(returncode=0, stdout="", stderr="")) + + plat.service_install(exe=r"C:\x\meshbay-node.exe") + + assert not win_startup.exists() # removed as part of service_install + assert calls and calls[0][0] == "/create" + + +def test_service_install_tolerates_no_startup_launcher_present(win_startup, monkeypatch): + assert not win_startup.exists() + monkeypatch.setattr(plat, "_current_user", lambda: "DOMAIN\\user") + monkeypatch.setattr(plat, "_schtasks", + lambda *args: Mock(returncode=0, stdout="", stderr="")) + plat.service_install(exe=r"C:\x\meshbay-node.exe") # no error + assert not win_startup.exists() + + # ── Packaged defaults ──────────────────────────────────────────────────────── def test_frozen_build_finds_default_env_beside_the_executable(monkeypatch, tmp_path): |