Skip to content

A missed PUSH_MSG_WAITING strands the inbox until the link is rebuilt #37

Description

@singular0

Nothing re-checks the node's inbox after the handshake. CompanionClient::requestSync()
(src/protocol/client.cpp:678) has exactly four callers:

  • handlePush's PushMsgWaiting case (client.cpp:263)
  • the drain's own tail, which asks for the next message after collecting one (client.cpp:726)
  • setStorageAvailable(true), when storage comes back while Ready (client.cpp:675)
  • the end of channel enumeration, which drains whatever arrived while no app was attached
    (client.cpp:372)

So once the handshake's drain has ended on RESP_NO_MORE_MESSAGES, the only thing that can
start another one is a PUSH_MSG_WAITING. There is no timer: batteryTimer_ polls the battery
every 60 s (client.cpp:20,92,369) and nothing polls the inbox.

Why it matters: a push is a single unacknowledged notification on a link that does not retransmit
it. If one is missed -- a dropped BLE notification, firmware that does not emit one for a
particular message, or the app busy at the wrong moment -- that message stays in the node's inbox,
and so does every message after it, because the next message's push is what would have collected
the backlog and it only fires once too. The failure is silent and total: sending keeps working
perfectly, the link stays up and shows as connected, and the only recovery is rebuilding the link.
For a messaging client that is the whole function of the app failing with no indication.

This is exactly the shape of a report that could not be reproduced on 2026-08-19: a session that
collected messages normally and then stopped for good while its own sends kept succeeding over the
same link. Instrumented runs afterwards -- 8/8 probes plus organic mesh traffic collected over 16
minutes, against a LilyGo T-Echo on v1.17.0 over BLE -- did not reproduce it, and the radio,
CompanionClient and History were each cleared under test. A missed push remains the mechanism
that fits, and it is worth closing whether or not it caused that particular incident, because
nothing in the current design can recover from one.

A periodic re-sync is cheap: SYNC_NEXT_MESSAGE against an empty inbox is one command and one
RESP_NO_MORE_MESSAGES, on the same order as the battery poll already running. It must stay
subject to the existing storageAvailable_ gate -- the pop is destructive, so a speculative poll
with nowhere to write the answer is exactly how messages get destroyed -- and to syncPending_,
so it never doubles up with a drain already under way.

Tasks

  • Add an inbox poll timer to CompanionClient, started when the link reaches Ready and stopped
    by resetConnection(), alongside batteryTimer_.
  • Have it call requestSync(), so both existing gates (storageAvailable_, syncPending_)
    apply unchanged and a poll during a live drain is a no-op.
  • Pick the interval against the uConsole's power budget -- a poll costs one round trip, so
    something in the 30-60 s range rather than seconds.
  • Cover it in tests/send_ack_test.cpp's fake-daemon style: a message placed in the fake
    inbox with no push emitted must still be collected once the interval elapses, and a poll
    must not disturb a drain already running.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: protocolCompanion protocol, codec, transportsbugSomething isn't workingpriority: P1Daily usability

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions