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:
parent
5fa6f890b4
commit
8f4f732dca
|
|
@ -15,10 +15,14 @@ dependencies = [
|
|||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
watch = [
|
||||
"watchdog>=3.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"watchdog>=3.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
|
|
@ -568,6 +568,111 @@ def cmd_bridge(args):
|
|||
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():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
|
|
@ -709,6 +814,24 @@ def main():
|
|||
)
|
||||
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.add_argument(
|
||||
"-l", "--listen-port",
|
||||
|
|
@ -777,6 +900,8 @@ def main():
|
|||
cmd_gossip(args)
|
||||
elif args.command == "seed":
|
||||
cmd_seed(args)
|
||||
elif args.command == "setup":
|
||||
cmd_setup(args)
|
||||
elif args.command == "bridge":
|
||||
cmd_bridge(args)
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -137,6 +137,8 @@ class GossipRelay:
|
|||
self._known_refs: Dict[str, Dict[str, str]] = {} # rid -> refs
|
||||
self._refs_lock = threading.Lock()
|
||||
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
|
||||
|
||||
|
|
@ -148,18 +150,52 @@ class GossipRelay:
|
|||
RNS.Transport.register_announce_handler(self._on_announce)
|
||||
self.announce()
|
||||
threading.Thread(target=self._startup_announce_loop, daemon=True).start()
|
||||
self._start_watcher()
|
||||
threading.Thread(target=self._poll_loop, daemon=True).start()
|
||||
RNS.log(f"Gossip relay started: {self.destination.hexhash}", RNS.LOG_INFO)
|
||||
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,
|
||||
)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the relay."""
|
||||
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)
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
def announce(self):
|
||||
|
|
@ -220,8 +256,8 @@ class GossipRelay:
|
|||
except Exception as e:
|
||||
RNS.log(f"Error scanning storage: {e}", RNS.LOG_DEBUG)
|
||||
|
||||
def _poll_loop(self):
|
||||
while self._running:
|
||||
def _poll_loop_once(self):
|
||||
"""Check all watched repos for ref changes and broadcast any diffs."""
|
||||
if self.auto_discover:
|
||||
self._discover_rids()
|
||||
|
||||
|
|
@ -239,11 +275,12 @@ class GossipRelay:
|
|||
except Exception as e:
|
||||
RNS.log(f"Gossip poll error ({rid[:20]}): {e}", RNS.LOG_WARNING)
|
||||
|
||||
# Interruptible sleep
|
||||
for _ in range(self.poll_interval):
|
||||
if not self._running:
|
||||
return
|
||||
time.sleep(1)
|
||||
def _poll_loop(self):
|
||||
while self._running:
|
||||
self._poll_loop_once()
|
||||
# Wait for next poll: woken early by watchdog event or stop()
|
||||
self._poll_event.wait(timeout=self.poll_interval)
|
||||
self._poll_event.clear()
|
||||
|
||||
# ── Internal: sending ────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -376,3 +376,59 @@ class TestPushRefsNow:
|
|||
relay.push_refs_now("rad:z3abc123")
|
||||
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in New Issue