Summary
RelaySession.publish() sends with _socket?.send(...). When _socket is
null (or the underlying WebSocket has already died), the send is silently
skipped but the 8-second ack timer still runs, so the user gets
TimeoutEvent <event id> not acknowledged within 0:00:08.000000
and the message is lost — no reconnect, no queue, no retry. The composed
text is gone from the outbox with only a transient toast to show for it.
I verified the loss server-side: the event id from the toast is not present
on the relay at all (queried the channel history directly). It was never
transmitted.
https://github.com/block/buzz/blob/main/mobile/lib/shared/relay/relay_session.dart#L299-L323
Future<NostrEvent> publish(
NostrEvent event, {
Duration timeout = const Duration(seconds: 8),
}) {
final completer = Completer<NostrEvent>();
final timer = Timer(timeout, () { ...completeError(TimeoutException(...)) });
_pendingEvents[event.id] = _PendingEvent(completer: completer, timeout: timer);
_socket?.send(['EVENT', event.toJson()]); // <-- no-ops when disconnected
return completer.future;
}
Why it triggers so easily on iOS
iOS suspends the app's socket as soon as it backgrounds, so the socket is
routinely dead when the user returns to the app. The relay independently
reaps the connection: heartbeat_loop pings every 30s and closes after 3
missed pongs (crates/buzz-relay/src/connection.rs), i.e. a ~90s ceiling.
Observed on my relay: the phone's connection is re-established every 40–90s
(WebSocket connection established → NIP-42 auth successful → WebSocket connection closed, repeatedly). Any send issued in the window between the
socket dying and the client noticing hits the null-socket path.
Typing a message right after opening the app is therefore the normal case,
not an edge case.
Steps to reproduce
- Open the mobile app, let it connect to a channel.
- Background the app (or just leave it until the relay reaps the socket —
under ~90s).
- Foreground it, immediately type a message and send.
- After 8 seconds:
TimeoutEvent ... not acknowledged within 0:00:08.
- Query the relay for that event id — it does not exist. The message was
never sent, and the composed text is not recoverable from the UI.
Expected
Any of, in rough order of preference:
publish() awaits a live socket (reconnect + NIP-42 re-auth) before
sending, rather than no-oping on null.
- Failing that, queue the event and flush on reconnect — mobile networks
drop constantly; an outbox is the normal shape for this.
- At minimum: fail loudly and recoverably — keep the text in the composer,
surface a retry affordance, and distinguish "never sent" from "sent but
unacknowledged". Today those two are indistinguishable to the user, which
matters because they need opposite responses (resend vs. don't).
Note that a bare timeout is also misleading: an 8s deadline on a message that
was never transmitted describes the symptom, not the failure.
Environment
- Buzz iOS app, self-hosted relay (docker compose bundle) behind a reverse
proxy, TLS, single community.
- Relay and proxy verified healthy throughout: NIP-11 responds in ~24ms,
no errors, no rate limiting, publishes from a CLI client accepted in ~40ms.
- Two long-lived
buzz-acp WebSocket clients on the same URL through the
same proxy had zero reconnects in 3 days, so the connection churn is
specific to the mobile client/platform, not the server path.
- Phone and server on the same LAN, direct connection (no relay hop).
Related
Summary
RelaySession.publish()sends with_socket?.send(...). When_socketisnull (or the underlying WebSocket has already died), the send is silently
skipped but the 8-second ack timer still runs, so the user gets
and the message is lost — no reconnect, no queue, no retry. The composed
text is gone from the outbox with only a transient toast to show for it.
I verified the loss server-side: the event id from the toast is not present
on the relay at all (queried the channel history directly). It was never
transmitted.
https://github.com/block/buzz/blob/main/mobile/lib/shared/relay/relay_session.dart#L299-L323
Why it triggers so easily on iOS
iOS suspends the app's socket as soon as it backgrounds, so the socket is
routinely dead when the user returns to the app. The relay independently
reaps the connection:
heartbeat_looppings every 30s and closes after 3missed pongs (
crates/buzz-relay/src/connection.rs), i.e. a ~90s ceiling.Observed on my relay: the phone's connection is re-established every 40–90s
(
WebSocket connection established→NIP-42 auth successful→WebSocket connection closed, repeatedly). Any send issued in the window between thesocket dying and the client noticing hits the null-socket path.
Typing a message right after opening the app is therefore the normal case,
not an edge case.
Steps to reproduce
under ~90s).
TimeoutEvent ... not acknowledged within 0:00:08.never sent, and the composed text is not recoverable from the UI.
Expected
Any of, in rough order of preference:
publish()awaits a live socket (reconnect + NIP-42 re-auth) beforesending, rather than no-oping on null.
drop constantly; an outbox is the normal shape for this.
surface a retry affordance, and distinguish "never sent" from "sent but
unacknowledged". Today those two are indistinguishable to the user, which
matters because they need opposite responses (resend vs. don't).
Note that a bare timeout is also misleading: an 8s deadline on a message that
was never transmitted describes the symptom, not the failure.
Environment
proxy, TLS, single community.
no errors, no rate limiting, publishes from a CLI client accepted in ~40ms.
buzz-acpWebSocket clients on the same URL through thesame proxy had zero reconnects in 3 days, so the connection churn is
specific to the mobile client/platform, not the server path.
Related
same class of problem on the other client — a dead socket that the send
path doesn't account for.