feat: watchdog push detection, setup command, and gossip refactor

Gossip relay now reacts to rad push instantly when watchdog is installed.
A threading.Event replaces the interruptible sleep loop: watchdog fires
the event on any filesystem change in the seed's storage directory, waking
the poll loop immediately.  Without watchdog the relay falls back to the
configured poll interval with a clear log message.

New command 'radicle-rns setup' checks all prerequisites and prints exact
fix instructions for anything missing: rad/radicle-node binaries, seed
identity, watchdog, and whether the seed is registered in the user's
radicle node.

Other changes:
- gossip: _poll_loop_once() extracted so tests can drive one iteration
- gossip: stop() sets poll_event so the thread exits without waiting out
  the full poll interval
- gossip: _start_watcher() creates storage dir if absent (watchdog requires
  the watched path to exist)
- pyproject.toml: watchdog>=3.0 added as [watch] optional dep and dev dep
- 5 new tests for watchdog/event/auto-discover behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Maciek "mab122" Bator 2026-04-22 22:28:42 +02:00
parent 5fa6f890b4
commit 8f4f732dca
4 changed files with 245 additions and 23 deletions

View File

@ -15,10 +15,14 @@ dependencies = [
] ]
[project.optional-dependencies] [project.optional-dependencies]
watch = [
"watchdog>=3.0",
]
dev = [ dev = [
"pytest>=7.0.0", "pytest>=7.0.0",
"pytest-asyncio>=0.21.0", "pytest-asyncio>=0.21.0",
"mypy>=1.0.0", "mypy>=1.0.0",
"watchdog>=3.0",
] ]
[project.scripts] [project.scripts]

View File

