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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
|
"""
What a Windows node says about itself when something is wrong.
A 0.16 upgrade in service mode left the previous version's node running, and
every message on the way out pointed elsewhere: `meshbay-node service start`
printed "started" for a node that had not, the app said "the node started but
could not link to your hub account" about one that had stopped answering, the
Node page said "No operator paired" about one whose pairing was intact, and the
daemon itself had logged nowhere at all -- Task Scheduler discards its stderr.
"""
import argparse
import json
import logging
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
import pytest
from meshbay_node import __version__
from meshbay_node import platform as plat
from meshbay_node.cli import dispatch, lifecycle
# ── the daemon's log file ──────────────────────────────────────────────────
@pytest.fixture
def root_logger():
"""add_log_file changes the root logger: pin it and put it back."""
root = logging.getLogger()
handlers, level, disabled = list(root.handlers), root.level, root.disabled
root.setLevel(logging.INFO)
root.disabled = False
yield root
for h in root.handlers:
if h not in handlers:
h.close()
root.handlers[:] = handlers
root.setLevel(level)
root.disabled = disabled
def test_the_daemon_logs_to_a_file_where_there_is_no_console(tmp_path, monkeypatch, root_logger):
log_path = tmp_path / "state" / "node.log"
monkeypatch.setattr(plat, "log_file", lambda: log_path)
dispatch.add_log_file()
logging.getLogger("meshbay_node.daemon").warning("hub login failed: probe")
for h in root_logger.handlers:
h.flush()
assert log_path.exists(), "the log file's directory must be created, not assumed"
assert "hub login failed: probe" in log_path.read_text(encoding="utf-8")
def test_no_log_file_where_journald_has_the_output(monkeypatch, root_logger):
monkeypatch.setattr(plat, "log_file", lambda: None)
before = list(root_logger.handlers)
dispatch.add_log_file()
assert root_logger.handlers == before
def test_the_log_file_is_under_localappdata_on_windows(monkeypatch, tmp_path):
monkeypatch.undo() # the conftest keeps log_file() away from the real profile
monkeypatch.setattr(plat.sys, "platform", "win32")
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
assert plat.log_file() == tmp_path / "meshbay" / "state" / "node.log"
monkeypatch.setattr(plat.sys, "platform", "linux")
assert plat.log_file() is None
def test_only_the_daemon_run_opens_the_log_file(monkeypatch):
"""CLI verbs print reports; appending them to the daemon's log would bury
the daemon's own lines."""
calls = []
monkeypatch.setattr(dispatch, "add_log_file", lambda: calls.append(1))
monkeypatch.setattr(plat, "load_node_env", lambda _d: 0)
for argv, expected in ((["meshbay-node", "status"], 0), (["meshbay-node"], 1)):
calls.clear()
monkeypatch.setattr("sys.argv", argv)
dispatch.start()
assert len(calls) == expected, argv
# ── `service start` says what happened ──────────────────────────────────────
class _Status(BaseHTTPRequestHandler):
body = {}
def do_GET(self): # noqa: N802
data = json.dumps(self.body).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *_a):
pass
@pytest.fixture
def fake_daemon(tmp_path):
server = HTTPServer(("127.0.0.1", 0), _Status)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
data_dir = tmp_path / "data"
data_dir.mkdir()
cfg = argparse.Namespace(
data_dir=data_dir,
node=argparse.Namespace(ui_port=server.server_address[1]))
yield cfg
server.shutdown()
def test_a_token_from_the_stopped_instance_is_not_taken_for_the_new_one(fake_daemon):
_Status.body = {"version": "0.16.0", "status": "running"}
token = fake_daemon.data_dir / "ui-token"
token.write_text("t", encoding="utf-8")
old = time.time() - 60
os.utime(token, (old, old))
assert lifecycle._await_daemon(fake_daemon, since=time.time() - 1, timeout=1.5) is None
def test_a_daemon_that_started_is_seen(fake_daemon):
_Status.body = {"version": "0.16.0", "status": "waiting_for_account"}
since = time.time() - 1
(fake_daemon.data_dir / "ui-token").write_text("t", encoding="utf-8")
got = lifecycle._await_daemon(fake_daemon, since=since, timeout=5)
assert got == _Status.body
def _service_start(monkeypatch, awaited, capsys):
monkeypatch.setattr(plat, "service_status", lambda: {"installed": True, "state": "Ready"})
monkeypatch.setattr(plat, "service_run", lambda: None)
monkeypatch.setattr(plat, "service_end", lambda: None)
monkeypatch.setattr(plat, "log_file", lambda: "C:/x/meshbay/state/node.log")
monkeypatch.setattr(lifecycle, "load_config", lambda _p: object())
monkeypatch.setattr(lifecycle, "_running_status", lambda _cfg: None)
monkeypatch.setattr(lifecycle, "_await_daemon", lambda _cfg, _since, **_kw: awaited)
monkeypatch.setattr(lifecycle.sys, "platform", "win32")
lifecycle.service(argparse.Namespace(subcommand="start", config=None))
return capsys.readouterr().out
def test_service_start_does_not_report_a_node_that_never_answered(monkeypatch, capsys):
with pytest.raises(SystemExit) as exc:
_service_start(monkeypatch, None, capsys)
assert exc.value.code == 1
out = capsys.readouterr().out
assert not out.startswith("started"), "it must not claim the node started"
assert "no node answered" in out
assert "node.log" in out, "and must say where to look"
def test_service_start_names_the_version_that_answered(monkeypatch, capsys):
out = _service_start(monkeypatch, {"version": __version__, "status": "running"}, capsys)
assert f"node {__version__}" in out and "warning" not in out
def test_service_start_warns_when_the_service_runs_an_older_node(monkeypatch, capsys):
out = _service_start(monkeypatch, {"version": "0.15.0", "status": "waiting_for_account"},
capsys)
assert "0.15.0" in out and "warning" in out and "not replaced" in out
# ── a graceful stop, through the control API ───────────────────────────────
def test_the_control_api_asks_the_daemon_to_stop():
from fastapi.testclient import TestClient
from meshbay_node.ui import create_ui_app
asked = []
app = create_ui_app({"ui_token": "tok", "request_shutdown": lambda: asked.append(1)})
client = TestClient(app)
assert client.post("/api/shutdown").status_code in (401, 403), "behind the token"
assert asked == []
r = client.post("/api/shutdown?t=tok")
assert r.status_code == 200 and r.json() == {"stopping": True}
assert asked == [1]
def test_a_stop_request_ends_the_wait_for_the_hub():
"""A node waiting for its account is the one people stop and restart."""
import asyncio
from meshbay_node.daemon import NodeDaemon
async def go():
daemon = NodeDaemon.__new__(NodeDaemon)
daemon._stop_event = asyncio.Event()
asyncio.get_running_loop().call_later(0.1, daemon._stop_event.set)
t0 = time.monotonic()
stopped = await daemon._pause(30)
return stopped, time.monotonic() - t0
stopped, took = asyncio.run(go())
assert stopped and took < 5
class _RealNode:
"""A real daemon process in a throwaway profile, pointed at a hub that is
not there (it waits for it, control API up)."""
def __init__(self, tmp_path):
import socket
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
self.port = s.getsockname()[1]
self.tmp = tmp_path
home = tmp_path / "home"
self.conf = tmp_path / "node.toml"
unlock = tmp_path / "unlock.key"
unlock.write_text("x" * 43, encoding="utf-8")
self.data = tmp_path / "data"
self.conf.write_text(
f'data_dir = "{self.data.as_posix()}"\n'
'[hub]\nurl = "http://127.0.0.1:1"\nusername = "probe"\n'
f'[node]\nquic_enabled = false\nui_port = {self.port}\n'
f'[keystore]\npath = "{(tmp_path / "keystore.enc").as_posix()}"\n'
f'unlock_file = "{unlock.as_posix()}"\n', encoding="utf-8")
self.env = {**os.environ, "HOME": str(home), "LOCALAPPDATA": str(home),
"USERPROFILE": str(home)}
self.procs = []
def spawn(self, name: str):
import subprocess
import sys
out = self.tmp / f"{name}.txt"
with open(out, "w", encoding="utf-8") as f:
proc = subprocess.Popen(
[sys.executable, "-c", "from meshbay_node.daemon import main; main()",
"--config", str(self.conf)],
env=self.env, stderr=f, stdout=f)
proc.out = out
self.procs.append(proc)
return proc
def wait_up(self, proc) -> None:
deadline = time.monotonic() + 40
while not (self.data / "ui-token").exists() and time.monotonic() < deadline:
assert proc.poll() is None, proc.out.read_text(encoding="utf-8")
time.sleep(0.2)
time.sleep(1)
def kill_all(self) -> None:
for p in self.procs:
if p.poll() is None:
p.kill()
def test_a_real_daemon_shuts_down_properly_when_asked(tmp_path):
"""The whole path, on a real process: before this, nine stops of a Windows
node in a row logged not one shutdown -- every one was a TerminateProcess."""
node = _RealNode(tmp_path)
proc = node.spawn("first")
try:
node.wait_up(proc)
assert plat.request_graceful_stop(node.data, node.port, timeout=15)
assert proc.wait(timeout=10) is not None
err = proc.out.read_text(encoding="utf-8")
assert "Node stopped" in err, err[-2000:]
finally:
node.kill_all()
def test_a_second_node_leaves_the_running_one_alone_and_says_why(tmp_path):
"""Found on a real install: the sign-in launcher run while a node was up
started a second one, which wrote its token over the first's, failed to bind
inside uvicorn's task and exited with nothing in the log. The node that kept
running then refused every stop, status and restart -- the token file named
a process that no longer existed."""
node = _RealNode(tmp_path)
first = node.spawn("first")
try:
node.wait_up(first)
token = (node.data / "ui-token").read_text(encoding="utf-8")
second = node.spawn("second")
assert second.wait(timeout=40) == 2
said = second.out.read_text(encoding="utf-8")
assert f"127.0.0.1:{node.port} is taken" in said, said[-2000:]
assert "already running" in said
assert (node.data / "ui-token").read_text(encoding="utf-8") == token
assert first.poll() is None
assert plat.request_graceful_stop(node.data, node.port, timeout=15), (
"the running node can no longer be asked to stop")
finally:
node.kill_all()
def test_the_control_port_is_exclusive():
"""On Windows SO_REUSEADDR would let a second socket share a port in use."""
from meshbay_node.daemon import ControlPortTaken, bind_control_port
first = bind_control_port(0)
try:
port = first.getsockname()[1]
with pytest.raises(ControlPortTaken, match="already running"):
bind_control_port(port)
finally:
first.close()
# ── "No operator paired" only when the roster says so ──────────────────────
async def test_a_node_that_has_not_read_its_roster_does_not_claim_there_is_no_operator():
from meshbay_node import ops
result = await ops.list_groups({"config": None, "groups_ctx": {}})
assert result["operator_paired"] is None
async def test_a_node_with_a_roster_reports_the_operator(tmp_path):
from meshbay_node import ops
from meshbay_node.roster import Roster
roster = Roster(db_path=tmp_path / "roster.db")
await roster.open()
try:
result = await ops.list_groups({"config": None, "groups_ctx": {}, "roster": roster})
finally:
await roster.close()
assert result["operator_paired"] is False
|