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
Nothing re-checks the node's inbox after the handshake.
CompanionClient::requestSync()(
src/protocol/client.cpp:678) has exactly four callers:handlePush'sPushMsgWaitingcase (client.cpp:263)client.cpp:726)setStorageAvailable(true), when storage comes back while Ready (client.cpp:675)(
client.cpp:372)So once the handshake's drain has ended on
RESP_NO_MORE_MESSAGES, the only thing that canstart another one is a
PUSH_MSG_WAITING. There is no timer:batteryTimer_polls the batteryevery 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,
CompanionClientandHistorywere each cleared under test. A missed push remains the mechanismthat 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_MESSAGEagainst an empty inbox is one command and oneRESP_NO_MORE_MESSAGES, on the same order as the battery poll already running. It must staysubject to the existing
storageAvailable_gate -- the pop is destructive, so a speculative pollwith 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
CompanionClient, started when the link reaches Ready and stoppedby
resetConnection(), alongsidebatteryTimer_.requestSync(), so both existing gates (storageAvailable_,syncPending_)apply unchanged and a poll during a live drain is a no-op.
something in the 30-60 s range rather than seconds.
tests/send_ack_test.cpp's fake-daemon style: a message placed in the fakeinbox with no push emitted must still be collected once the interval elapses, and a poll
must not disturb a drain already running.