From e12570f9d1aa2645e6bb223b1417fa0e81957b65 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Mon, 14 Sep 2026 23:51:36 +0200 Subject: fix(win): a service-mode daemon can be replaced, and the Node page can link one Two live-reproduced bugs in Windows node start/stop, found sideloading the 0.14.0 build: - node:start's crash-recovery step killed a service-mode daemon with taskkill/CTRL_BREAK, both of which fail with "Access is denied" against a process running under the Scheduled Task's own S4U logon session (a different session from the Electron app's). The daemon it was meant to replace just kept running, unreplaced, and schtasks /run on a task Windows still considered Running was then a silent no-op too. Route through winServiceTaskEnd() (schtasks /end) first, the way nodeServiceStop/ nodeServiceRestart already correctly do. service-mode.ps1 also now starts the task right after registering it -- Register-ScheduledTask's own AtStartup trigger does not run it immediately, so nothing was listening until the next reboot. - The Node page's Start button called node.start() with no arguments, so an unlinked node (a fresh install, or one whose hub-side link was lost) could never link on Start alone -- only create-group-page.js's own call passed {hubUrl, username, token}. Reproduced on a fresh non-service install signed in to the real hub: Start hung for ~105s and failed with "could not link", pointing at a "Link Node" control that lives on Settings, not the Node page (that message is fixed too). Co-Authored-By: Claude Sonnet 5 --- packages/meshbay-node/tests/test_packaging_win.py | 73 +++++++++++++++++++++++ 1 file changed, 73 insertions(+) (limited to 'packages/meshbay-node') diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index 6f7573b..877994d 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -1216,3 +1216,76 @@ def test_node_startup_hint_key_exists_in_all_ten_locales(): for name in ("en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"): cat = (HUB_STATIC / "locales" / f"{name}.js").read_text(encoding="utf-8") assert "'setup.node_startup_hint':" in cat, f"{name}.js is missing the key" + + +def test_service_mode_ps1_starts_the_task_after_installing_it(): + """ + Register-ScheduledTask -Trigger AtStartup registers a boot trigger; it + does not run the task now. Neither the installer's "background service" + choice nor the Node page's later toggle followed up with `service.ps1 + run` -- reproduced live 2026-09-14 on a fresh install: the task existed, + Task Scheduler agreed it was installed, and nothing was listening on + 18000 until either a reboot or a separate manual Start. Both callers + share this one elevated script, so the fix belongs here, once. + + Must run only after a successful install, and never on `remove` -- a + script with no failure branch that always called `run` would restart an + already-running task pointlessly on every mode switch away from service. + """ + src = (WIN / "service-mode.ps1").read_text(encoding="utf-8") + install_branch, remove_branch = src.split('$Action -eq "install"', 1)[1], None + assert '"run"' in install_branch or "'run'" in install_branch, ( + "service-mode.ps1 registers the task but never starts it -- the " + "daemon stays down until the next reboot") + # The run step must be conditioned on install having actually succeeded, + # not fired unconditionally regardless of $Action. + guard_line = src[src.index('$Action -eq "install"') - 40:src.index('$Action -eq "install"') + 40] + assert "-and" in guard_line or "-not $failed" in install_branch, ( + "the follow-up `run` must be gated on Action=install and success, " + "not run unconditionally on every invocation including remove") + + +def test_node_start_ends_a_service_mode_daemon_via_task_scheduler_not_taskkill(): + """ + A service-mode daemon runs under the Scheduled Task's own S4U logon + session, a different one from the Electron app's. killNodeProcesses() + signals or taskkills by image name/pid from THIS session -- reproduced + live 2026-09-14: `taskkill /IM meshbay-node.exe /F` against the exact + live pid of an S4U-launched "MeshBay Node" task instance answered + "Access is denied", while `schtasks /end /tn "MeshBay Node"` against the + same pid, from the same unelevated shell, succeeded immediately -- Task + Scheduler holds the authority to stop what it started; this process does + not. + + killNodeProcesses()'s own error is swallowed (fire-and-forget, `() => + resolve()`), so the old bug was silent: node:start's "clear a + crash-looping one" step did nothing to a stuck service-mode instance, + and the winServiceTaskRun() that followed was then a no-op too (a task + Windows still considers Running does not get a second concurrent + instance under the default multiple-instances policy). The daemon a + broken Start was supposed to replace just kept running, unreplaced, + until a reboot. nodeServiceStop/nodeServiceRestart already route through + winServiceTaskEnd() first for exactly this reason -- node:start must too. + """ + 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'")] + + 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") -- cgit v1.2.3