aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_windows_node_lifecycle.py
blob: 241f5830125e9ba1b13437edbddc281405a09a17 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
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