Skip to content

Commit b0dbbf1

Browse files
authored
fix(watchd): resolve aliases, sync state at startup, tell the truth when a log is empty (#91)
Three defects from the first live run of the daemon against a real homeserver. #88 said the sync loop was unverified; this is what running it found. ## Every send through the socket failed Commands hand the daemon the room argument a human typed, normally an alias. They cannot resolve it themselves — resolving needs credentials, and the daemon is holding those — so the socket received `#test:example.org` and passed it to `room_send`, which wants a room id. ``` Error: LocalProtocolError: No such room with id #test:example.org found. ``` The daemon resolves now, checking its watched rooms first so the common case costs nothing. ## And then it still failed, on the id ``` Error: LocalProtocolError: No such room with id !CkTNagkk...:example.org found. ``` A sync resumed from a stored token returns only what is new, so nio's room list stayed empty — and `room_send` looks the room up there to decide how to encrypt. The daemon now does one `full_state=True` sync before serving anything. Two errors, one message, different causes. The second only became visible once the first was fixed, which is the ordinary shape of this: a fix does not confirm a diagnosis, it exposes the next thing. ## The reader blamed the daemon for being absent while it was running Any missing log produced: ``` No log for !room:example.org. Start the daemon and add the room to watch_rooms: matrix-watchd.py --start ``` A missing log has three causes, and that message names one. The most common in practice is the one it does not cover: the daemon is running, it is watching that room, and nothing has been said yet — a sync does not replay history, so the log starts at the first event after the daemon began watching. Telling someone to restart a healthy daemon is worse than saying nothing. It now asks the daemon over the socket and distinguishes the three, exiting 0 for the case that is not an error. ## Verification End to end against a live homeserver, in `#test` as the repository's testing note requires: ``` $ matrix-send-e2ee.py '#test:example.org' "…" Event ID: $k40enVvKZjibK3EjppBjqa3fU924dRlT9182CM3n6xY $ matrix-watch.py '#test:example.org' --once since last: 1 messages, 0 mentioning you [10:34] Sebastian Mendel: 🤖 Daemon-Test: Runde durch den Socket ``` Socket in, homeserver, daemon sync, decryption, log, reader. A second message sent while a reader was already following appeared in it within the poll interval. 44 existing tests still pass. No test is added here: all three defects are in the seam between the daemon and a live homeserver, which is the part #88 documented as not unit-testable, and a mock of nio's room list would assert my model of nio rather than nio.
2 parents 9587690 + fa3d52e commit b0dbbf1

2 files changed

Lines changed: 50 additions & 5 deletions

File tree

skills/matrix-communication/scripts/matrix-watch.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from _lib import (
2727
cursor_path,
28+
daemon_request,
2829
load_config,
2930
log_path,
3031
read_cursor,
@@ -130,9 +131,27 @@ def main() -> int:
130131
cursor = cursor_path(directory, room_id, args.cursor)
131132

132133
if not log.exists():
134+
# A missing log has two very different causes, and saying the wrong one
135+
# sends people to restart a daemon that is already running.
136+
status = daemon_request({"op": "status"})
137+
if status and status.get("ok") and room_id in status.get("rooms", []):
138+
print(
139+
f"Watching {room_id}, nothing logged yet.\n"
140+
"The log starts at the first event after the daemon began "
141+
"watching; a sync does not replay history.",
142+
file=sys.stderr,
143+
)
144+
return 0
145+
if status and status.get("ok"):
146+
print(
147+
f"The daemon is running but not watching {room_id}.\n"
148+
'Add it to "watch_rooms" in ~/.config/matrix/config.json and '
149+
"restart it.",
150+
file=sys.stderr,
151+
)
152+
return 1
133153
print(
134-
f"No log for {room_id}.\n"
135-
"Start the daemon and add the room to watch_rooms:\n"
154+
f"No log for {room_id}, and no daemon is running.\n"
136155
" matrix-watchd.py --start",
137156
file=sys.stderr,
138157
)

skills/matrix-communication/scripts/matrix-watchd.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,24 @@ async def dispatch(self, request: dict) -> dict:
268268
except Exception as exc: # noqa: BLE001 # a bad request must not kill the daemon
269269
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
270270

271+
def room_id_for(self, room: str) -> str:
272+
"""Turn whatever the caller typed into a room id.
273+
274+
Commands hand over the argument a human wrote, which is usually an
275+
alias - they cannot resolve it themselves, because resolving needs
276+
credentials and the daemon is holding those. Watched rooms are already
277+
mapped, so the common case costs nothing.
278+
"""
279+
if room.startswith("!"):
280+
return room
281+
for room_id, label in self.rooms.items():
282+
if label == room:
283+
return room_id
284+
resolved = resolve_room_alias(self.config, room)
285+
if resolved:
286+
return resolved
287+
raise ValueError(f"cannot resolve room {room!r}")
288+
271289
async def op_send(self, request: dict) -> dict:
272290
content = {
273291
"msgtype": request.get("msgtype") or "m.text",
@@ -287,7 +305,7 @@ async def op_send(self, request: dict) -> dict:
287305
}
288306

289307
response = await self.client.room_send(
290-
room_id=request["room"],
308+
room_id=self.room_id_for(request["room"]),
291309
message_type="m.room.message",
292310
content=content,
293311
ignore_unverified_devices=True,
@@ -296,7 +314,7 @@ async def op_send(self, request: dict) -> dict:
296314

297315
async def op_react(self, request: dict) -> dict:
298316
response = await self.client.room_send(
299-
room_id=request["room"],
317+
room_id=self.room_id_for(request["room"]),
300318
message_type="m.reaction",
301319
content={
302320
"m.relates_to": {
@@ -311,7 +329,7 @@ async def op_react(self, request: dict) -> dict:
311329

312330
async def op_redact(self, request: dict) -> dict:
313331
response = await self.client.room_redact(
314-
room_id=request["room"],
332+
room_id=self.room_id_for(request["room"]),
315333
event_id=request["event_id"],
316334
reason=request.get("reason"),
317335
)
@@ -369,6 +387,14 @@ async def run(self) -> int:
369387
server = await asyncio.start_unix_server(self.handle_client, str(path))
370388
os.chmod(path, 0o600)
371389

390+
# One state-carrying sync before anything is served. A sync resumed from
391+
# a stored token returns only what is new, so nio's room list stays
392+
# empty - and room_send looks a room up there to decide how to encrypt.
393+
# Without this the first send fails with "No such room with id", which
394+
# names the room it was just handed.
395+
await self.client.sync(timeout=10000, full_state=True)
396+
self.last_sync = int(time.time())
397+
372398
print(f"watching {len(self.rooms)} room(s), socket at {path}")
373399

374400
sync_task = asyncio.create_task(self.sync_loop())

0 commit comments

Comments
 (0)