Skip to content

Commit e383547

Browse files
Nursedudeclaude
andcommitted
fix: node cache dropped service_type on load — a writer with no reader
Live-caught during LXMF propagation adoption (plan step 2b). to_dict() has always written service_type, but _load_cache() restored 14 other fields and silently dropped this one. Every gateway restart therefore erased the RNS service type of every known node until it announced again — up to 6h for a propagation node's 360-min interval. Measured on moc immediately after the adoption restart: 2 of 9,115 cached entries carried service_type, and the oldest of those was stamped 90 seconds AFTER the restart. Everything heard before it lost the field. honest_failure_modes #4 (reader/writer pairs wire together or fail together), and it broke a real consumer: probe_lxmf_propagation_node_dark matches the configured node by service_type, so after the restart that ADOPTION ITSELF REQUIRES it reported our healthy, actively-serving node as "NEVER been heard" — the exact wrong-hash fault it exists to catch. The box had in fact heard it 7 times in 25h and had delivered a store-and-forward message through it 10 minutes earlier. Red-first: both tests fail with `assert None == 'LXMF_PROPAGATION'` before the one-line restore. Second test pins the full save->load round-trip so writer and reader fail together next time. lint 0, parity in sync, full suite 9001 passed / 1 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 0a85896 commit e383547

2 files changed

Lines changed: 65 additions & 0 deletions

File tree

src/gateway/node_tracker.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,15 @@ def _load_cache(self):
708708
node._state_machine = NodeStateMachine.from_dict(node_data['state_machine'])
709709
except Exception as e:
710710
logger.debug(f"Could not restore state machine: {e}")
711+
# Restore the RNS service type. to_dict() has always written
712+
# this; the loader used to drop it, so every restart erased
713+
# the service type of every known node until it announced
714+
# again — which for an LXMF propagation node is up to its
715+
# 360-min interval. A writer with no reader (honest_failure_
716+
# modes #4); it made probe_lxmf_propagation_node_dark report
717+
# a healthy configured node as "never heard" (2026-07-21).
718+
if node_data.get('service_type'):
719+
node.service_type = node_data['service_type']
711720
# Restore favorites from cache (BaseUI 2.7+)
712721
node.is_favorite = node_data.get('is_favorite', False)
713722
if node_data.get('favorite_updated'):

tests/test_node_tracker.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,62 @@ def test_load_cache(self, tmp_path):
588588
assert 'cached_1' in tracker._nodes
589589
assert tracker._nodes['cached_1'].name == 'Cached Node'
590590

591+
def test_load_cache_restores_service_type(self, tmp_path):
592+
"""service_type must survive the save/load round-trip.
593+
594+
Regression, live-caught 2026-07-21: ``to_dict()`` wrote service_type
595+
but ``_load_cache()`` restored 14 other fields and silently dropped
596+
this one, so every gateway restart erased the RNS service type of
597+
every known node until it announced again (up to 6h for a propagation
598+
node's 360-min interval).
599+
600+
That is honest_failure_modes #4 — a writer with no matching reader —
601+
and it broke a real consumer: ``probe_lxmf_propagation_node_dark``
602+
reads service_type to find the configured propagation node, so after
603+
the restart that ADOPTION ITSELF REQUIRES it saw the node as "never
604+
heard" and paged, while the node was healthy and serving.
605+
"""
606+
cache_file = tmp_path / "node_cache.json"
607+
cache_data = {
608+
'version': 1,
609+
'nodes': [{
610+
'id': 'rns_3968a2eeac25e2e7',
611+
'network': 'rns',
612+
'name': 'propagation node',
613+
'rns_hash': '3968a2eeac25e2e7a7961f25842d3d85',
614+
'service_type': 'LXMF_PROPAGATION',
615+
'last_seen': '2026-07-21T03:37:44.163446',
616+
}]
617+
}
618+
cache_file.write_text(json.dumps(cache_data))
619+
620+
with patch.object(UnifiedNodeTracker, 'get_cache_file', return_value=cache_file):
621+
tracker = UnifiedNodeTracker()
622+
623+
node = tracker._nodes['rns_3968a2eeac25e2e7']
624+
assert node.service_type == 'LXMF_PROPAGATION'
625+
626+
def test_save_then_load_preserves_service_type(self, tmp_path):
627+
"""The full round-trip, not just the read half.
628+
629+
Pins writer and reader together so a future change to either side
630+
fails here rather than fleet-wide six hours later.
631+
"""
632+
cache_file = tmp_path / "node_cache.json"
633+
634+
with patch.object(UnifiedNodeTracker, 'get_cache_file', return_value=cache_file):
635+
with patch.object(UnifiedNodeTracker, '_load_cache'):
636+
tracker = UnifiedNodeTracker()
637+
node = UnifiedNode(id="rns_abc", network="rns", name="pn")
638+
node.service_type = "LXMF_PROPAGATION"
639+
tracker.add_node(node)
640+
tracker._save_cache()
641+
642+
with patch.object(UnifiedNodeTracker, 'get_cache_file', return_value=cache_file):
643+
reloaded = UnifiedNodeTracker()
644+
645+
assert reloaded._nodes["rns_abc"].service_type == "LXMF_PROPAGATION"
646+
591647
def test_load_cache_handles_missing_file(self, tmp_path):
592648
"""Test loading when cache file doesn't exist."""
593649
cache_file = tmp_path / "nonexistent.json"

0 commit comments

Comments
 (0)