Skip to content

Commit af510bb

Browse files
author
Maksym Shtembuliak
committed
fix: reset failed admission before manual retry
1 parent 1deb44c commit af510bb

6 files changed

Lines changed: 173 additions & 16 deletions

File tree

‎custom_components/eybond_local/connection/admission_transaction.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,40 @@ def begin_callback_continuation(self) -> None:
769769
self._reset_callback_attempt()
770770
self._state = _STATE_CALLBACK_READY
771771

772+
def begin_manual_callback_continuation(self) -> None:
773+
"""Prepare one explicit manual callback attempt after admission failed.
774+
775+
The failure screen is shared by the passive-inbound verifier and the
776+
selected-route callback verifier. Those authorities end in different
777+
private states, but the user's explicit ``configure manually`` action
778+
has one meaning: discard every uncommitted attempt capability and start
779+
a fresh callback identity transaction with the same durable PN context.
780+
781+
A failed passive-inbound attempt uses the existing FAILED ->
782+
CALLBACK_READY transition. A selected-route attempt may already be
783+
callback-ready or may still hold an unadopted identity/recovery
784+
capability; reset it through the transaction's single cleanup boundary.
785+
READY is accepted only for an observed request carrying an explicit
786+
callback route: this covers a route attempt that failed before its first
787+
authority mutation. Running, adopted, handed-off and closed states
788+
remain fail-closed.
789+
"""
790+
791+
if self._request is None:
792+
raise RuntimeError("collector_admission_request_unavailable")
793+
if self._state == _STATE_FAILED:
794+
self.begin_callback_continuation()
795+
return
796+
if self._state == _STATE_READY:
797+
if type(self._request.callback_route) is not CallbackRecoveryRoute:
798+
raise RuntimeError("admission_transaction_not_callback_ready")
799+
elif self._state not in self._CALLBACK_RESETTABLE:
800+
raise RuntimeError("admission_transaction_not_callback_ready")
801+
if self._close_requested:
802+
raise RuntimeError("admission_transaction_closed")
803+
self._reset_callback_attempt()
804+
self._state = _STATE_CALLBACK_READY
805+
772806
def begin_observed_callback_continuation(self) -> None:
773807
"""Start selected-route verification from one exact observed session.
774808

‎custom_components/eybond_local/connection/recovery/verification_engine.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,7 +823,12 @@ async def _async_reset_phase(self) -> RecoveryVerificationOutcome | None:
823823
await self._close_channel()
824824
return self._fail(FAILURE_SESSION_UNAVAILABLE)
825825
except Exception as exc:
826-
logger.info(
826+
# This is a user-visible admission/transition failure. Keep the
827+
# exact typed management error in the normal HA log so a support
828+
# package can distinguish a rejected command, wrong response,
829+
# transport loss, and timeout without enabling debug logging or
830+
# guessing from the generic UI reason.
831+
logger.warning(
827832
"Recovery verification: collector %s restart not confirmed: %s",
828833
self._collector_pn,
829834
exc,

‎custom_components/eybond_local/flows/config/admission.py‎

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -743,20 +743,11 @@ async def async_step_verify_connection_manual_callback(
743743
transaction = self._admission_transaction
744744
self._admission_task = None
745745
if transaction is not None:
746-
if transaction.request.callback_route is not None:
747-
# The automatic recovery released its failed outcome back to
748-
# CALLBACK_READY. Keep the SAME transaction/identity context;
749-
# the manual submit runs a full new one-trigger attempt through
750-
# the shared continuation, with no PN/session hand-across.
751-
self._manual_preselected_strategy = (
752-
CONNECTION_STRATEGY_CALLBACK_ON_DEMAND
753-
)
754-
return await self.async_step_manual()
755-
# Clear the completed inbound attempt and enter the callback-ready
756-
# lifecycle IN the same transaction (its inbound owner was already
757-
# released and channels closed on the failure path). No hand-across,
758-
# no transaction close and no owner/PN/session copy.
759-
transaction.begin_callback_continuation()
746+
# One transaction operation normalizes both failure origins
747+
# (passive inbound and selected callback route) into a fresh manual
748+
# callback attempt. The flow never guesses the transaction's
749+
# private lifecycle state and never copies its PN/session authority.
750+
transaction.begin_manual_callback_continuation()
760751
# Only a form DEFAULT; the manual step still requires the user to
761752
# submit, and the transaction's callback identity/recovery path proves it.
762753
self._manual_preselected_strategy = CONNECTION_STRATEGY_CALLBACK_ON_DEMAND

‎tests/test_admission_callback_continuation.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,7 +820,7 @@ def test_bridge_does_not_close_the_transaction(self) -> None:
820820
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)
821821
}
822822
self.assertNotIn("async_close", calls)
823-
self.assertIn("begin_callback_continuation", calls)
823+
self.assertIn("begin_manual_callback_continuation", calls)
824824

825825
def test_source_boundary_selects_the_transaction_continuation(self) -> None:
826826
methods = self._flow_methods()

‎tests/test_collector_admission_transaction.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
HANDOFF_OK,
3232
)
3333
from custom_components.eybond_local.connection.recovery.verification import (
34+
CallbackRecoveryRoute,
3435
STATE_INBOUND_VERIFIED,
3536
InboundRecoveryOutcome,
3637
)
@@ -59,6 +60,22 @@ def _request(**kw):
5960
return CollectorAdmissionRequest(observed_session=_observed(**kw), origin="passive_scan")
6061

6162

63+
def _route_request(**kw):
64+
observed = _observed(**kw)
65+
return CollectorAdmissionRequest(
66+
observed_session=observed,
67+
origin="scan_selected_route",
68+
callback_route=CallbackRecoveryRoute(
69+
bind_ip="192.0.2.10",
70+
trigger_target_ip="203.0.113.10",
71+
trigger_udp_port=58899,
72+
advertised_ha_host="192.0.2.10",
73+
advertised_ha_port=observed.listener_port,
74+
listener_port=observed.listener_port,
75+
),
76+
)
77+
78+
6279
def _verified_outcome(pn=FULL_PN):
6380
proof = InboundRecoveryProof(
6481
method="reboot_reconnect_no_trigger",
@@ -663,6 +680,48 @@ async def test_missing_registry_fails_before_channel_or_restart(self):
663680
self.assertEqual(txn.state, "failed")
664681
self.assertFalse(txn.holds_claim)
665682

683+
async def test_manual_callback_bridge_normalizes_both_failure_origins(self):
684+
"""The failure-menu action always opens one fresh callback lifecycle."""
685+
686+
# A passive-inbound failure follows the explicit FAILED transition.
687+
inbound = _make(_request(), registry=_FakeRegistry())
688+
inbound._outcome = InboundRecoveryOutcome(
689+
failure_reason="restart_not_confirmed", collector_pn=SHORT_PN
690+
)
691+
inbound._state = "failed"
692+
inbound.begin_manual_callback_continuation()
693+
self.assertEqual(inbound.state, "callback_ready")
694+
self.assertIsNone(inbound.outcome)
695+
696+
# A selected route can fail before the first callback authority mutates
697+
# READY. It is still a valid explicit manual continuation and must not
698+
# produce admission_transaction_identity_not_runnable on form submit.
699+
selected = _make(_route_request(), registry=_FakeRegistry())
700+
selected.begin_manual_callback_continuation()
701+
self.assertEqual(selected.state, "callback_ready")
702+
703+
# A held, unadopted callback capability is discarded before the manual
704+
# attempt. Exact owner cleanup remains inside the transaction.
705+
held_registry = _FakeRegistry()
706+
held = _make(_route_request(), registry=held_registry)
707+
held._state = "identity_certified"
708+
held._registry = held_registry
709+
held._owner = "callback_verification:old"
710+
held_registry.claims[held._owner] = S1
711+
held.begin_manual_callback_continuation()
712+
self.assertEqual(held.state, "callback_ready")
713+
self.assertFalse(held.holds_claim)
714+
self.assertEqual(held_registry.released, ["callback_verification:old"])
715+
716+
async def test_manual_callback_bridge_refuses_live_or_committed_authority(self):
717+
for state in ("running", "identity_running", "recovery_running", "handed_off", "closed"):
718+
with self.subTest(state=state):
719+
txn = _make(_route_request(), registry=_FakeRegistry())
720+
txn._state = state
721+
with self.assertRaises(RuntimeError):
722+
txn.begin_manual_callback_continuation()
723+
self.assertEqual(txn.state, state)
724+
666725
async def test_registry_lookup_failure_is_typed_without_wire_io(self):
667726
reg = _LookupRaisingRegistry()
668727
txn = _make(_request(), registry=reg)

‎tests/test_config_flow.py‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12295,6 +12295,74 @@ async def test_D_manual_callback_bridge_keeps_transaction_as_continuation(
1229512295
)
1229612296
)
1229712297

12298+
async def test_selected_route_failure_can_enter_manual_without_unknown_error(
12299+
self,
12300+
) -> None:
12301+
"""Regression: route failure -> manual submit never reuses READY state."""
12302+
12303+
from custom_components.eybond_local.connection.callback_identity import (
12304+
CallbackIdentityOutcome,
12305+
)
12306+
from custom_components.eybond_local.connection.recovery.verification import (
12307+
CallbackRecoveryRoute,
12308+
)
12309+
12310+
flow = self._make_flow()
12311+
observed = ObservedCollectorSession(
12312+
collector_pn=self.FULL_PN,
12313+
identity_source="fc2_parameter_2",
12314+
session_id=self.OLD_SESSION,
12315+
listener_port=18899,
12316+
protocol_shape="eybond_framed",
12317+
peer_hint=self.PEER_IP,
12318+
)
12319+
request = CollectorAdmissionRequest(
12320+
observed_session=observed,
12321+
origin="scan_selected_route",
12322+
callback_route=CallbackRecoveryRoute(
12323+
bind_ip="192.168.1.50",
12324+
trigger_target_ip=self.PEER_IP,
12325+
trigger_udp_port=58899,
12326+
advertised_ha_host="192.168.1.50",
12327+
advertised_ha_port=18899,
12328+
listener_port=18899,
12329+
),
12330+
)
12331+
transaction = admission_transaction_module.CollectorAdmissionTransaction(
12332+
request,
12333+
registry_provider=lambda: None,
12334+
listener_host="0.0.0.0",
12335+
hass_provider=lambda: flow.hass,
12336+
)
12337+
flow._admission_transaction = transaction
12338+
flow._callback_continuation = transaction
12339+
12340+
# Exact production hole: a selected route can reach its failure menu
12341+
# before READY changed. Choosing manual used to leave READY untouched;
12342+
# async_run_identity then raised into aiohttp as an unknown error.
12343+
manual = await flow.async_step_verify_connection_manual_callback()
12344+
self.assertEqual(manual["step_id"], "manual")
12345+
self.assertEqual(transaction.state, "callback_ready")
12346+
12347+
async def _identity(_hass, _request):
12348+
return CallbackIdentityOutcome(result="callback_timeout")
12349+
12350+
with patch.object(
12351+
admission_transaction_module,
12352+
"async_run_callback_identity_transaction",
12353+
side_effect=_identity,
12354+
):
12355+
result = await flow.async_step_manual(
12356+
self._manual_input(
12357+
self.PEER_IP,
12358+
connection_strategy="callback_on_demand",
12359+
)
12360+
)
12361+
12362+
self.assertEqual(result["type"], "menu")
12363+
self.assertEqual(result["step_id"], "manual_confirm")
12364+
self.assertEqual(transaction.state, "callback_ready")
12365+
1229812366
# ---- blocker 1: weak->strong enrichment must retarget a retry onto the
1229912367
# ---- replacement full-PN session, not the stale observation.
1230012368

0 commit comments

Comments
 (0)