summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-14 23:51:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-14 23:51:36 +0200
commite12570f9d1aa2645e6bb223b1417fa0e81957b65 (patch)
treed3eda196ed925dc4fbedcb7671b38bd1f05f552d /packages
parentcd2745cecff12e894e0dfa702bff6a90f0e8734e (diff)
downloadmeshbay-e12570f9d1aa2645e6bb223b1417fa0e81957b65.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-client/src/main.js22
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js23
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py50
-rw-r--r--packages/meshbay-node/tests/test_packaging_win.py73
5 files changed, 160 insertions, 10 deletions
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index bebe144..f201a21 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -1567,11 +1567,24 @@ function registerBridge() {
if (process.platform === 'win32') {
if (opts && opts.hubUrl && opts.username) provisionNode(opts.hubUrl, opts.username);
- await killNodeProcesses(); // clear a crash-looping one
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();
}
const p = await waitForNode(Date.now() + 60000);
@@ -1585,9 +1598,12 @@ function registerBridge() {
? p
: await linkNodeKeyAndAwaitRunning(opts, Date.now() + 45000);
if (!ready || 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).
throw new Error(
- 'the node started but could not link to your hub account. Open the '
- + 'Node page and use "Link this node", or check you are signed in to '
+ 'the node started but could not link to your hub account. Open '
+ + 'Settings and use "Link Node", or check you are signed in to '
+ 'the hub this node is configured for.');
}
return { started: true, ...ready };
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 87c4e33..367774f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1042,7 +1042,7 @@ function App() {
.catch(() => {});
}} />`;
} else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) {
- page = html`<${LazyNodePage} groups=${groups} />`;
+ page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`;
} else if (route.startsWith('/group/')) {
const groupId = route.slice(7);
const group = groups.find(g => g.id === groupId);
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 6dcaa58..0f75edc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js
@@ -4,6 +4,7 @@ import {
import { t } from './i18n.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
+import { HUB } from './hub-client.js';
// ── Node management (D5) ────────────────────────────────────────────────────
//
@@ -11,7 +12,7 @@ import { Icon } from './icon.js';
// (platform.node.call), not over MNP/WebRTC. The MNP protocol types remain
// for potential future browser-side use.
-function NodeServicePanel({ onChanged }) {
+function NodeServicePanel({ onChanged, token, username }) {
const [info, setInfo] = useState(null);
const [busy, setBusy] = useState('');
const [err, setErr] = useState('');
@@ -77,6 +78,16 @@ function NodeServicePanel({ onChanged }) {
});
}, [act, info]);
+ // The Start button below passes {hubUrl, username, token} to node:start,
+ // same as create-group-page.js's own startNode() -- node:start only links
+ // an unlinked node key to the hub account when given credentials to link
+ // it with (main.js's linkNodeKeyAndAwaitRunning). Without them, a node that
+ // is not linked yet (a fresh install, or one whose hub-side link was lost)
+ // just polls for up to 105s and fails with "could not link", pointing at a
+ // "Link Node" control that lives on Settings, not here. Reproduced live
+ // 2026-09-14: a fresh non-service install's own Start button hung and
+ // failed this way, the exact same account and key that had just linked
+ // fine through the Create Group wizard.
if (!platform.node.service.available) return null;
if (!info || info.supported === false) {
return html`<div class="node-service">
@@ -108,7 +119,7 @@ function NodeServicePanel({ onChanged }) {
${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())}>
+ onClick=${() => act('start', () => platform.node.start({ hubUrl: HUB, username, token }))}>
${busy === 'start' ? t('node.service_starting') : t('node.service_start')}</button>
${info.installed && html`
<button class="btn btn-small btn-secondary" disabled=${!!busy || !running}
@@ -171,7 +182,7 @@ async function saveCsv(filename, text) {
URL.revokeObjectURL(url);
}
-export function NodePage({ groups }) {
+export function NodePage({ groups, token, username }) {
const [status, setStatus] = useState('idle');
const [error, setError] = useState('');
const [nodeGroups, setNodeGroups] = useState([]);
@@ -664,14 +675,14 @@ export function NodePage({ groups }) {
if (status === 'idle' || status === 'connecting') {
return html`<div class="page-content">
<h2>${t('node.title')}</h2>
- <${NodeServicePanel} onChanged=${fetchStatus} />
+ <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} />
<p class="page-message"><span class="spinner"></span>${' '}${t('status.connecting')}</p>
</div>`;
}
if (status === 'error') {
return html`<div class="page-content">
<h2>${t('node.title')}</h2>
- <${NodeServicePanel} onChanged=${fetchStatus} />
+ <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} />
<p class="error-msg">${error}</p>
<button class="btn btn-primary" onClick=${fetchStatus}>
${t('node.retry')}</button>
@@ -686,7 +697,7 @@ export function NodePage({ groups }) {
onClick=${reloadConfig}>
${t('node.reload')}</button>
</div>
- <${NodeServicePanel} onChanged=${fetchStatus} />
+ <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} />
${actionMsg && html`<div class="node-message">${actionMsg}</div>`}
${!operatorPaired && html`
<div class="node-pair-banner">
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index ab942e4..f780073 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -534,3 +534,53 @@ def test_search_connects_in_one_place():
index = code[code.index("async function fetchGroupIndex("):
code.index("async function fetchAllIndexes(")]
assert "connectToGroup(" in pool and "connectToGroup(" in index
+
+
+# node:start only links an unlinked node key to the hub account when it is
+# given credentials to link it with (main.js's linkNodeKeyAndAwaitRunning on
+# Windows, the equivalent inline block on Linux -- both gate on `opts.token`).
+# create-group-page.js's own startNode() passes {hubUrl, username, token};
+# the Node page's Start button, reachable independently of that wizard,
+# passed none. Reproduced live 2026-09-14 on a fresh non-service Windows
+# install signed in to the real hub: Start on a node that had never been
+# linked (the ordinary state for anyone who has not gone through Create
+# Group yet) polled for up to 105s and failed with "could not link" --
+# cross-platform, since both mains share this same frontend call.
+NODE_PAGE = STATIC / "node-page.js"
+
+
+@pytest.fixture(scope="module")
+def node_page():
+ return NODE_PAGE.read_text(encoding="utf-8")
+
+
+def test_node_page_start_button_can_link_an_unlinked_node(node_page):
+ fn = node_page[node_page.index("function NodeServicePanel("):]
+ fn = fn[:fn.index("\nfunction ") if "\nfunction " in fn else len(fn)]
+ start_call = fn[fn.index("act('start'"):]
+ start_call = start_call[:start_call.index(")}>")]
+ assert "hubUrl" in start_call and "HUB" in start_call, (
+ "the Start button must pass hubUrl (HUB) through to node:start, or "
+ "an unlinked node can never link on Start alone")
+ assert "username" in start_call and "token" in start_call, (
+ "the Start button must pass username and token through to "
+ "node:start -- linkNodeKeyAndAwaitRunning needs both to PUT the key")
+
+ assert "import { HUB" in node_page or "import {HUB" in node_page, (
+ "HUB must come from hub-client.js, the one file allowed to decide "
+ "where the hub is"
+ )
+
+
+def test_node_page_receives_the_session_it_hands_to_start():
+ """app.js is what actually has to hand token/username down; a fixed
+ node-page.js reading them off undefined props is the same bug moved up
+ one file."""
+ app_src = APP.read_text(encoding="utf-8")
+ marker = "route === '/node' && platform.capabilities.nodeAdmin"
+ route = app_src[app_src.index(marker):]
+ route = route[:route.index(";")]
+ assert "LazyNodePage" in route
+ assert "token=" in route and "username=" in route, (
+ "app.js renders the Node page without the session NodeServicePanel "
+ "now expects, so its Start button's opts are undefined again")
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")