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-node/tests/test_packaging_win.py | 474 ++++++++++++++++++---- 1 file changed, 404 insertions(+), 70 deletions(-) (limited to 'packages/meshbay-node/tests/test_packaging_win.py') 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") + 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") - body = _macro_body(nsh, "customInstall") - assert "taskkill /IM meshbay-node.exe /F" in body + 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_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_ever_touches_the_service_task(): +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") + + +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_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") - - 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_ -- cgit v1.2.3