feat: best-effort mirror (dual-send) of heartbeats + events

Optional second destination so each heartbeat and event is posted to a
mirror server (the office NAS) alongside the primary — dual-write to
de-risk the migration. Default off (blank mirror URLs); existing installs
unchanged.

- event_forwarder: mirror_reachable() fast-fail probe + mirror_forward_pass()
  (reliable event mirror with its OWN state file, total exception isolation,
  never raises into the primary path).
- series3_watcher: MIRROR_API_URL/MIRROR_SFM_URL/MIRROR_SFM_STATE_FILE config;
  best-effort heartbeat mirror; isolated event-mirror pass after the primary.
- settings dialog + config-template: new 'Mirror' tab / keys.
- 8 new tests incl. the isolation invariant. 44 passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 23:16:47 +00:00
parent 730f215e23
commit 4adaa09d75
5 changed files with 337 additions and 0 deletions
+122
View File
@@ -714,5 +714,127 @@ class TestForwardEventPair(unittest.TestCase):
self.assertIn("serial=BE11529", req["path"])
# ── Mirror (dual-send) ───────────────────────────────────────────────────────
def _mk_mirror_event(watch_dir, name="M529LK44.AB0", age_seconds=200, content=b"binary"):
"""Create a Blastware-shaped event binary with a controlled mtime."""
p = Path(watch_dir) / name
p.write_bytes(content)
target = time.time() - age_seconds
os.utime(str(p), (target, target))
return p
class TestMirrorReachable(unittest.TestCase):
def test_empty_url_is_unreachable(self):
self.assertFalse(ef.mirror_reachable("", timeout=1.0))
def test_dead_port_is_unreachable_and_fast(self):
t0 = time.time()
self.assertFalse(ef.mirror_reachable("http://127.0.0.1:1", timeout=2.0))
self.assertLess(time.time() - t0, 2.5)
def test_any_http_response_counts_as_reachable(self):
server, base = _start_fake_server()
try:
self.assertTrue(ef.mirror_reachable(base, timeout=2.0))
finally:
server.shutdown()
server.server_close()
class TestMirrorForwardPass(unittest.TestCase):
"""The dual-send entry point: best-effort, isolated, own state."""
def setUp(self):
_FakeImportHandler.received = []
self.server, self.base_url = _start_fake_server()
def tearDown(self):
self.server.shutdown()
self.server.server_close()
def test_empty_mirror_url_is_noop(self):
with tempfile.TemporaryDirectory() as tmp:
_mk_mirror_event(tmp)
mstate = ef.ForwardState(os.path.join(tmp, "mirror.json"))
self.assertIsNone(ef.mirror_forward_pass(tmp, "", mstate, max_age_days=30))
self.assertEqual(len(_FakeImportHandler.received), 0)
def test_skips_when_unreachable_without_posting(self):
with tempfile.TemporaryDirectory() as tmp:
_mk_mirror_event(tmp)
mstate = ef.ForwardState(os.path.join(tmp, "mirror.json"))
result = ef.mirror_forward_pass(
tmp, self.base_url, mstate,
reachable_fn=lambda url, timeout=3.0: False,
max_age_days=30, quiescence_seconds=5,
missing_report_grace_seconds=60, timeout=5.0,
)
self.assertIsNone(result)
self.assertEqual(mstate.count(), 0)
self.assertEqual(len(_FakeImportHandler.received), 0)
def test_forwards_when_reachable_using_its_own_state(self):
with tempfile.TemporaryDirectory() as tmp:
_mk_mirror_event(tmp)
mstate = ef.ForwardState(os.path.join(tmp, "mirror.json"))
counts = ef.mirror_forward_pass(
tmp, self.base_url, mstate,
reachable_fn=lambda url, timeout=3.0: True,
max_age_days=30, quiescence_seconds=5,
missing_report_grace_seconds=60, timeout=5.0,
)
self.assertIsNotNone(counts)
self.assertEqual(counts["forwarded"], 1)
self.assertEqual(mstate.count(), 1)
self.assertEqual(len(_FakeImportHandler.received), 1)
def test_never_raises_when_forward_blows_up(self):
def _boom(*a, **k):
raise RuntimeError("boom")
with tempfile.TemporaryDirectory() as tmp:
_mk_mirror_event(tmp)
mstate = ef.ForwardState(os.path.join(tmp, "mirror.json"))
orig = ef.forward_pending
ef.forward_pending = _boom
try:
result = ef.mirror_forward_pass(
tmp, self.base_url, mstate,
reachable_fn=lambda url, timeout=3.0: True,
max_age_days=30,
)
finally:
ef.forward_pending = orig
self.assertIsNone(result)
def test_down_mirror_leaves_primary_state_untouched(self):
with tempfile.TemporaryDirectory() as tmp:
_mk_mirror_event(tmp)
primary_state = ef.ForwardState(os.path.join(tmp, "primary.json"))
pcounts = ef.forward_pending(
tmp, self.base_url, primary_state,
max_age_days=30, quiescence_seconds=5,
missing_report_grace_seconds=60, timeout=5.0,
)
self.assertEqual(pcounts["forwarded"], 1)
primary_snapshot = primary_state.count()
mstate = ef.ForwardState(os.path.join(tmp, "mirror.json"))
t0 = time.time()
result = ef.mirror_forward_pass(
tmp, "http://127.0.0.1:1", mstate, # nothing listens
max_age_days=30, quiescence_seconds=5,
missing_report_grace_seconds=60, timeout=5.0,
)
self.assertIsNone(result)
self.assertLess(time.time() - t0, 3.5)
self.assertEqual(mstate.count(), 0)
self.assertEqual(primary_state.count(), primary_snapshot)
if __name__ == "__main__":
unittest.main()