diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-07 21:25:47 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-07 22:22:51 +0200 |
| commit | e833fe1bfc8eb6f66cc5dc53997cc4158bab583f (patch) | |
| tree | 6874dfd09cb210eaf61e4f5761d0bad23c9cd0a3 | |
| parent | 92e25ffcc5edf5d1a9996bfb921b5a95b826134b (diff) | |
| download | meshbay-e833fe1bfc8eb6f66cc5dc53997cc4158bab583f.tar.gz | |
fix: set the Windows node up at sign-in, and stop it for real
Found by the first Windows beta tester, then reproduced on a clean install.
After a service-mode install nothing set the node up for the account that
signed in: the boot task started a node that quit ("hub.username not set"),
and the sidebar showed Node / Create group only once the hub held a node key.
The only way to the wizard that provisions was the home page's welcome card,
which an account already in a group never sees. The way out was
`meshbay-node init` and the key pasted on the profile page -- which is also
what PACKAGING-GUIDE.md told people to do.
- main.js `node:ensure`, called by app.js at sign-in: provisions, starts and
links the node this build ships (Windows, bundled node only). A node set up
for another account, or an account linked to another node, is left alone.
node:start waits for it, so the two never race.
- The sidebar shows the Node section when a node exists on this machine.
- The Node page's status is the node's: its control API and the process
list, not the service task's state (a node started from a terminal ran
while the page said Stopped). Stop says Stopped only once no
meshbay-node.exe is left, and stays offered for a process that answers
nothing.
- CLI stop kills the pid that answered when a graceful stop does not finish,
and fails with the reason when a node process is still there.
- The daemon ends its process 3s after _shutdown(): Python's exit waited for a
busy indexer thread, with the control API already closed. Armed by main()
only, never by a daemon run inside a test.
- node.toml is read as utf-8-sig (PowerShell 5.1 writes a BOM), and a config
that cannot be read is logged instead of dying silently in service mode.
- "Pair this browser" queues the code for the next group of this node to
open instead of saying "Paired successfully"; no banner before a group.
- test_e2e_windows_app.py (opt-in, MESHBAY_WIN_E2E=1) drives the installed
app against a throwaway hub: fresh account to linked node, Stop, Start,
Restart, checked against the real processes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
24 files changed, 885 insertions, 59 deletions
diff --git a/docs/PACKAGING-GUIDE.md b/docs/PACKAGING-GUIDE.md index 9649826..fdc0a57 100644 --- a/docs/PACKAGING-GUIDE.md +++ b/docs/PACKAGING-GUIDE.md @@ -71,20 +71,21 @@ Run the installer. It is **per-user** and lands in node daemon ships beside the app at `resources\node-runtime\meshbay-node.exe`; the client finds it automatically. -It asks two things, both skippable: +It asks one thing — **when the node runs**: -- **"Run MeshBay Node as a background service?"** — Yes starts the node **at - boot, before you even sign in**, and needs one administrator confirmation - (which also sets up the firewall rules, in the same step — see below). No - keeps the normal per-user mode: the node starts when you sign in, with no - admin needed, and you can turn autostart on later from the Node page. -- **(per-user mode only) "Allow MeshBay through Windows Firewall now?"** — one - administrator confirmation adds the inbound rules the client and the node - need for WebRTC and casting. Declining is fine — Windows shows its own - "Allow access" dialog instead, the first time each is actually used. +- **As a background service** (the default) — the node starts **at boot, + before you even sign in**. +- **Automatically when I sign in to Windows** — a launcher in your Startup + folder, no service. +- **Only while MeshBay is open** — the app starts the node and stops it when + you quit. -Running setup again (an upgrade, a repair install) asks neither question if -the firewall rules are already there. +Every choice also adds the Windows Firewall rules the app and the node need +(WebRTC, casting), so setup asks for **one administrator confirmation**: for the +service and the rules together, or for the rules alone. Running setup again (an +upgrade, a repair) keeps the choice it finds and asks nothing when the rules and +the service are already in place. The choice can be changed later on the +**Node** page (**Start automatically**). **ffmpeg** — required for video streaming, **bundled in the installer by default** (verified, checksummed, GPLv3-licensed; `LICENSE-ffmpeg.txt` ships @@ -92,29 +93,38 @@ alongside it). Nothing to install separately. ### First run -Open MeshBay and sign in. Use the **Node** page (or a terminal) to provision: +Open MeshBay, choose the hub and sign in. **That is all**: the app sets the node +up for the account you signed in with, starts it the way you chose at install, +and links it to your account. **Node** and **Create group** appear in the +sidebar; nothing has to be typed in a terminal and no key has to be copied. -``` -meshbay-node init -meshbay-node autostart install # per-user mode: run at every sign-in (no admin) -meshbay-node service install # service mode: run at boot (needs an elevated prompt) -``` +Two cases where the app does not do it on its own, because it would undo +something: + +- the node was already set up for **another account or another hub** — the + **Start** button on the Node page asks before switching it; +- your account is already linked to a node on **another machine** — linking + this one would disconnect that one. The Node page shows this node's key; + **Profile → Link Node** moves the link here if that is what you want. + +The Node page's **Start / Stop / Restart** work in every mode, and its status +is the node's own: **Running** when it answers, **Stopped** only once no +`meshbay-node.exe` is left. If a node will not stop, the page says so and why. -The Node page's Start/Stop/Restart buttons work the same either way — they -drive the Scheduled Task when service mode is active, or the daemon process -directly otherwise. +The node logs to `%LOCALAPPDATA%\meshbay\state\node.log` — in service mode +that file is the only place it can tell you why it would not start. Runtime data — `node.toml`, `keystore.enc`, `unlock.key`, `data\` — lives in -`%LOCALAPPDATA%\meshbay\` and **survives uninstall/reinstall**, in either mode +`%LOCALAPPDATA%\meshbay\` and **survives uninstall/reinstall**, in every mode (service mode runs as your own account too — never LocalSystem — so nothing about where your data lives changes). ### Uninstall *Apps & features → MeshBay → Uninstall*, or the Start-menu *Uninstall MeshBay* -entry. It stops a running daemon and removes the sign-in launcher; it offers -(opt-in, one admin confirmation) to also remove the firewall rules and the -boot-time service task, if you set one up. None of this touches +entry. It stops a running node, removes the sign-in launcher, and removes the +firewall rules and the boot-time service task with one administrator +confirmation (none if neither is there). None of this touches `%LOCALAPPDATA%\meshbay\` (the keystore). ### Build from source diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js index f582b57..8e987d3 100644 --- a/packages/meshbay-client/src/main.js +++ b/packages/meshbay-client/src/main.js @@ -1459,6 +1459,34 @@ function registerBridge() { return r; } + // Every meshbay-node.exe running, in any session (tasklist lists session 0, + // where a service node runs). Whether a node is there is a question for the + // process list as well as its control API: the API closes first on the way + // down, and a node started some other way than the service task -- from a + // terminal, after `meshbay-node init` -- runs while the task reads "Ready". + // Both made the Node page say "Stopped" about a node that was running. + function winNodePids() { + return new Promise((resolve) => { + execFile('tasklist', ['/FI', 'IMAGENAME eq meshbay-node.exe', '/NH', '/FO', 'CSV'], + { windowsHide: true }, (err, stdout) => { + if (err) return resolve([]); + resolve(String(stdout || '').split(/\r?\n/) + .map((l) => l.split('","')) + .filter((c) => c.length > 1 && /^\d+$/.test(c[1])) + .map((c) => Number(c[1]))); + }); + }); + } + + // 'active' when a node answers, 'unknown' when a node process is there but + // does not (starting, stopping, or stuck: Stop must still be offered), + // 'inactive' when there is none. + async function winNodeActivity() { + const [p, pids] = await Promise.all([probeNode(), winNodePids()]); + if (p) return { activeState: 'active', node: p }; + return { activeState: pids.length ? 'unknown' : 'inactive', node: null }; + } + // Start (or restart) through the CLI and return what answered, or throw // with the CLI's own words and where the log is. async function winNodeStartVia(args) { @@ -1691,14 +1719,15 @@ function registerBridge() { if (process.platform === 'win32') { const svc = await winServiceTaskStatus(); if (svc.installed) { - // Service mode: Task Scheduler already tracks running/not, directly — - // no need to probe the daemon's own API for this panel. - const running = /running/i.test(svc.state); + // Service mode. Not the task's state alone: a node started from a + // terminal runs while the task reads "Ready", and one still on its + // way out after a /end reads "Ready" too. + const { activeState } = await winNodeActivity(); return { supported: true, mode: 'service', installed: true, - activeState: running ? 'active' : 'inactive', + activeState, subState: svc.state, // Whether switching startup mode can actually elevate right now — // service-mode.ps1 is an extraResource, present in a packaged Full @@ -1715,7 +1744,7 @@ function registerBridge() { // gated on `installed`, so with no autostart configured they silently // vanished — the daemon was perfectly manageable, just not launchable // at sign-in. `autostart` carries that state as its own field instead. - const [p, bin] = await Promise.all([probeNode(), findNodeBinary()]); + const [{ activeState }, bin] = await Promise.all([winNodeActivity(), findNodeBinary()]); return { supported: true, // Only claim "startup mode" once a node was actually found (bundled @@ -1726,8 +1755,8 @@ function registerBridge() { mode: bin ? 'startup' : null, installed: Boolean(bin), autostart: winAutostartInstalled(), - activeState: p ? 'active' : 'inactive', - subState: p ? 'running' : '', + activeState, + subState: activeState === 'active' ? 'running' : '', canElevate: winCanElevateServiceMode(), }; } @@ -1759,11 +1788,16 @@ function registerBridge() { async function nodeServiceStop() { if (process.platform === 'win32') { - await killNodeProcesses(); + const r = await killNodeProcesses(); nodeStartedByApp = false; - // Said only once nothing answers: this used to report success about a - // service node it could not reach from this session. - if (await probeNode()) { + // Said only once nothing answers and no node process is left: this used + // to report success about a service node it could not reach from this + // session, then about a process still running with its API closed. + if (!r.ok) { + throw new Error(`${r.out || 'the node could not be stopped'} +Its log: ${nodeLogHint()}`); + } + if (await probeNode() || (await winNodePids()).length) { throw new Error(`the node could not be stopped. Its log: ${nodeLogHint()}`); } return { stopped: true }; @@ -1980,7 +2014,76 @@ function registerBridge() { } } + // ── Windows: the node this application ships, set up for whoever signs in ── + // Installing MeshBay installs a node, and signing in is all it should take + // to have one running for that account. Nothing did it: node.toml was + // written by the Create Group wizard alone, which the home page offers only + // to an account with no groups -- a member of somebody else's group, or + // anyone who clicked "Skip setup", never saw it. A fresh install then left + // the service task starting a node that quit at once ("hub.username not + // set"), the account with no node key, and the sidebar without its Node + // section; the way out was `meshbay-node init` in a terminal and the key + // pasted on the profile page (the first Windows beta tester, 2026-10). + // + // It never overrides a choice: a node set up for another account or hub is + // left alone (node:start asks before changing that), and an account already + // linked to another node keeps it -- `PUT /me/node_key` replaces, and the + // node it replaced would stop working with nothing said. + let nodeEnsuring = null; + + async function ensureNode(opts) { + if (process.platform !== 'win32' || !hasBundledNode()) return { state: 'unsupported' }; + const hubUrl = String(config.hubBase || '').replace(/\/+$/, ''); + const username = String((opts && opts.username) || ''); + const token = opts && opts.token; + if (!hubUrl || !token || !USERNAME_RE.test(username)) return { state: 'unsupported' }; + const current = provisionedAs(); + if (current && (!sameHub(current.hubUrl, hubUrl) || current.username !== username)) { + return { state: 'other_account' }; + } + if (!current) provisionNode(hubUrl, username); + + let p = await probeNode(); + if (!p) { + try { + // However this machine is set up: the service task, or a process of + // its own -- and it reports what answered, not what it launched. + await winNodeStartVia(['autostart', 'start']); + } catch (err) { + console.error('[node] start after sign-in:', err.message); + return { state: 'not_started' }; + } + if ((await winStartupMode()) !== 'service') nodeStartedByApp = true; + p = await waitForNode(Date.now() + 15000); + if (!p) return { state: 'not_started' }; + } + if (p.status === 'running') return { state: 'running' }; + + let linked = ''; + try { + const r = await fetch(`${hubUrl}/v1/users/${encodeURIComponent(username)}/pubkeys`, { + headers: { Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(5000), + }); + if (!r.ok) return { state: 'not_linked' }; + linked = (await r.json()).pk_node_ed25519 || ''; + } catch { return { state: 'not_linked' }; } + if (linked && linked !== p.pk_node_ed25519) return { state: 'other_node' }; + const ready = await linkNodeKeyAndAwaitRunning({ token, hubUrl }, Date.now() + 90000); + return { state: ready && ready.status === 'running' ? 'running' : 'not_linked' }; + } + + handle('node:ensure', async (_e, opts) => { + if (!nodeEnsuring) { + nodeEnsuring = ensureNode(opts).finally(() => { nodeEnsuring = null; }); + } + return nodeEnsuring; + }); + handle('node:start', async (_e, opts) => { + // One at a time: a start racing the sign-in's own would stop the node the + // other had just started. + if (nodeEnsuring) await nodeEnsuring.catch(() => {}); const already = await probeNode(); if (already && already.status === 'running') { return { started: true, ...already }; diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js index 0de76db..9b466f4 100644 --- a/packages/meshbay-client/src/preload.js +++ b/packages/meshbay-client/src/preload.js @@ -143,6 +143,7 @@ contextBridge.exposeInMainWorld('meshbay', { // PATH. Windows only; other platforms always resolve true. bundled: () => ipcRenderer.invoke('node:bundled'), start: (opts) => ipcRenderer.invoke('node:start', opts), + ensure: (opts) => ipcRenderer.invoke('node:ensure', opts), op: (name, args) => ipcRenderer.invoke('node:op', name, args), pairingCode: () => ipcRenderer.invoke('node:pairing-code'), setPairingCode: (code) => ipcRenderer.invoke('node:set-pairing-code', code), diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index c1913c0..b9466d0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -429,7 +429,7 @@ function Nav({ user, theme, onThemeChange, onLogout, onMenuToggle, unreadCount, // ── Sidebar ────────────────────────────────────────────────────────────────── -function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, hasNodeKey, +function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, showNode, allowPublicGroups = true }) { const isStaff = role === 'moderator' || role === 'admin'; return html` @@ -449,7 +449,7 @@ function Sidebar({ groups, presence, indexProgressPct, route, menuOpen, role, ha <a class="sidebar-item ${route === '/search' ? 'active' : ''}" href="#/search"><${Icon} name="search" /> ${t('sidebar.search')}</a> </div> - ${platform.capabilities.nodeAdmin && hasNodeKey && html` + ${platform.capabilities.nodeAdmin && showNode && html` <div class="sidebar-section"> <div class="sidebar-heading">${t('sidebar.node')}</div> <a class="sidebar-item ${route === '/node' ? 'active' : ''}" @@ -781,6 +781,9 @@ function App() { const [notifDisabled, setNotifDisabled] = useState(false); const [userPrefs, setUserPrefs] = useState({}); const [hasNodeKey, setHasNodeKey] = useState(false); + // A node on this machine, linked to the account or not: the Node section is + // where it is looked after, so it cannot wait for a link to show up. + const [localNode, setLocalNode] = useState(false); // Instance policy, fetched once, unauthenticated. `null` until it answers; // treat unknown as "allowed" so a slow hub never blocks a legitimate private // group — the hub refuses a public one server-side regardless. @@ -1041,6 +1044,25 @@ function App() { fetchNotifications(); }, [user]); + // The node this application ships, set up, started and linked for whoever + // signs in (main.js `ensureNode`): installing it is all it should take. + // Keyed on the account, not the session object -- the token rotates, and a + // renewal is not a sign-in. + const signedInAs = user ? user.username : null; + useEffect(() => { + setLocalNode(false); + if (!signedInAs || !platform.capabilities.nodeAdmin || !platform.node.available) return; + let cancelled = false; + platform.node.installed() + .then((r) => { if (!cancelled) setLocalNode(Boolean(r && r.installed)); }) + .catch(() => {}); + const live = userRef.current; + platform.node.ensure({ username: live.username, token: live.token }) + .then(() => { if (!cancelled) refreshNodeKey(); }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [signedInAs]); + // After an invitation is accepted: the group is ours now, and only the hub // knows its row the way `/mine` answers it. const reloadGroups = useCallback(() => { @@ -1316,7 +1338,7 @@ function App() { .catch(() => {}); refreshNodeKey(); }} />`; - } else if (route === '/node' && platform.capabilities.nodeAdmin && hasNodeKey) { + } else if (route === '/node' && platform.capabilities.nodeAdmin && (hasNodeKey || localNode)) { page = html`<${LazyNodePage} groups=${groups} token=${user.token} username=${user.username} />`; } else if (groupId) { page = html`<${GroupPage} @@ -1380,7 +1402,7 @@ function App() { menuOpen=${menuOpen} role=${user.role} allowPublicGroups=${allowPublicGroups} - hasNodeKey=${hasNodeKey} />`} + showNode=${hasNodeKey || localNode} />`} ${menuOpen && html`<div class="overlay visible" onClick=${() => setMenuOpen(false)} />`} <main class="main"> 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 f4151f7..4dff605 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -939,6 +939,7 @@ export default { 'node.pair_code_placeholder': 'Kopplungscode', 'node.pair_button': 'Diesen Browser koppeln', 'node.pair_success': 'Erfolgreich gekoppelt.', + 'node.pair_pending': 'Kopplungscode bereit: Er wird verwendet, wenn Sie das nächste Mal eine Gruppe dieses Nodes öffnen.', 'node.reload': 'Konfiguration neu laden', 'node.reloaded': 'Konfiguration neu geladen. Verzeichnisänderungen an bestehenden Gruppen sind jetzt aktiv.', 'node.restart_needed': 'Nach dem Hinzufügen: Gruppenschlüssel initialisieren (meshbay-node gek-init --group <name>).', 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 84607e7..90c043e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -1079,6 +1079,7 @@ export default { 'node.pair_code_placeholder': 'Pairing code', 'node.pair_button': 'Pair this browser', 'node.pair_success': 'Paired successfully.', + 'node.pair_pending': "Pairing code ready: it is used the next time you open one of this node's groups.", 'node.reload': 'Reload config', 'node.reloaded': 'Config reloaded. Root changes on existing groups are now active.', 'node.attach_group': 'Add group', 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 28ad85e..7b46b17 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -933,6 +933,7 @@ export default { 'node.pair_code_placeholder': 'Código de emparejamiento', 'node.pair_button': 'Emparejar este navegador', 'node.pair_success': 'Emparejamiento exitoso.', + 'node.pair_pending': 'Código de emparejamiento listo: se usará la próxima vez que abra uno de los grupos de este Node.', 'node.reload': 'Recargar configuración', 'node.reloaded': 'Configuración recargada. Los cambios de directorios en los grupos existentes ya están activos.', 'node.restart_needed': 'Después de añadir: inicialice la clave de grupo (meshbay-node gek-init --group <nombre>).', 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 9e8dbbe..d3f9200 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -936,6 +936,7 @@ export default { 'node.pair_code_placeholder': "Code d'appairage", 'node.pair_button': 'Appairer ce navigateur', 'node.pair_success': 'Appairage réussi.', + 'node.pair_pending': "Code d'appairage prêt : il sera utilisé à la prochaine ouverture d'un des groupes de ce node.", 'node.reload': 'Recharger la configuration', 'node.reloaded': 'Configuration rechargée. Les modifications de répertoires sur les groupes existants sont maintenant actives.', 'node.restart_needed': 'Après l\'ajout : initialisez la clé de groupe (meshbay-node gek-init --group <name>).', 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 493455d..715738e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -935,6 +935,7 @@ export default { 'node.pair_code_placeholder': 'Codice di associazione', 'node.pair_button': 'Associa questo browser', 'node.pair_success': 'Associazione riuscita.', + 'node.pair_pending': 'Codice di associazione pronto: verrà usato la prossima volta che apri uno dei gruppi di questo Node.', 'node.reload': 'Ricarica configurazione', 'node.reloaded': 'Configurazione ricaricata. Le modifiche alle directory dei gruppi esistenti sono ora attive.', 'node.restart_needed': "Dopo l'aggiunta: inizializzi la chiave di gruppo (meshbay-node gek-init --group <nome>).", 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 35a639a..23358c2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -923,6 +923,7 @@ export default { 'node.pair_code_placeholder': 'ペアリングコード', 'node.pair_button': 'このブラウザをペアリング', 'node.pair_success': 'ペアリングに成功しました。', + 'node.pair_pending': 'ペアリングコードの準備ができました。この Node のグループを次に開いたときに使用されます。', 'node.reload': '設定を再読み込み', 'node.reloaded': '設定を再読み込みしました。既存グループのルート変更が反映されました。', 'node.restart_needed': '追加後、グループ鍵を初期化してください(meshbay-node gek-init --group <name>)。', 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 73731cf..5e53084 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -937,6 +937,7 @@ export default { 'node.pair_code_placeholder': 'Koppelingscode', 'node.pair_button': 'Deze browser koppelen', 'node.pair_success': 'Succesvol gekoppeld.', + 'node.pair_pending': 'Koppelingscode klaar: die wordt gebruikt zodra u een van de groepen van deze Node opent.', 'node.reload': 'Configuratie herladen', 'node.reloaded': 'Configuratie herladen. Mapwijzigingen op bestaande groepen zijn nu actief.', 'node.restart_needed': 'Na het toevoegen: initialiseer de groepssleutel (meshbay-node gek-init --group <naam>).', 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 849c632..5d6d852 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -955,6 +955,7 @@ export default { 'node.pair_code_placeholder': 'Kod parowania', 'node.pair_button': 'Sparuj tę przeglądarkę', 'node.pair_success': 'Parowanie zakończone sukcesem.', + 'node.pair_pending': 'Kod parowania gotowy: zostanie użyty przy następnym otwarciu jednej z grup tego Node.', 'node.reload': 'Przeładuj konfigurację', 'node.reloaded': 'Konfiguracja przeładowana. Zmiany katalogów w istniejących grupach są teraz aktywne.', 'node.restart_needed': 'Po dodaniu: zainicjalizuj klucz grupy (meshbay-node gek-init --group <nazwa>).', 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 5672b60..6fe50f6 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 @@ -934,6 +934,7 @@ export default { 'node.pair_code_placeholder': 'Código de pareamento', 'node.pair_button': 'Parear este navegador', 'node.pair_success': 'Pareamento realizado com sucesso.', + 'node.pair_pending': 'Código de pareamento pronto: ele será usado na próxima vez que você abrir um dos grupos deste Node.', 'node.reload': 'Recarregar configuração', 'node.reloaded': 'Configuração recarregada. Alterações de diretórios em grupos existentes estão ativas.', 'node.restart_needed': 'Após adicionar: inicialize a chave do grupo (meshbay-node gek-init --group <nome>).', 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 26025ef..b3886ff 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 @@ -911,6 +911,7 @@ export default { 'node.pair_code_placeholder': '配对码', 'node.pair_button': '配对此浏览器', 'node.pair_success': '配对成功。', + 'node.pair_pending': '配对码已就绪:下次打开此 Node 的任一群组时将自动使用。', 'node.reload': '重新加载配置', 'node.reloaded': '配置已重新加载。对现有群组的目录更改现已生效。', 'node.restart_needed': '添加后请初始化群组密钥(meshbay-node gek-init --group <名称>)。', 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 9d230c1..04f4abc 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -6,7 +6,7 @@ import { t } from './i18n.js'; import { ask } from './ask.js'; import * as platform from './platform.js'; import { Icon } from './icon.js'; -import { HUB } from './hub-client.js'; +import { HUB, session } from './hub-client.js'; // ── Node management (D5) ──────────────────────────────────────────────────── // @@ -108,6 +108,9 @@ function NodeServicePanel({ onChanged, token, username }) { const KNOWN_STATES = ['active', 'inactive', 'failed', 'activating', 'deactivating']; const stateKey = KNOWN_STATES.includes(info.activeState) ? info.activeState : 'unknown'; const running = info.activeState === 'active' || info.activeState === 'activating'; + // A node process that does not answer ('unknown': on its way up or down, or + // stuck) is still one to stop -- only "nothing there" takes Stop away. + const stoppable = info.activeState !== 'inactive'; const dot = info.activeState === 'active' ? 'online' : info.activeState === 'failed' ? 'offline' : 'unknown'; const label = info.installed ? t('node.service_state_' + stateKey) @@ -132,7 +135,7 @@ function NodeServicePanel({ onChanged, token, username }) { 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} + <button class="btn btn-small btn-secondary" disabled=${!!busy || !stoppable} 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} @@ -520,13 +523,17 @@ export function NodePage({ groups, token, username }) { setPairBusy(true); setPairStatus(''); try { + // An operator code is redeemed over MNP, with the account's identity in + // a group -- this page has no transport. It used to store the code where + // nothing read it and say "Paired successfully", and the banner came back + // on the next refresh. Queued the way the Create Group wizard queues it: + // the next group of this node to open redeems it. const result = await nodeOp('pairOperator'); if (result && result.code) { await platform.node.setPairingCode(result.code); + session.pendingJoinCode = result.code; } - setPairStatus('paired'); - setOperatorPaired(true); - await refresh(); + setPairStatus('pending'); } catch (err) { setPairStatus(platform.bridgeMessage(err)); } finally { @@ -712,15 +719,15 @@ export function NodePage({ groups, token, username }) { </div> <${NodeServicePanel} onChanged=${fetchStatus} token=${token} username=${username} /> ${actionMsg && html`<div class="node-message">${actionMsg}</div>`} - ${operatorPaired === false && html` + ${operatorPaired === false && nodeGroups.length > 0 && html` <div class="node-pair-banner"> <p>${t('node.pair_needed')}</p> <button class="btn btn-primary btn-small" disabled=${pairBusy} onClick=${doPairOperator}> ${pairBusy ? t('node.settings_saving') : t('node.pair_button')}</button> - ${pairStatus === 'paired' - ? html`<p class="success-msg">${t('node.pair_success')}</p>` + ${pairStatus === 'pending' + ? html`<p class="success-msg">${t('node.pair_pending')}</p>` : pairStatus ? html`<p class="error-msg">${pairStatus}</p>` : null} </div> `} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index 28817c7..e3b3d2d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -253,6 +253,16 @@ export const node = { return bridge.node.start(opts); }, /** + * After sign-in: set up, start and link the node this application ships, + * for the signed-in account ({username, token}). Leaves alone a node set up + * for another account and an account linked to another node. Resolves to + * `{ state }`; a build or platform with nothing to do answers 'unsupported'. + */ + async ensure(opts) { + if (!bridge || !bridge.node || !bridge.node.ensure) return { state: 'unsupported' }; + return bridge.node.ensure(opts); + }, + /** * One of the local node's operations, by name (`NODE_OPS` in the desktop * client's main.js). The page never names a route: the main process checks * the arguments, builds the request, and asks the person itself before diff --git a/packages/meshbay-hub/tests/test_node_page_pairing.py b/packages/meshbay-hub/tests/test_node_page_pairing.py index 435488e..c716e0a 100644 --- a/packages/meshbay-hub/tests/test_node_page_pairing.py +++ b/packages/meshbay-hub/tests/test_node_page_pairing.py @@ -46,3 +46,23 @@ def test_the_banner_needs_an_explicit_false(): "the pairing banner must not show for a node that has not read its roster") assert "useState(null)" in src.split("const [operatorPaired", 1)[1].split("\n", 1)[0] assert "setOperatorPaired(!!" not in src + + +def test_pair_this_browser_queues_the_code_instead_of_claiming_success(): + """It stored the code where nothing read it, said "Paired successfully", + and the banner came back on the next refresh. The code is redeemed over + MNP by the next group of this node to open, as the wizard's is.""" + src = _source() + pair = src.split("const doPairOperator = useCallback(", 1)[1].split("}, [refresh]);", 1)[0] + assert "session.pendingJoinCode = result.code;" in pair + assert "setOperatorPaired(true)" not in pair + assert "import { HUB, session } from './hub-client.js';" in src + + +def test_no_pairing_banner_for_a_node_with_no_group_yet(): + """A node set up at sign-in hosts nothing yet: there is nothing to pair + for until a group exists, and creating one pairs.""" + src = _source() + banner = src.index('class="node-pair-banner"') + guard = src.rindex("${", 0, banner) + assert "nodeGroups.length > 0" in src[guard:banner] diff --git a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py index 901c3d5..b461055 100644 --- a/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py +++ b/packages/meshbay-node/src/meshbay_node/cli/lifecycle.py @@ -55,23 +55,36 @@ def _running_status(cfg) -> dict | None: return _await_daemon(cfg, since=0, timeout=0.1) +class NodeStillRunning(RuntimeError): + pass + + def _stop_node(cfg) -> str: """Stop the node, whichever session it runs in: through its own control API - first, so it shuts down properly, then by force. Returns how it went.""" + first, so it shuts down properly, then by force. Returns how it went, and + raises NodeStillRunning when a node process is still there at the end -- + never "stopped" about a process nobody saw go.""" import time from meshbay_node.platform import ( + _pid_alive, autostart_end, + kill_pid, + node_pids, request_graceful_stop, service_end, service_state, service_status, ) + answering = _running_status(cfg) + pid = answering.get("pid") if answering else None if request_graceful_stop(cfg.data_dir, cfg.node.ui_port): how = "stopped" - elif _running_status(cfg) is None: + elif answering is None and not node_pids(): how = "not running" else: + # Asked and not gone: its control API closes first, so it may answer + # nothing while its process is still there. how = "stopped (forced)" if service_status()["installed"]: # Also leaves the task "Ready": a /run while it still reads "Running" @@ -80,7 +93,17 @@ def _stop_node(cfg) -> str: deadline = time.monotonic() + 15 while service_state().lower() == "running" and time.monotonic() < deadline: time.sleep(0.25) + if pid is not None and _pid_alive(int(pid)): + kill_pid(int(pid)) # the one that answered, by pid, wherever it came from autostart_end() # a node in this session that would not stop + deadline = time.monotonic() + 5 + while (left := node_pids()) and time.monotonic() < deadline: + time.sleep(0.25) + if left: + raise NodeStillRunning( + f"meshbay-node is still running (pid {', '.join(map(str, left))}) and could " + "not be stopped from here -- it was probably started from an " + "administrator prompt. Stop it there, or end it in Task Manager.") return how @@ -94,7 +117,12 @@ def _start_and_confirm(args, action: str) -> None: cfg = load_config(args.config or DEFAULT_CONFIG_PATH) if action == "restart": - _stop_node(cfg) + try: + _stop_node(cfg) + except NodeStillRunning as e: + # Starting another would find the port taken and quit at once. + print(f"Could not restart the node: {e}") + sys.exit(1) else: already = _running_status(cfg) if already is not None: @@ -122,6 +150,21 @@ def _start_and_confirm(args, action: str) -> None: "its files were not replaced, or another copy was started.") +def _print_stop(args) -> None: + try: + print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) + except NodeStillRunning as e: + print(e) + sys.exit(1) + + +def _stop_node_or_warn(args) -> None: + try: + _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)) + except NodeStillRunning as e: + print(f"warning: {e}") + + def restart_daemon(args) -> None: if sys.platform == "win32": _start_and_confirm(args, "restart") @@ -161,7 +204,7 @@ def autostart(args) -> None: elif sub == "start": _start_and_confirm(args, "start") elif sub == "stop": - print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) + _print_stop(args) elif sub == "status": st = _plat.autostart_status() if st["installed"]: @@ -186,7 +229,7 @@ def service(args) -> None: if sub == "install": # A node already running in this session holds the control API's port: # the service's own would exit at once, leaving the old one in charge. - _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)) + _stop_node_or_warn(args) try: _plat.service_install() except RuntimeError as e: @@ -201,7 +244,7 @@ def service(args) -> None: elif sub == "remove": # Deleting a task does not end its running instance: stop the node # first, or it runs on in session 0 with nothing left to stop it. - _stop_node(load_config(args.config or DEFAULT_CONFIG_PATH)) + _stop_node_or_warn(args) _plat.service_remove() print(f"Removed the {_plat.TASK_NAME!r} scheduled task.") elif sub == "start": @@ -211,7 +254,7 @@ def service(args) -> None: sys.exit(1) _start_and_confirm(args, "start") elif sub == "stop": - print(_stop_node(load_config(args.config or DEFAULT_CONFIG_PATH))) + _print_stop(args) elif sub == "status": st = _plat.service_status() if st["installed"]: diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py index 2cd8a8f..eb694ae 100644 --- a/packages/meshbay-node/src/meshbay_node/config.py +++ b/packages/meshbay-node/src/meshbay_node/config.py @@ -403,7 +403,11 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config: cfg = Config() if path.exists(): - raw = tomllib.loads(path.read_text(encoding="utf-8")) + # utf-8-sig: Windows PowerShell 5.1's `Set-Content -Encoding utf8` and + # older Notepads write a BOM, which TOML refuses -- and a service node + # then stopped before it had said anything (found editing node.toml + # on a real install). + raw = tomllib.loads(path.read_text(encoding="utf-8-sig")) hub = raw.get("hub", {}) cfg.hub.url = hub.get("url", cfg.hub.url) diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py index e1aac41..6fba120 100644 --- a/packages/meshbay-node/src/meshbay_node/daemon.py +++ b/packages/meshbay-node/src/meshbay_node/daemon.py @@ -31,6 +31,7 @@ import os import signal import socket import sys +import threading import time from dataclasses import asdict from pathlib import Path @@ -81,6 +82,31 @@ if WEBRTC_AVAILABLE: log = logging.getLogger(__name__) +# How long a stopped node's process may outlive its _shutdown(), and the most +# _shutdown() itself may take. Python's own exit waits for every worker thread, +# and one still walking or hashing a large tree kept the process alive for as +# long as that took, with its control API already closed: the desktop app saw +# no node and said "Stopped" about a process that was still there. +EXIT_GRACE_SECS = 3.0 +SHUTDOWN_DEADLINE_SECS = 30.0 + + +def exit_after(seconds: float, why: str, code: int = 0) -> threading.Timer: + """End this process in `seconds`, whatever is still running in it.""" + def _exit() -> None: + busy = sorted(t.name for t in threading.enumerate() + if t is not threading.current_thread() and not t.daemon + and t is not threading.main_thread()) + log.warning("%s -- ending the process now (still busy: %s)", + why, ", ".join(busy) or "nothing") + logging.shutdown() + os._exit(code) + timer = threading.Timer(seconds, _exit) + timer.daemon = True + timer.start() + return timer + + # ── Hub WS sender bridge ───────────────────────────────────────────────────── class _WsSender: @@ -101,6 +127,9 @@ def _root_shape(roots) -> set[tuple]: class NodeDaemon(EnrichmentMixin): + # Set by main(), never by a test that runs a daemon in its own process. + exit_process_when_stopped = False + def __init__(self, config: Config, config_path: Path = DEFAULT_CONFIG_PATH): self._config = config self._config_path = config_path @@ -1564,6 +1593,9 @@ class NodeDaemon(EnrichmentMixin): async def _shutdown(self) -> None: log.info("Shutting down...") self._state["status"] = "stopping" + if self.exit_process_when_stopped: + exit_after(SHUTDOWN_DEADLINE_SECS, + f"shutdown took more than {SHUTDOWN_DEADLINE_SECS:.0f}s", code=1) for handle in self._pending_broadcasts.values(): handle.cancel() @@ -1624,6 +1656,8 @@ class NodeDaemon(EnrichmentMixin): token_file.unlink(missing_ok=True) log.info("Node stopped") + if self.exit_process_when_stopped: + exit_after(EXIT_GRACE_SECS, "the node has stopped") # ── Entry point ─────────────────────────────────────────────────────────────── @@ -1668,7 +1702,15 @@ def main() -> None: # Printed for whoever ran it, and logged too: a daemon started by the # service task has no console, and these are why it would refuse to start. - cfg = load_config(args.config or DEFAULT_CONFIG_PATH) + config_path = args.config or DEFAULT_CONFIG_PATH + try: + cfg = load_config(config_path) + except Exception as e: + # Uncaught, this went to a stderr that Task Scheduler discards: the + # node died with "Logging to ..." as its last word. + print(f"Error: cannot read {config_path}: {e}") + log.error("cannot read %s: %s", config_path, e) + sys.exit(1) if not cfg.hub.username: print("Error: hub.username not set in config. Run: meshbay-node init") log.error("hub.username not set in config. Run: meshbay-node init") @@ -1681,7 +1723,8 @@ def main() -> None: log.error("%s", e) sys.exit(1) - daemon = NodeDaemon(cfg, Path(args.config or DEFAULT_CONFIG_PATH)) + daemon = NodeDaemon(cfg, Path(config_path)) + daemon.exit_process_when_stopped = True try: asyncio.run(daemon.run()) except ControlPortTaken as e: diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py index 88db83d..c0e4d00 100644 --- a/packages/meshbay-node/src/meshbay_node/platform.py +++ b/packages/meshbay-node/src/meshbay_node/platform.py @@ -397,6 +397,35 @@ def autostart_end() -> None: subprocess.run(argv, capture_output=True) +def node_pids() -> list[int]: + """Every meshbay-node.exe running, in any session, except this process and + its parent (the CLI is meshbay-node.exe too). Empty off Windows. + + What says whether a node is still there after a stop: its control API + closes first, so a process that has not exited yet answers nothing and + used to count as gone. + """ + if sys.platform != "win32": + return [] + r = subprocess.run(["tasklist", "/FI", f"IMAGENAME eq {NODE_IMAGE}", "/NH", "/FO", "CSV"], + capture_output=True, text=True) + mine = {os.getpid(), os.getppid()} + pids = [] + for line in r.stdout.splitlines(): + cells = [c.strip('"') for c in line.split('","')] + if len(cells) > 1 and cells[1].isdigit() and int(cells[1]) not in mine: + pids.append(int(cells[1])) + return pids + + +def kill_pid(pid: int) -> None: + """Force-stop one process and its children (its ffmpeg transcodes). Reaches + this session only, unless the caller is elevated. Windows only: systemd + stops the node everywhere else.""" + if sys.platform == "win32": + subprocess.run(["taskkill", "/F", "/T", "/PID", str(pid)], capture_output=True) + + # ── Service mode (Windows, opt-in at install time) ─────────────────────────── # # The Startup-folder .vbs above only ever runs after *this* user signs in. A diff --git a/packages/meshbay-node/tests/test_e2e_windows_app.py b/packages/meshbay-node/tests/test_e2e_windows_app.py new file mode 100644 index 0000000..b407aa8 --- /dev/null +++ b/packages/meshbay-node/tests/test_e2e_windows_app.py @@ -0,0 +1,286 @@ +"""The installed Windows application, driven the way a person uses it. + +Opt-in (MESHBAY_WIN_E2E=1): it needs an installed MeshBay, launches it, and +starts and stops the real node. Everything it asserts was broken at least once +on a real install while every other test passed: + +- a fresh account signs in and, with nothing else done, has a node set up, + started, linked and listed in the sidebar (it used to take `meshbay-node + init` in a terminal and a key pasted on the profile page); +- Stop leaves no meshbay-node.exe behind and only then says "Stopped"; +- Start and Restart answer with a running node, a new one for Restart. + +Before running it: +- install MeshBay with the setup .exe ("background service" is the default; + MESHBAY_E2E_MODE=signin|open when another was chosen); +- no %LOCALAPPDATA%\\meshbay and no %APPDATA%\\meshbay-client: it refuses to + touch an existing node or profile -- move them aside first; +- run it from an ordinary terminal. Inside a packaged application (MSIX, + such as an IDE or agent installed from the Store) %LOCALAPPDATA% and + %APPDATA% are virtualised for every child process: what this test writes + there, a node started by Task Scheduler never sees. Start it outside the + package (Win32_Process.Create, a plain console window). + + $env:MESHBAY_WIN_E2E = 1 + pytest packages/meshbay-node/tests/test_e2e_windows_app.py -v -s + +The hub is a throwaway one on loopback (SQLite, a fresh key), never a real one. +""" + +import json +import os +import secrets +import socket +import sqlite3 +import subprocess +import sys +import time +from pathlib import Path + +import pytest + +pytestmark = [ + pytest.mark.skipif(sys.platform != "win32", reason="the Windows application"), + pytest.mark.skipif(os.environ.get("MESHBAY_WIN_E2E") != "1", + reason="opt-in: set MESHBAY_WIN_E2E=1 (see the module docstring)"), + pytest.mark.timeout(900), +] + +LOCALAPPDATA = Path(os.environ.get("LOCALAPPDATA", "")) +APPDATA = Path(os.environ.get("APPDATA", "")) +INSTALL = Path(os.environ.get("MESHBAY_E2E_INSTALL", LOCALAPPDATA / "Programs" / "MeshBay")) +NODE_HOME = LOCALAPPDATA / "meshbay" +APP_PROFILE = APPDATA / "meshbay-client" +MODE = os.environ.get("MESHBAY_E2E_MODE", "service") +CDP_PORT = 9333 + + +def _free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _launch(argv: list[str]) -> None: + """Start a process detached: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP.""" + subprocess.Popen(argv, creationflags=0x00000008 | 0x00000200, close_fds=True) + + +def _node_pids() -> list[int]: + r = subprocess.run(["tasklist", "/FI", "IMAGENAME eq meshbay-node.exe", "/NH", "/FO", "CSV"], + capture_output=True, text=True) + return [int(c[1]) for c in (line.split('","') for line in r.stdout.splitlines()) + if len(c) > 1 and c[1].isdigit()] + + +def _wait(what: str, cond, timeout: float, every: float = 1.0): + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + last = cond() + if last: + return last + time.sleep(every) + raise AssertionError(f"timed out after {timeout:.0f}s waiting for {what} (last: {last!r})") + + +SUBMIT = "[...document.querySelectorAll('main form button[type=submit]')].pop().click()" + + +class Page: + """The application's window over the DevTools protocol.""" + + HELPERS = """ + var fill = (el, v) => { + const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype + : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, v); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + }; + var clickText = (t) => { + const els = [...document.querySelectorAll('button, a')].filter(e => !e.disabled); + const el = els.find(e => e.innerText.trim() === t) + || els.find(e => e.innerText.trim().startsWith(t)); + if (!el) throw new Error('nothing to click named ' + t); + el.click(); + }; + """ + + def __init__(self): + from websockets.sync.client import connect + targets = json.loads(_http_get(f"http://127.0.0.1:{CDP_PORT}/json/list")) + page = next(t for t in targets if t["type"] == "page" and t["url"].startswith("app://")) + self._conn = connect(page["webSocketDebuggerUrl"], max_size=None) + self._ws = self._conn.__enter__() + self._id = 0 + + def eval(self, expr: str): + self._id += 1 + self._ws.send(json.dumps({"id": self._id, "method": "Runtime.evaluate", "params": { + "expression": f"{self.HELPERS}\n(async () => ({expr}))()", + "awaitPromise": True, "returnByValue": True, "userGesture": True}})) + while True: + msg = json.loads(self._ws.recv(timeout=120)) + if msg.get("id") == self._id: + break + res = msg["result"] + if "exceptionDetails" in res: + raise AssertionError(res["exceptionDetails"].get("exception", {}).get("description")) + return res["result"].get("value") + + def text(self) -> str: + return self.eval("document.body.innerText") + + def sidebar(self) -> str: + return self.eval("(document.querySelector('aside') || {}).innerText || ''") + + def click(self, label: str, timeout: float = 30) -> None: + """Click a button or link by its label once it is there and enabled.""" + time.sleep(0.3) # after a fill, let the page render what it was given + _wait(f"a control named {label!r}", + lambda: _try(lambda: self.eval(f"(clickText({json.dumps(label)}), 1)")), timeout) + + def close(self): + self._conn.__exit__(None, None, None) + + +def _http_get(url: str, token: str | None = None) -> str: + import urllib.request + req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"} if token else {}) + with urllib.request.urlopen(req, timeout=5) as r: + return r.read().decode() + + +def _node_status() -> dict | None: + import urllib.request + try: + token = (NODE_HOME / "data" / "ui-token").read_text(encoding="utf-8").strip() + with urllib.request.urlopen(f"http://127.0.0.1:18000/api/status?t={token}", + timeout=3) as r: + return json.loads(r.read()) + except (OSError, ValueError): + return None + + +@pytest.fixture(scope="module") +def hub(tmp_path_factory): + pytest.importorskip("meshbay_hub") + pytest.importorskip("websockets.sync.client") + work = tmp_path_factory.mktemp("hub") + port = _free_port() + env = {**os.environ, + "MESHBAY_DATABASE_URL": f"sqlite+aiosqlite:///{(work / 'hub.db').as_posix()}", + "MESHBAY_HUB_ID": "127.0.0.1", "MESHBAY_HUB_KEY": str(work / "hub_private.pem"), + "MESHBAY_HUB_HOST": "127.0.0.1", "MESHBAY_HUB_PORT": str(port)} + proc = subprocess.Popen([sys.executable, "-m", "uvicorn", "meshbay_hub.app:create_app", + "--factory", "--host", "127.0.0.1", "--port", str(port)], + env=env, cwd=work, stdout=(work / "hub.log").open("w"), + stderr=subprocess.STDOUT) + url = f"http://127.0.0.1:{port}" + try: + _wait("the local hub", lambda: _try(lambda: _http_get(f"{url}/v1/hub/version")), 30) + yield {"url": url, "db": work / "hub.db"} + finally: + proc.kill() + + +def _try(fn): + try: + return fn() + except Exception: + return None + + +@pytest.fixture(scope="module") +def app(hub): + exe = INSTALL / "MeshBay.exe" + if not exe.exists(): + pytest.skip(f"no installed MeshBay at {INSTALL}") + for there in (NODE_HOME / "node.toml", NODE_HOME / "keystore.enc", APP_PROFILE): + if there.exists(): + pytest.fail(f"{there} exists: this test only runs on a machine with no node " + "and no app profile -- move them aside, it never deletes them") + if MODE == "service": + r = subprocess.run(["schtasks", "/query", "/tn", "MeshBay Node"], capture_output=True) + assert r.returncode == 0, "service mode chosen at install, but there is no boot task" + APP_PROFILE.mkdir(parents=True) + (APP_PROFILE / "config.json").write_text(json.dumps({"hubBase": hub["url"]}), + encoding="utf-8") + _launch([str(exe), f"--remote-debugging-port={CDP_PORT}"]) + page = _wait("the application window", lambda: _try(Page), 60) + try: + yield page + finally: + # What the window showed last: the first thing to read when it failed. + last = _try(lambda: page.eval("location.href + ' ' + document.body.innerText")) + print(f"\n--- the application's page at the end ---\n{last}") + page.close() + subprocess.run(["taskkill", "/F", "/IM", "MeshBay.exe"], capture_output=True) + subprocess.run([str(INSTALL / "resources" / "node-runtime" / "meshbay-node.exe"), + "autostart", "stop"], capture_output=True, timeout=120) + + +def test_a_fresh_account_gets_its_node_set_up_and_can_stop_it(app, hub): + page = app + username = "e2e" + secrets.token_hex(3) + passphrase = "e2e-" + secrets.token_urlsafe(18) + + # Register. + _wait("the sign-in page", lambda: "Register" in page.text(), 30) + page.eval("(location.hash = '#/register', 1)") + _wait("the registration form", lambda: page.eval( + "document.querySelectorAll('main input').length >= 4"), 15) + page.eval(f"""(() => {{ const i = document.querySelectorAll('main input'); + fill(i[0], {json.dumps(username)}); fill(i[1], {json.dumps(username + '@example.invalid')}); + fill(i[2], {json.dumps(passphrase)}); fill(i[3], {json.dumps(passphrase)}); + return 1; }})()""") + time.sleep(0.5) # the form's state follows the input events a render later + page.eval(f"({SUBMIT}, 1)") + # Not "recovery key" alone: the registration form says it too. + _wait("the recovery key", lambda: "I have saved my recovery key" in page.text(), 90) + page.eval("(document.querySelector('input[type=checkbox]').click(), 1)") + page.click("Continue") + _wait("the verification form", lambda: "Verify" in page.text(), 30) + code = sqlite3.connect(hub["db"]).execute( + "select code from email_verifications order by rowid desc limit 1").fetchone()[0] + page.eval(f"(fill(document.querySelector('main input'), {json.dumps(code)}), 1)") + page.click("Verify") + _wait("the verified page", lambda: "Go to login" in page.text(), 30) + page.click("Go to login") + _wait("the login form", lambda: page.eval( + "document.querySelectorAll('main input').length >= 2"), 15) + page.eval(f"""(() => {{ const i = document.querySelectorAll('main input'); + fill(i[0], {json.dumps(username)}); fill(i[1], {json.dumps(passphrase)}); + return 1; }})()""") + time.sleep(0.5) # the form's state follows the input events a render later + page.eval(f"({SUBMIT}, 1)") + + # Nothing else done: the node is set up, running, linked, and in the sidebar. + _wait("the Node section in the sidebar", lambda: "Create group" in page.sidebar(), 120) + status = _wait("a running node", lambda: (s := _node_status()) and + s.get("status") == "running" and s, 120) + toml = (NODE_HOME / "node.toml").read_text(encoding="utf-8") + assert f'username = "{username}"' in toml and hub["url"] in toml + token = page.eval("JSON.parse(localStorage.getItem('mb_auth')).token") + keys = json.loads(_http_get(f"{hub['url']}/v1/users/{username}/pubkeys", token)) + assert keys.get("pk_node_ed25519") == status["pk_node_ed25519"] + + # Stop: no process left, and only then "Stopped". + page.eval("(location.hash = '#/node', 1)") + _wait("the Node page", lambda: "Restart" in page.text(), 30) + page.click("Stop") + _wait("no meshbay-node.exe", lambda: not _node_pids(), 60) + _wait("the page saying Stopped", lambda: "Stopped" in page.text(), 30) + assert _node_status() is None + + # Start, then Restart: a running node each time, a new one for Restart. + page.click("Start") + first = _wait("a node after Start", lambda: (s := _node_status()) and + s.get("status") == "running" and s, 120) + _wait("the page saying Running", lambda: "Running" in page.text(), 30) + page.click("Restart") + second = _wait("a new node after Restart", lambda: (s := _node_status()) and + s.get("status") == "running" and s.get("pid") != first.get("pid") and s, 120) + assert len(_node_pids()) == 1, _node_pids() + assert second["pk_node_ed25519"] == first["pk_node_ed25519"] diff --git a/packages/meshbay-node/tests/test_packaging_win.py b/packages/meshbay-node/tests/test_packaging_win.py index ad366d0..1fe6be8 100644 --- a/packages/meshbay-node/tests/test_packaging_win.py +++ b/packages/meshbay-node/tests/test_packaging_win.py @@ -824,7 +824,10 @@ def test_nothing_treats_find_node_binary_as_always_a_promise(): 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 + # Nothing answering is not enough: the control API closes before the + # process exits, and the page said "Stopped" about a node still running. + assert "if (await probeNode() || (await winNodePids()).length)" in stop + assert "if (!r.ok)" in stop and "throw new Error" in stop def test_node_start_provisions_before_it_starts_anything(): diff --git a/packages/meshbay-node/tests/test_windows_node_lifecycle.py b/packages/meshbay-node/tests/test_windows_node_lifecycle.py new file mode 100644 index 0000000..241f583 --- /dev/null +++ b/packages/meshbay-node/tests/test_windows_node_lifecycle.py @@ -0,0 +1,235 @@ +"""The node's life on a Windows desktop: set up at sign-in, stopped for real. + +Two defects from the first Windows beta tester, each found again live on a +clean install (2026-10-07): + +- After a service-mode install the node was never set up for the account that + signed in, the Node section stayed out of the sidebar, and the way out was + `meshbay-node init` in a terminal plus the key pasted on the profile page. +- A Stop left meshbay-node.exe running while the Node page said "Stopped": the + control API closes before the process exits, and the page asked nothing else. + +What can run for real here does (processes, the CLI's stop, the exit timer); +the desktop application's side is pinned by reading main.js and app.js, the only +evidence available without launching it -- tests/e2e_windows_app.py launches it. +""" + +import inspect +import os +import subprocess +import sys +import textwrap +import time +from pathlib import Path + +import pytest +from meshbay_node import platform as plat +from meshbay_node.cli import lifecycle + +ROOT = Path(__file__).resolve().parents[3] +MAIN_JS = ROOT / "packages" / "meshbay-client" / "src" / "main.js" +STATIC = ROOT / "packages" / "meshbay-hub" / "src" / "meshbay_hub" / "static" + + +def _js(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _block(src: str, start: str, end: str = "\n });\n") -> str: + return src.split(start, 1)[1].split(end, 1)[0] + + +# ── node.toml ──────────────────────────────────────────────────────────────── + +def test_a_byte_order_mark_in_node_toml_is_read(tmp_path): + from meshbay_node.config import load_config + cfg_file = tmp_path / "node.toml" + cfg_file.write_bytes(b"\xef\xbb\xbf" + + b'[hub]\nurl = "http://127.0.0.1:1"\nusername = "someone"\n') + assert load_config(cfg_file).hub.username == "someone" + + +def test_a_node_toml_it_cannot_read_is_said_not_swallowed(tmp_path): + """A service node has no console: the reason it will not start goes to + the log, where it used to die after "Logging to ..." in silence.""" + cfg_file = tmp_path / "node.toml" + cfg_file.write_text("[hub\nusername = ", encoding="utf-8") + env = {**os.environ, "LOCALAPPDATA": str(tmp_path), "APPDATA": str(tmp_path), + "HOME": str(tmp_path), "USERPROFILE": str(tmp_path)} + r = subprocess.run([sys.executable, "-c", "from meshbay_node.daemon import main; main()", + "--config", str(cfg_file)], + capture_output=True, text=True, timeout=60, env=env) + assert r.returncode == 1, r.stdout + r.stderr + assert "cannot read" in r.stdout + if sys.platform == "win32": + log = tmp_path / "meshbay" / "state" / "node.log" + assert "cannot read" in log.read_text(encoding="utf-8") + + +# ── the daemon leaves when it has stopped ──────────────────────────────────── + +def test_a_stopped_node_does_not_wait_for_a_busy_thread(): + """A worker still walking a large tree kept the process alive after + _shutdown(), with its control API already closed.""" + script = textwrap.dedent(""" + import threading, time + from meshbay_node.daemon import exit_after + threading.Thread(target=time.sleep, args=(120,), name="walker").start() + exit_after(0.5, "the node has stopped") + """) + t0 = time.monotonic() + r = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, + timeout=60) + assert r.returncode == 0, r.stderr + assert time.monotonic() - t0 < 30 + assert "walker" in r.stderr, "it names what it did not wait for" + + +def test_only_the_real_daemon_ends_its_own_process(): + """Tests run daemons inside pytest: the timer must be armed by main() + alone, both before _shutdown() (a hung shutdown) and after it.""" + from meshbay_node import daemon + assert daemon.NodeDaemon.exit_process_when_stopped is False + assert "daemon.exit_process_when_stopped = True" in inspect.getsource(daemon.main) + shutdown = inspect.getsource(daemon.NodeDaemon._shutdown) + assert shutdown.count("if self.exit_process_when_stopped:") == 2 + assert shutdown.index("SHUTDOWN_DEADLINE_SECS") < shutdown.index('log.info("Node stopped")') \ + < shutdown.index("EXIT_GRACE_SECS") + + +# ── the CLI's stop: by pid, then checked ───────────────────────────────────── + +class _Stub: + def __init__(self, monkeypatch, *, answering=None, graceful=False, pids=(), + kill_works=True): + self.killed: list[int] = [] + self.pids = list(pids) + monkeypatch.setattr(lifecycle, "_running_status", lambda cfg: answering) + monkeypatch.setattr(plat, "request_graceful_stop", lambda *a, **k: graceful) + monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""}) + monkeypatch.setattr(plat, "_pid_alive", lambda pid: pid in self.pids) + monkeypatch.setattr(plat, "autostart_end", lambda: None) + monkeypatch.setattr(plat, "node_pids", lambda: list(self.pids)) + + def kill(pid): + self.killed.append(pid) + if kill_works: + self.pids.remove(pid) + monkeypatch.setattr(plat, "kill_pid", kill) + clock = iter(range(0, 10_000)) + monkeypatch.setattr(time, "monotonic", lambda: next(clock)) + monkeypatch.setattr(time, "sleep", lambda s: None) + + +class _Cfg: + data_dir = Path(".") + + class node: + ui_port = 1 + + +def test_a_stop_the_node_did_not_finish_kills_it_by_pid(monkeypatch): + stub = _Stub(monkeypatch, answering={"pid": 4242}, graceful=False, pids=[4242]) + assert lifecycle._stop_node(_Cfg) == "stopped (forced)" + assert stub.killed == [4242] + + +def test_a_node_process_left_behind_is_never_called_stopped(monkeypatch): + _Stub(monkeypatch, answering=None, graceful=False, pids=[5151], kill_works=False) + with pytest.raises(lifecycle.NodeStillRunning, match="5151"): + lifecycle._stop_node(_Cfg) + + +def test_a_node_answering_nothing_is_still_a_node(monkeypatch): + """Its API closed, its process there: not "not running".""" + stub = _Stub(monkeypatch, answering=None, graceful=False, pids=[6161]) + monkeypatch.setattr(plat, "autostart_end", lambda: stub.pids.clear()) + assert lifecycle._stop_node(_Cfg) == "stopped (forced)" + + +def test_a_stop_that_fails_exits_non_zero_with_the_reason(monkeypatch, capsys): + def refuse(cfg): + raise lifecycle.NodeStillRunning("meshbay-node is still running (pid 7)") + monkeypatch.setattr(lifecycle, "_stop_node", refuse) + monkeypatch.setattr(lifecycle, "load_config", lambda p: _Cfg) + + class Args: + config = None + subcommand = "stop" + monkeypatch.setattr(plat, "service_status", lambda: {"installed": False, "state": ""}) + monkeypatch.setattr(sys, "platform", "win32") + with pytest.raises(SystemExit) as e: + lifecycle.autostart(Args) + assert e.value.code == 1 + assert "pid 7" in capsys.readouterr().out + + +@pytest.mark.skipif(sys.platform != "win32", reason="lists and kills real processes") +def test_node_pids_finds_every_copy_but_its_caller(tmp_path, monkeypatch): + """Under an image name of this test's own: the real one would find, and + kill, the developer's node.""" + import shutil + exe = tmp_path / "mbpidtest.exe" + shutil.copy(r"C:\Windows\System32\PING.EXE", exe) + proc = subprocess.Popen([str(exe), "-n", "300", "127.0.0.1"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + monkeypatch.setattr(plat, "NODE_IMAGE", "mbpidtest.exe") + try: + assert plat.node_pids() == [proc.pid] + plat.kill_pid(proc.pid) + assert proc.wait(timeout=10) is not None + assert plat.node_pids() == [] + finally: + if proc.poll() is None: + proc.kill() + + +# ── the desktop application: what it says, and when it sets the node up ────── + +def test_the_node_page_asks_the_node_not_only_the_task(): + """A node started from a terminal runs while the service task reads + "Ready"; one on its way out answers nothing for a while. The page said + "Stopped" about both.""" + main_js = _js(MAIN_JS) + status = main_js.split("async function nodeServiceStatus()", 1)[1].split("\n }\n", 1)[0] + assert "/running/i.test(svc.state)" not in status + assert status.count("winNodeActivity()") == 2 + activity = main_js.split("async function winNodeActivity()", 1)[1].split("\n }\n", 1)[0] + assert "probeNode()" in activity and "winNodePids()" in activity + assert "'unknown'" in activity and "'inactive'" in activity + page = _js(STATIC / "node-page.js") + assert "const stoppable = info.activeState !== 'inactive';" in page + assert "disabled=${!!busy || !stoppable}" in page + + +def test_signing_in_sets_up_this_machines_node(): + main_js = _js(MAIN_JS) + assert "handle('node:ensure'" in main_js + ensure = main_js.split("async function ensureNode(opts)", 1)[1].split("\n }\n", 1)[0] + # Only the node this build ships, on Windows: Linux is unchanged. + assert "process.platform !== 'win32' || !hasBundledNode()" in ensure + # A node set up for someone else is left as it is... + assert ensure.index("return { state: 'other_account' }") < ensure.index("provisionNode(") + # ...and so is an account already linked to another node. + assert "if (linked && linked !== p.pk_node_ed25519) return { state: 'other_node' };" in ensure + assert ensure.index("state: 'other_node'") < ensure.index("linkNodeKeyAndAwaitRunning(") + # Started however this machine is set up, through the CLI like every start. + assert "winNodeStartVia(['autostart', 'start'])" in ensure + # One start at a time. + start = main_js.split("handle('node:start'", 1)[1][:400] + assert "if (nodeEnsuring) await nodeEnsuring" in start + preload = _js(ROOT / "packages" / "meshbay-client" / "src" / "preload.js") + assert "ensure: (opts) => ipcRenderer.invoke('node:ensure', opts)" in preload + assert "bridge.node.ensure(opts)" in _js(STATIC / "platform.js") + + +def test_the_node_section_shows_for_a_node_not_yet_linked(): + """It waited for the hub to hold a node key, which nothing gave it.""" + app = _js(STATIC / "app.js") + assert "showNode=${hasNodeKey || localNode}" in app + assert "platform.capabilities.nodeAdmin && (hasNodeKey || localNode)" in app + effect = app.split("const signedInAs = user ? user.username : null;", 1)[1] + effect = effect.split("}, [signedInAs]);", 1)[0] + assert "platform.node.ensure(" in effect and "platform.node.installed()" in effect + # The token rotates; a renewal is not a sign-in. + assert "}, [signedInAs]);" in app |