@ -568,6 +568,111 @@ def cmd_bridge(args):
print("Bridge stopped.") print("Bridge stopped.")
def cmd_setup(args):
"""Check prerequisites and print setup instructions."""
seed_home = Path(args.seed_home)
seed_port = args.seed_port
ok = True
def check(label: str, passed: bool, fix: Optional[str] = None) -> bool:
icon = "" if passed else ""
print(f" {icon} {label}")
if not passed and fix:
print(f"{fix}")
return passed
print("Checking prerequisites...")
# rad CLI
try:
r = subprocess.run(["rad", "--version"], capture_output=True, text=True, timeout=5)
ver = r.stdout.strip().split("\n")[0] if r.returncode == 0 else None
ok &= check(f"rad CLI{f' ({ver})' if ver else ''}", r.returncode == 0, "Install Radicle: https://radicle.xyz")
except FileNotFoundError:
ok &= check("rad CLI", False, "Install Radicle: https://radicle.xyz")
# radicle-node
try:
r = subprocess.run(["radicle-node", "--version"], capture_output=True, text=True, timeout=5)
ok &= check("radicle-node", r.returncode == 0, "Install Radicle: https://radicle.xyz")
except FileNotFoundError:
ok &= check("radicle-node", False, "Install Radicle: https://radicle.xyz")
# RNS Python library
try:
import RNS as _rns # noqa: F401
ok &= check("Reticulum (RNS)", True)
except ImportError:
ok &= check("Reticulum (RNS)", False, "pip install rns")
# watchdog (optional)
try:
import watchdog # noqa: F401
check("watchdog (instant push detection)", True)
except ImportError:
check(
"watchdog (optional — enables instant push detection)",
False,
"pip install watchdog # or: uv add watchdog",
)
print()
print("Seed identity...")
seed_node = SeedNode(seed_home=seed_home, port=seed_port)
seed_initialized = seed_node.is_initialized()
ok &= check(
f"Seed identity at {seed_home}",
seed_initialized,
f"RAD_HOME={seed_home} rad auth",
)
seed_nid: Optional[str] = None
if seed_initialized:
seed_nid = seed_node.get_nid()
check(
f"Seed NID: {seed_nid[:48] if seed_nid else '(could not read)'}",
bool(seed_nid),
)
print()
print("User radicle-node configuration...")
user_nid = detect_radicle_nid()
ok &= check(
f"Your radicle identity{f' ({user_nid[:32]}...)' if user_nid else ''}",
bool(user_nid),
"rad auth # initialise your radicle identity first",
)
# Check whether the seed is registered in the user's radicle-node.
# We do this by calling 'rad node' and looking for the seed NID.
seed_registered = False
if seed_nid:
try:
r = subprocess.run(
["rad", "node"], capture_output=True, text=True, timeout=5
)
seed_registered = seed_nid in r.stdout
except Exception:
pass
ok &= check(
"Seed registered in your radicle node",
seed_registered,
f"rad node connect {seed_nid}@127.0.0.1:{seed_port}",
)
print()
if ok:
print("All checks passed. Start the bridge with:")
print(f" radicle-rns seed")
else:
print("Setup incomplete. Follow the instructions above, then run:")
print(f" radicle-rns setup # re-check")
print(f" radicle-rns seed # once all checks pass")
def main(): def main():
"""Main entry point.""" """Main entry point."""
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
@ -709,6 +814,24 @@ def main():
) )
add_identity_arg(seed_parser) add_identity_arg(seed_parser)
setup_parser = subparsers.add_parser(
"setup",
help="Check prerequisites and print setup instructions",
)
setup_parser.add_argument(
"--seed-home",
default=str(DEFAULT_SEED_HOME),
metavar="PATH",
help=f"RAD_HOME for the seed node (default: {DEFAULT_SEED_HOME})",
)
setup_parser.add_argument(
"--seed-port",
type=int,
default=DEFAULT_SEED_PORT,
metavar="PORT",
help=f"Seed TCP port (default: {DEFAULT_SEED_PORT})",
)
bridge_parser = subparsers.add_parser("bridge", help="Run Radicle-Reticulum bridge") bridge_parser = subparsers.add_parser("bridge", help="Run Radicle-Reticulum bridge")
bridge_parser.add_argument( bridge_parser.add_argument(
"-l", "--listen-port", "-l", "--listen-port",
@ -777,6 +900,8 @@ def main():
cmd_gossip(args) cmd_gossip(args)
elif args.command == "seed": elif args.command == "seed":
cmd_seed(args) cmd_seed(args)
elif args.command == "setup":
cmd_setup(args)
elif args.command == "bridge": elif args.command == "bridge":
cmd_bridge(args) cmd_bridge(args)
else: else:

View File

@ -137,6 +137,8 @@ class GossipRelay:
self._known_refs: Dict[str, Dict[str, str]] = {} # rid -> refs self._known_refs: Dict[str, Dict[str, str]] = {} # rid -> refs
self._refs_lock = threading.Lock() self._refs_lock = threading.Lock()
self._running = False self._running = False
self._poll_event = threading.Event() # set by watchdog or stop()
self._observer = None # watchdog Observer, if available
self._on_sync_triggered: Optional[Callable[[str, str], None]] = None self._on_sync_triggered: Optional[Callable[[str, str], None]] = None
@ -148,18 +150,52 @@ class GossipRelay:
RNS.Transport.register_announce_handler(self._on_announce) RNS.Transport.register_announce_handler(self._on_announce)
self.announce() self.announce()
threading.Thread(target=self._startup_announce_loop, daemon=True).start() threading.Thread(target=self._startup_announce_loop, daemon=True).start()
self._start_watcher()
threading.Thread(target=self._poll_loop, daemon=True).start() threading.Thread(target=self._poll_loop, daemon=True).start()
RNS.log(f"Gossip relay started: {self.destination.hexhash}", RNS.LOG_INFO) RNS.log(f"Gossip relay started: {self.destination.hexhash}", RNS.LOG_INFO)
RNS.log( RNS.log(
f" Watching {len(self.rids)} repo(s), polling every {self.poll_interval}s", f" Watching {len(self.rids)} repo(s), "
f"{'inotify+' if self._observer else ''}poll every {self.poll_interval}s",
RNS.LOG_INFO, RNS.LOG_INFO,
) )
def stop(self): def stop(self):
"""Stop the relay.""" """Stop the relay."""
self._running = False self._running = False
self._poll_event.set() # wake poll loop so it exits promptly
if self._observer:
try:
self._observer.stop()
self._observer.join(timeout=3)
except Exception:
pass
RNS.log("Gossip relay stopped", RNS.LOG_INFO) RNS.log("Gossip relay stopped", RNS.LOG_INFO)
def _start_watcher(self):
"""Set up a watchdog filesystem observer if the library is available."""
try:
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
relay = self
class _Handler(FileSystemEventHandler):
def on_any_event(self, event):
if not event.is_directory:
relay._poll_event.set()
self.storage.mkdir(parents=True, exist_ok=True)
self._observer = Observer()
self._observer.schedule(_Handler(), str(self.storage), recursive=True)
self._observer.start()
RNS.log(f"Watchdog active on {self.storage}", RNS.LOG_INFO)
except ImportError:
RNS.log(
"watchdog not installed — install it for instant push detection "
"(pip install watchdog). Falling back to polling.",
RNS.LOG_INFO,
)
# ── Public API ─────────────────────────────────────────────────────────── # ── Public API ───────────────────────────────────────────────────────────
def announce(self): def announce(self):
@ -220,8 +256,8 @@ class GossipRelay:
except Exception as e: except Exception as e:
RNS.log(f"Error scanning storage: {e}", RNS.LOG_DEBUG) RNS.log(f"Error scanning storage: {e}", RNS.LOG_DEBUG)
def _poll_loop(self): def _poll_loop_once(self):
while self._running: """Check all watched repos for ref changes and broadcast any diffs."""
if self.auto_discover: if self.auto_discover:
self._discover_rids() self._discover_rids()
@ -239,11 +275,12 @@ class GossipRelay:
except Exception as e: except Exception as e:
RNS.log(f"Gossip poll error ({rid[:20]}): {e}", RNS.LOG_WARNING) RNS.log(f"Gossip poll error ({rid[:20]}): {e}", RNS.LOG_WARNING)
# Interruptible sleep def _poll_loop(self):
for _ in range(self.poll_interval): while self._running:
if not self._running: self._poll_loop_once()
return # Wait for next poll: woken early by watchdog event or stop()
time.sleep(1) self._poll_event.wait(timeout=self.poll_interval)
self._poll_event.clear()
# ── Internal: sending ──────────────────────────────────────────────────── # ── Internal: sending ────────────────────────────────────────────────────

View File

@ -376,3 +376,59 @@ class TestPushRefsNow:
relay.push_refs_now("rad:z3abc123") relay.push_refs_now("rad:z3abc123")
assert relay._known_refs["rad:z3abc123"] == SAMPLE_REFS assert relay._known_refs["rad:z3abc123"] == SAMPLE_REFS
# ── Watchdog / poll event ──────────────────────────────────────────────────────
class TestWatchdog:
def test_poll_event_wakes_poll_loop_early(self, tmp_path):
relay = _make_relay(tmp_path)
relay._known_refs["rad:z3abc123"] = {"refs/heads/main": "old"}
new_refs = {"refs/heads/main": "new"}
broadcasts = []
with patch("radicle_reticulum.gossip._read_refs", return_value=new_refs), \
patch.object(relay, "_broadcast", side_effect=lambda r, refs: broadcasts.append(r)):
# Signal the event (simulates watchdog firing)
relay._poll_event.set()
# Run one poll iteration
relay._poll_loop_once()
assert broadcasts == ["rad:z3abc123"]
def test_stop_sets_poll_event(self, tmp_path):
relay = _make_relay(tmp_path)
relay._running = True
with patch("radicle_reticulum.gossip.RNS.log"):
relay.stop()
assert relay._poll_event.is_set()
def test_start_watcher_graceful_without_watchdog(self, tmp_path):
relay = _make_relay(tmp_path)
with patch.dict("sys.modules", {"watchdog": None, "watchdog.observers": None,
"watchdog.events": None}), \
patch("radicle_reticulum.gossip.RNS.log"):
relay._start_watcher()
assert relay._observer is None
def test_auto_discover_adds_new_repos(self, tmp_path):
relay = _make_relay(tmp_path, rids=[])
storage = tmp_path / "storage"
storage.mkdir()
(storage / "z3newrepo").mkdir()
relay.storage = storage
relay._discover_rids()
assert "rad:z3newrepo" in relay.rids
def test_auto_discover_does_not_add_duplicates(self, tmp_path):
relay = _make_relay(tmp_path, rids=["rad:z3existing"])
storage = tmp_path / "storage"
storage.mkdir()
(storage / "z3existing").mkdir()
relay.storage = storage
relay._discover_rids()
assert relay.rids.count("rad:z3existing") == 1