aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md53
-rw-r--r--docs/MESHBAY_DESIGN.md18
-rw-r--r--docs/windows-build.md15
-rw-r--r--packages/meshbay-node/src/meshbay_node/platform.py8
-rw-r--r--packaging/win/README.md83
5 files changed, 159 insertions, 18 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 4d8a208..ef54db8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -343,6 +343,59 @@ do. Read them before writing anything that touches the same mechanism.
the page. `ask.js` draws both in the page, and
`test_no_native_dialogs_in_the_spa.py` bans all three browser dialogs
+- **An upgrade that cannot stop the node installs around it.** In service mode
+ the daemon runs in the task's S4U session, and the installer's unelevated
+ `taskkill` got "Access is denied" — which `main.js` had already recorded on
+ 2026-09-14, for itself only. It also ran in `customInstall`, which
+ electron-builder inserts *after* the files are copied; a test asserted that
+ `taskkill` under the name "stops a running daemon before overwriting it",
+ wrong on both counts. The locked `meshbay-node.exe` was not replaced, the
+ last-resort extract ignored that, and since a onedir exe embeds all the
+ node's Python, the 0.16 app and hub talked to the previous node: "started but
+ could not link", "No operator paired" (a node not yet signed in has not read
+ its roster, and answered `false`), and `service start` printing "started"
+ about a node nobody had checked. Linux never saw it — the package manager
+ restarts the unit. Stopping now happens in `customCheckAppRunning`
+ (`schtasks /end`, which the owner may do), the daemon logs to
+ `%LOCALAPPDATA%\meshbay\state\node.log` because Task Scheduler discards its
+ stderr, and the build starts the frozen daemon instead of only asking for
+ `--help`. **Read the installer framework's template before believing a hook
+ runs when its name suggests**, and **a message about a node must come from a
+ node that was asked** — every one of those four was a guess stated as a fact
+
+- **On Windows the CLI is the process it is stopping.** The frozen
+ `meshbay-node.exe` is the daemon *and* every CLI verb, so the forced-stop
+ fallback `taskkill /IM meshbay-node.exe` killed `autostart stop` and
+ `restart-daemon` themselves: exit 1, no output, and — for a restart — no node
+ afterwards. The app's Restart delegates to that verb, so it reported "the
+ node did not start". Every unit test passed, because `subprocess.run` was
+ mocked; it takes running the installed exe to see. It now spares its own pid
+ and its parent's (a venv's `meshbay-node.exe` is a launcher whose `python.exe`
+ child does the work), and `/T` takes the daemon's children with it.
+ `test_the_forced_stop_really_spares_its_caller` runs it for real, under an
+ image name of its own — under the real one it would kill the developer's node
+
+- **A second instance must fail before it touches anything shared.** The
+ daemon wrote `ui-token` and logged "Control API on …" *before* uvicorn had
+ bound the port. So the sign-in launcher, run while a node was up, started one
+ that overwrote the running node's token, failed to bind inside uvicorn's task,
+ and exited with the reason on a hidden console — nothing in `node.log`. The
+ node that stayed up then refused every graceful stop, status and restart,
+ because the token file named a dead process; the next upgrade had to kill it.
+ Linux has the same order. `bind_control_port` now takes the port first
+ (exclusively on Windows, where `SO_REUSEADDR` would let a second socket
+ share a port in use) and a refusal is logged and exits 2. **Log a claim
+ after it is true**: "Control API on" had always been printed about a
+ server that did not exist yet
+
+- **A test that redirects `HOME` isolates nothing on Windows.** `Path.home()`
+ reads `USERPROFILE` there and the node's directories come from
+ `LOCALAPPDATA`, so the CLI tests wrote `invite-code`, `pair-code` and
+ `invite-link` — `TEST-CODE`, `example.invalid` — into the developer's real
+ node directory on every run, and into a `~/.local/share/meshbay` created for
+ the purpose. The node conftest now points all four variables at a throwaway
+ directory for every test
+
- **A user unit cannot carry `User=`.** `meshbay-node.spec` installed the system
template into `%{_userunitdir}`, where systemd refuses the file outright — the
packaged unit could never have started, and nothing noticed because nobody had
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 4541d64..38ceec8 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -2942,10 +2942,20 @@ treated as correctness from the start. What the port needed is registered as
Two Windows-specific design points worth stating here:
-- **Autostart has two modes, chosen at install and switchable afterwards** from the
- Node page: a per-user startup launcher (the default) and a scheduled-task service
- mode. A per-user default is right for the desktop persona; a service is what a
- machine that must serve while nobody is logged in needs.
+- **The node runs in one of three modes, chosen at install and switchable
+ afterwards** from the Node page: only while the application is open (it starts
+ the node and stops one it started), at sign-in through a per-user startup
+ launcher, or as a scheduled-task service from boot. The two automatic ones
+ exclude each other. A per-user mode is right for the desktop persona; a service
+ is what a machine that must serve while nobody is logged in needs.
+- **Starting and stopping have one implementation, the CLI's**, which the
+ application, the installer's restart and a terminal all call. A stop asks the
+ node through its own control API first — the one channel that reaches it in any
+ session without elevation, and the one that runs its shutdown — then Task
+ Scheduler, then a forced stop that spares the command running it (the CLI and
+ the daemon are one executable). A start reports the version that answered, never
+ "started" about a node nobody asked. A second instance refuses before it writes
+ anything the running one depends on.
- **A user service unit cannot carry a system unit's user directive.** Two unit
templates exist, held apart by a test that parses directives rather than
searching the file — searching matched the *comment* explaining why the directive
diff --git a/docs/windows-build.md b/docs/windows-build.md
index 88133cb..3f14664 100644
--- a/docs/windows-build.md
+++ b/docs/windows-build.md
@@ -40,7 +40,9 @@ This runs [`packaging/win/build-win.ps1`](../packaging/win/build-win.ps1), which
4. `npm run sync-ui` — copies the SPA from `meshbay-hub/.../static`
5. runs [`build-node-runtime.ps1`](../packaging/win/build-node-runtime.ps1) —
creates a throwaway venv, installs `meshbay-node` + `meshbay-common`,
- freezes the daemon with PyInstaller, fetches and verifies ffmpeg
+ freezes the daemon with PyInstaller, fetches and verifies ffmpeg, then
+ starts the frozen daemon in a throwaway profile and checks it answers
+ (`smoke-node-runtime.ps1` — also runnable against an installed build)
6. `electron-builder --win nsis`
Expect this to take several minutes the first time (Chromium download,
@@ -76,7 +78,16 @@ prompt unless you opt into service mode (background daemon that starts at
boot, before sign-in) or accept the firewall rules, both offered during setup
and both switchable afterwards from the Node page. Installs to
`%LOCALAPPDATA%\Programs\MeshBay\`; runtime data lives in
-`%LOCALAPPDATA%\meshbay\`.
+`%LOCALAPPDATA%\meshbay\`, and the node's log in
+`%LOCALAPPDATA%\meshbay\state\node.log`.
+
+Installing over a previous version stops a running node first (including a
+background-service one) and starts the new one afterwards. To check what an
+installed build actually runs:
+
+```powershell
+meshbay-node service start # waits for the node, prints the version that answered
+```
## What's not automated yet
diff --git a/packages/meshbay-node/src/meshbay_node/platform.py b/packages/meshbay-node/src/meshbay_node/platform.py
index 09397a2..2255036 100644
--- a/packages/meshbay-node/src/meshbay_node/platform.py
+++ b/packages/meshbay-node/src/meshbay_node/platform.py
@@ -226,6 +226,14 @@ def _startup_vbs() -> Path:
def _pid_alive(pid: int) -> bool:
"""Whether a process with this pid exists, in any session (tasklist lists
session 0 too, where a service-mode daemon runs; opening it would not)."""
+ if sys.platform != "win32":
+ # A zombie counts as gone: it has exited, and only its parent has not
+ # reaped it yet -- os.kill(pid, 0) would still find it.
+ try:
+ stat = Path(f"/proc/{pid}/stat").read_text(encoding="ascii")
+ except OSError:
+ return False
+ return stat.rsplit(")", 1)[1].split()[0] != "Z"
r = subprocess.run(["tasklist", "/FI", f"PID eq {pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True)
return f'"{pid}"' in r.stdout
diff --git a/packaging/win/README.md b/packaging/win/README.md
index 0598193..3c141d1 100644
--- a/packaging/win/README.md
+++ b/packaging/win/README.md
@@ -36,17 +36,47 @@ back out on uninstall. New shells only — a `WM_SETTINGCHANGE` broadcast nudges
open ones. It uses stock `WordFunc.nsh` (the `EnVar` plugin is not in
electron-builder's NSIS bundle).
-## Autostart: two modes, one choice at install time
+## When the node runs: three modes, chosen at install time
-**Per-user (default, no admin).** A `.vbs` in the Startup folder
-(`meshbay_node.platform._startup_vbs`), toggled from the Node page or
-`meshbay-node autostart install|remove`. Starts when *this user* signs in.
+Setup's radio page, and the Node page's *Start automatically* selector
+afterwards, offer the same three:
-**Service mode (one admin confirmation, at install time only).** A Scheduled
-Task, `meshbay_node.platform.service_install` / `packaging/win/service.ps1`,
-that starts **at boot, before anyone signs in**. A real Windows Service would
-run under LocalSystem/NetworkService — accounts with no normal user profile,
-so `%LOCALAPPDATA%\meshbay\` (config, keystore, data) would not exist for it.
+| Mode | What runs it | Stops when |
+|---|---|---|
+| **Only while MeshBay is open** | the desktop app, at launch (once the node has been set up) | the app quits — only a node the app started |
+| **At sign-in** (no admin) | a `.vbs` in the Startup folder (`meshbay_node.platform._startup_vbs`); `meshbay-node autostart install` / `remove` | sign-out (a hidden console of its own delivers CTRL_LOGOFF) |
+| **Background service** (one admin confirmation) | the boot-time Scheduled Task below | shutdown |
+
+The first mode used to do neither half: nothing started the node with the app
+(after a reboot a group stayed offline with MeshBay open) and nothing stopped it
+at quit. The two automatic ones are mutually exclusive — both would start the
+node twice — and `autostart install` refuses while the boot task exists.
+
+**Starting, stopping, restarting — one implementation.** The CLI's
+(`meshbay_node/cli/lifecycle.py`); the Node page, the tray, `node:start` and a
+terminal all go through it. A stop asks the node through its own control API
+first (`POST /api/shutdown`, loopback, per-run token): the only channel that
+reaches a node in any session with no elevation, and the one that runs its
+`_shutdown()` — WebRTC sessions closed, transcodes stopped. Then Task Scheduler,
+then `taskkill`. Before this, every stop of a Windows node was a
+TerminateProcess (nine in a row, not one shutdown logged), the CLI's own
+CTRL_BREAK reached every process on *its* console and killed itself, and the app
+reported a service node it could not reach as stopped. A start launches the node
+with nothing of the caller's inherited (a child of Electron held Electron's
+sockets after the app quit) and reports the version that answered.
+
+**Switching modes.** Leaving service mode stops the node first — deleting a
+task does not end its running instance, which ran on in session 0 with nothing
+able to stop it — removes the task, keeps the firewall rules (every mode needs
+them; removing them left a node that silently accepted no connections), and
+starts the node again in the new mode. Entering it stops the running node first,
+or the service's own finds the control API's port taken and quits.
+
+**Background service.** A Scheduled Task,
+`meshbay_node.platform.service_install` / `packaging/win/service.ps1`, that
+starts **at boot, before anyone signs in**. A real Windows Service would run
+under LocalSystem/NetworkService — accounts with no normal user profile, so
+`%LOCALAPPDATA%\meshbay\` (config, keystore, data) would not exist for it.
Relocating storage to make that work is real surgery, deliberately not this.
The alternative used instead: `schtasks /create ... /ru <user> /rp ""` with no
@@ -62,8 +92,37 @@ state — the same reason `/sc onlogon` needed it too, back when Task Scheduler
was tried for the per-user mode and abandoned for exactly that reason).
Querying, starting and stopping an *already-created* task does not — Task
Scheduler grants the owning user that much itself, which is what lets the Node
-page's Start/Stop/Restart drive it with no further UAC prompts
-(`src/main.js`'s `winServiceTaskStatus/Run/End`, mirroring `service.ps1`).
+page's Start/Stop/Restart drive it with no further UAC prompts.
+
+**The task's settings.** Registered with no execution time limit, allowed to
+start and keep running on battery, `MultipleInstances IgnoreNew` and
+`StartWhenAvailable`. Task Scheduler's defaults end a task after 72 hours,
+never start it on battery and stop it when the cable comes out — each one a
+node that was simply down. `service.ps1 status` reports a task that still has
+those defaults, or that runs another executable than this install's, as stale
+(exit 2), and setup registers it again (its one elevation).
+
+**Upgrading a running node.** A service node lives in the task's S4U session,
+so an unelevated `taskkill` from setup gets "Access is denied" — and
+electron-builder's `customInstall` only runs after the files are copied anyway.
+Left running, a node keeps `meshbay-node.exe` locked, the copy fails, and
+electron-builder's last-resort extract ignores that. So `customCheckAppRunning`,
+which electron-builder runs before `uninstallOldVersion` and before extraction,
+runs `build/stop-node.ps1` (embedded in the installer — the installed copy of
+anything may be what is being replaced): the control API first, then
+`schtasks /end`, then `taskkill`, until no `meshbay-node.exe` is left; if one
+will not stop, setup says so and quits rather than half-upgrade.
+An upgrade keeps the mode it finds (`customInit` reads the task, then the
+launcher, then a previous install), restores the sign-in launcher — the previous
+version's uninstaller deletes it — and starts the node again the way that mode
+runs it. A silent upgrade of an "at sign-in" install used to come out with no
+autostart at all and its node stopped.
+
+**Logs.** A daemon started by the task or the Startup launcher has no console,
+so it also logs to `%LOCALAPPDATA%\meshbay\state\node.log` (rotated at 5 MB,
+three kept). That file is where to look when the app says the node did not
+start. `meshbay-node service start` waits for the daemon's control API and
+reports the version that answered, or points at this file.
**One elevation, not two.** Choosing service mode needs admin for both the
Scheduled Task *and* the firewall rules; `service-mode.ps1` runs both from a
@@ -90,7 +149,7 @@ That runs [`build-win.ps1`](build-win.ps1):
| 2 | `npm ci` + download Electron's Chromium |
| 3 | bump Electron to the latest release (Chromium CVE policy; `-NoElectronBump` to skip) |
| 4 | `npm run sync-ui` — copy the interface from `meshbay-hub/.../static` |
-| 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/` |
+| 5 | [`build-node-runtime.ps1`](build-node-runtime.ps1) — PyInstaller freeze → `packages/meshbay-client/node-runtime/`, then [`smoke-node-runtime.ps1`](smoke-node-runtime.ps1) starts the frozen daemon in a throwaway profile and checks it answers with the right version and writes its log |
| 6 | `electron-builder --win nsis` → `packages/meshbay-client/dist/MeshBay-Setup-<version>.exe` |
### Video (ffmpeg)