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]
|
[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]
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue