feat(auto-propagation) fix to not get stuck on a node we cant reach.

This commit is contained in:
Ivan
2026-05-06 01:03:23 -05:00
parent 1afb2d99d2
commit e5e0bbd36d
5 changed files with 180 additions and 9 deletions
@@ -106,8 +106,27 @@ class AutoPropagationManager:
ctx = self.context
router = ctx.message_router
previous_hex = (
self.config.lxmf_preferred_propagation_node_destination_hash.get()
)
# If a sync is in progress, only interrupt it when the current node
# appears unreachable. This prevents getting stuck on a node we
# cannot get a path to.
if router.propagation_transfer_state != LXMRouter.PR_IDLE:
return
current_has_path = False
if previous_hex:
try:
current_dest = bytes.fromhex(previous_hex)
current_has_path = RNS.Transport.has_path(current_dest)
except Exception:
pass
if current_has_path:
# Sync is likely making progress let it finish.
return
# Current node is unreachable stop the stuck sync so we can
# look for a working alternative.
self.app.stop_propagation_node_sync(context=ctx)
announces = self.database.announces.get_announces(aspect="lxmf.propagation")
@@ -133,9 +152,6 @@ class AutoPropagationManager:
if not sorted_candidates:
return
previous_hex = (
self.config.lxmf_preferred_propagation_node_destination_hash.get()
)
ordered: list[tuple[int, str]] = []
seen_hex: set[str] = set()
if previous_hex and previous_hex in best_by_hex:
@@ -172,7 +188,18 @@ class AutoPropagationManager:
)
return
# None of the candidates worked. If the previously-selected node is
# still unreachable, clear it rather than restoring a broken node.
if previous_hex:
self.app.set_active_propagation_node(previous_hex, context=self.context)
try:
previous_dest = bytes.fromhex(previous_hex)
if RNS.Transport.has_path(previous_dest):
self.app.set_active_propagation_node(
previous_hex, context=self.context
)
return
except Exception:
pass
self.app.remove_active_propagation_node(context=self.context)
else:
self.app.remove_active_propagation_node(context=self.context)
+12
View File
@@ -101,6 +101,18 @@
>
<MaterialDesignIcon icon-name="phone" class="w-5 h-5 sm:w-6 sm:h-6" />
</button>
<button
type="button"
class="sm:hidden rounded-full p-1.5 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="isSyncingPropagationNode ? $t('app.syncing') : $t('app.sync_messages')"
@click="syncPropagationNode"
>
<MaterialDesignIcon
icon-name="refresh"
class="w-5 h-5"
:class="{ 'animate-spin': isSyncingPropagationNode }"
/>
</button>
<button type="button" class="hidden sm:flex rounded-full" @click="syncPropagationNode">
<span
class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-xs transition"
+6 -2
View File
@@ -228,11 +228,15 @@ export default {
.toast-swipe-out-left {
transform: translateX(-120%) !important;
opacity: 0 !important;
transition: transform 0.25s ease, opacity 0.25s ease !important;
transition:
transform 0.25s ease,
opacity 0.25s ease !important;
}
.toast-swipe-out-right {
transform: translateX(120%) !important;
opacity: 0 !important;
transition: transform 0.25s ease, opacity 0.25s ease !important;
transition:
transform 0.25s ease,
opacity 0.25s ease !important;
}
</style>
+101 -2
View File
@@ -15,8 +15,7 @@ _VALID_HASH_C = "03" * 16
_APP_DATA_ENABLED = b"\x94\x00\x00\x01\x00"
@pytest.mark.asyncio
async def test_auto_propagation_logic():
def _make_manager():
app = MagicMock()
context = MagicMock()
config = MagicMock()
@@ -30,6 +29,12 @@ async def test_auto_propagation_logic():
context.message_router.propagation_transfer_state = LXMRouter.PR_IDLE
manager = AutoPropagationManager(app, context)
return manager, app, context, config, database
@pytest.mark.asyncio
async def test_auto_propagation_logic():
manager, app, context, config, database = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = False
with patch.object(manager, "check_and_update_propagation_node") as mock_check:
@@ -115,3 +120,97 @@ async def test_auto_propagation_logic():
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
@pytest.mark.asyncio
async def test_auto_propagation_skips_when_sync_active_and_path_exists():
"""Skip auto-propagation changes while sync is active and the path exists.
When a sync is active and the current node still has a path, the manager
should leave it alone so the transfer can finish.
"""
manager, app, context, config, database = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
_VALID_HASH_A
)
context.message_router.propagation_transfer_state = LXMRouter.PR_RECEIVING
with patch.object(RNS.Transport, "has_path", return_value=True):
await manager.check_and_update_propagation_node()
app.stop_propagation_node_sync.assert_not_called()
app.set_active_propagation_node.assert_not_called()
app.remove_active_propagation_node.assert_not_called()
@pytest.mark.asyncio
async def test_auto_propagation_finds_new_node_when_sync_stuck_no_path():
"""Recover when sync is stuck and the current node has no path.
The manager should stop the stuck sync and look for a working node.
"""
manager, app, context, config, database = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
_VALID_HASH_A
)
context.message_router.propagation_transfer_state = LXMRouter.PR_PATH_REQUESTED
announce1 = {
"destination_hash": _VALID_HASH_B,
"app_data": _APP_DATA_ENABLED,
}
database.announces.get_announces.return_value = [announce1]
with (
patch.object(RNS.Transport, "has_path") as mock_has_path,
patch.object(RNS.Transport, "hops_to", return_value=1),
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
):
# Current node A has no path, candidate B has a path.
mock_has_path.side_effect = lambda dh: dh == bytes.fromhex(_VALID_HASH_B)
await manager.check_and_update_propagation_node()
app.stop_propagation_node_sync.assert_called_once_with(context=context)
app.set_active_propagation_node.assert_called_once_with(
_VALID_HASH_B,
context=context,
)
config.lxmf_preferred_propagation_node_destination_hash.set.assert_called_with(
_VALID_HASH_B,
)
@pytest.mark.asyncio
async def test_auto_propagation_removes_broken_node_when_all_candidates_fail():
"""Remove the active propagation node when no candidate works.
When no candidate works and the previous node is unreachable, the active
propagation node should be removed instead of restoring a broken one.
"""
manager, app, context, config, database = _make_manager()
config.lxmf_preferred_propagation_node_auto_select.get.return_value = True
config.lxmf_preferred_propagation_node_destination_hash.get.return_value = (
_VALID_HASH_A
)
announce1 = {
"destination_hash": _VALID_HASH_B,
"app_data": _APP_DATA_ENABLED,
}
database.announces.get_announces.return_value = [announce1]
with (
patch.object(RNS.Transport, "has_path", return_value=False),
patch.object(manager, "_wait_for_path", return_value=False),
):
await manager.check_and_update_propagation_node()
app.set_active_propagation_node.assert_not_called()
app.remove_active_propagation_node.assert_called_once_with(context=context)
@@ -573,6 +573,35 @@ describe("Conditional Rendering", () => {
const sidebarButton = wrapper.find("button.sm\\:hidden");
expect(sidebarButton.exists()).toBe(true);
});
it("App shows propagation sync refresh icon on mobile", async () => {
const wrapper = mount(App, {
global: {
stubs: {
RouterView: { template: "<div>Router View</div>" },
RouterLink: createRouterLinkStub(),
MaterialDesignIcon: { template: '<div data-icon-name="{{ iconName }}"></div>' },
LanguageSelector: { template: "<div></div>" },
NotificationBell: { template: "<div></div>" },
SidebarLink: {
template: '<div><slot name="icon"></slot><slot name="text"></slot></div>',
props: ["to", "isCollapsed"],
},
},
mocks: {
$route: { name: "messages", meta: {}, query: {} },
$router: { push: vi.fn() },
$t: (key) => key,
},
},
});
const mobileRefreshButtons = wrapper.findAll("button").filter((b) => {
const cls = b.classes().join(" ");
return cls.includes("sm:hidden") && b.attributes("title") === "app.sync_messages";
});
expect(mobileRefreshButtons.length).toBe(1);
});
});
describe("Dark Mode Class Application", () => {