From 6a74f07da7b50b53366ce1778e2938801be1acde Mon Sep 17 00:00:00 2001 From: Matthew Wolter Date: Sat, 11 Apr 2026 20:03:39 -0700 Subject: [PATCH] =?UTF-8?q?G2:=20F22=20=E2=80=94=20add=20Event.is=5Ferror(?= =?UTF-8?q?)=20helper=20and=20document=20.type-check=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: wait_for_event matches a single EventType; when callers pass [X, ERROR] to send() or wait_for_events, the return value may be an error response whose payload is {"reason": "..."} — not the command- specific keys the caller expects. Without a documented contract and a convenience helper, every call site independently forgets to check .type before accessing payload keys, leading to KeyError (F21/M01, M04) or silent fallthrough. The is_error() helper and docstrings on send()/wait_for_events() establish the contract that subsequent commits in this branch rely on. Refs: Forensics report finding F22 --- src/meshcore/commands/base.py | 21 +++++++++++++++++---- src/meshcore/events.py | 11 +++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/meshcore/commands/base.py b/src/meshcore/commands/base.py index 9e0f00e..a927882 100644 --- a/src/meshcore/commands/base.py +++ b/src/meshcore/commands/base.py @@ -90,6 +90,14 @@ class CommandHandlerBase: expected_events: Optional[Union[EventType, List[EventType]]] = None, timeout: Optional[float] = None, ) -> Event: + """Wait for the first of *expected_events* to arrive. + + Returns the first matched ``Event``. When ``EventType.ERROR`` is + among the expected types, the caller **must** check + ``result.is_error()`` before accessing command-specific payload + keys — an ERROR payload is ``{"reason": "..."}`` and will + ``KeyError`` on any other key. + """ try: # Convert single event to list if needed if not isinstance(expected_events, list): @@ -129,9 +137,6 @@ class CommandHandlerBase: logger.debug(f"Command error: {e}") return Event(EventType.ERROR, {"error": str(e)}) - return Event(EventType.ERROR, {}) - - async def send( self, data: bytes, @@ -151,7 +156,14 @@ class CommandHandlerBase: timeout: Timeout in seconds, or None to use default_timeout Returns: - Event: The full event object that was received in response to the command + Event: The full event object that was received in response to + the command. + + Important: + When ``EventType.ERROR`` is included in *expected_events*, the + returned event may be an error response. Callers **must** + check ``result.is_error()`` before accessing command-specific + payload keys to avoid ``KeyError``. """ if not self.dispatcher: raise RuntimeError("Dispatcher not set, cannot send commands") @@ -266,6 +278,7 @@ class CommandHandlerBase: contact = self._get_contact_by_prefix(dst_bytes.hex()) # need a contact for return path if contact is None: logger.error("No contact found") + return Event(EventType.ERROR, {"reason": "contact_not_found"}) zero_hop = False if contact["out_path_len"] == -1: diff --git a/src/meshcore/events.py b/src/meshcore/events.py index f8a7521..258dc7f 100644 --- a/src/meshcore/events.py +++ b/src/meshcore/events.py @@ -104,6 +104,17 @@ class Event: if kwargs: self.attributes.update(kwargs) + def is_error(self) -> bool: + """Return True if this event represents an error response. + + Callers that include ``EventType.ERROR`` in their expected-events + list **must** check ``result.is_error()`` (or ``result.type == + EventType.ERROR``) before accessing keyed payload fields, because + an ERROR payload contains ``{"reason": "..."}`` — not the + command-specific keys the caller expects on the happy path. + """ + return self.type == EventType.ERROR + def clone(self): """ Create a copy of the event.