Summary
When a PRESENCE protocol message arrives whose inner presence messages have no id,
Message.update_inner_message_fields fabricates one from the protocol message's own
id. If the protocol message has no id either, the fabricated value is the literal
string "None:0". That value then flows into the RTP2b newness comparison, where it
either raises a ValueError that ends up silently dropping the message, or steers the
comparison down the wrong branch.
Found while tracing intermittent presence test failures. It is not the cause of those
failures — those were a test-side race, fixed separately — but it is on the same path.
Reproduction
from ably.types.message import Message
from ably.types.presence import PresenceMessage
from ably.realtime.presencemap import _is_newer
def build(proto):
Message.update_inner_message_fields(proto)
return [PresenceMessage.from_encoded(p) for p in proto['presence']]
# A: protocol message carries neither id nor connectionId
a = build({'action': 14, 'presence': [{'action': 2, 'clientId': 'c1'}]})[0]
a2 = build({'action': 14, 'presence': [{'action': 2, 'clientId': 'c1'}]})[0]
print(a.id, a.connection_id, a.is_synthesized())
# None:0 None False
_is_newer(a2, a)
# ValueError: Cannot parse id: invalid msgSerial or index in 'None:0'
# B: protocol message carries connectionId but no id
b = build({'action': 14, 'connectionId': 'abcdef',
'presence': [{'action': 2, 'clientId': 'c1'}]})[0]
print(b.id, b.connection_id, b.is_synthesized())
# None:0 abcdef True
Two problems
1. The fabricated id is not guarded. ably/types/message.py:364-366:
if msg.get("id") is None or msg.get("id") == '':
msg['id'] = f"{proto_msg.get('id')}:{msg_index}"
proto_msg.get('id') is interpolated unconditionally, so a missing id becomes the
four characters None. Nothing downstream can distinguish that from a real id.
2. The ValueError is swallowed, taking the message with it. _is_newer
(ably/realtime/presencemap.py:53) calls parse_id(), which raises on "None:0".
PresenceMap.put() does not catch it, and neither does
RealtimePresence.set_presence (ably/realtime/presence.py:513-571), so it
propagates out of the WebSocketTransport.on_protocol_message task. The done-callback at
ably/transport/websockettransport.py:230-233 logs it and returns:
except Exception as e:
exception = e
if exception is not None:
log.exception(f"WebSocketTransport.on_protocol_message_handled(): uncaught exception: {exception}")
The presence message — and the remainder of that protocol message's processing — is
dropped, with a log line as the only trace. No update fires on the channel, no error
surfaces to the application.
Case A is reachable whenever a presence message and its protocol message both lack an
id. Even where that combination is rare on the wire, an unparseable id should not be
able to take down the handling of a protocol message this quietly.
is_synthesized() and the wrong branch
ably/types/presence.py:95-96:
if not self.id or not self.connection_id:
return False
In case A this returns False, which is what sends the comparison down RTP2b2 and
into parse_id(). In case B it returns True only because the fabricated "None:0"
happens not to start with the real connectionId — the classification is being driven
by data the SDK made up rather than by anything the server sent. Both branches are
reached for the wrong reason.
Suggested direction
- Leave the id unset when the protocol message has none, rather than fabricating
"None:...".
- Have
_is_newer (or PresenceMap.put/remove) treat an unparseable id as a
comparison it cannot make, and fall back to the RTP2b1 timestamp path instead of
raising.
- Decide explicitly what
is_synthesized() should return for a message with no id,
and make it depend on what arrived rather than on a fabricated value.
Reproduced on main at 63b7812.
🤖 Generated with Claude Code
┆Issue is synchronized with this Jira Task by Unito
Summary
When a
PRESENCEprotocol message arrives whose inner presence messages have noid,Message.update_inner_message_fieldsfabricates one from the protocol message's ownid. If the protocol message has noideither, the fabricated value is the literalstring
"None:0". That value then flows into the RTP2b newness comparison, where iteither raises a
ValueErrorthat ends up silently dropping the message, or steers thecomparison down the wrong branch.
Found while tracing intermittent presence test failures. It is not the cause of those
failures — those were a test-side race, fixed separately — but it is on the same path.
Reproduction
Two problems
1. The fabricated id is not guarded.
ably/types/message.py:364-366:proto_msg.get('id')is interpolated unconditionally, so a missing id becomes thefour characters
None. Nothing downstream can distinguish that from a real id.2. The
ValueErroris swallowed, taking the message with it._is_newer(
ably/realtime/presencemap.py:53) callsparse_id(), which raises on"None:0".PresenceMap.put()does not catch it, and neither doesRealtimePresence.set_presence(ably/realtime/presence.py:513-571), so itpropagates out of the
WebSocketTransport.on_protocol_messagetask. The done-callback atably/transport/websockettransport.py:230-233logs it and returns:The presence message — and the remainder of that protocol message's processing — is
dropped, with a log line as the only trace. No
updatefires on the channel, no errorsurfaces to the application.
Case A is reachable whenever a presence message and its protocol message both lack an
id. Even where that combination is rare on the wire, an unparseable id should not beable to take down the handling of a protocol message this quietly.
is_synthesized()and the wrong branchably/types/presence.py:95-96:In case A this returns
False, which is what sends the comparison down RTP2b2 andinto
parse_id(). In case B it returnsTrueonly because the fabricated"None:0"happens not to start with the real
connectionId— the classification is being drivenby data the SDK made up rather than by anything the server sent. Both branches are
reached for the wrong reason.
Suggested direction
"None:..."._is_newer(orPresenceMap.put/remove) treat an unparseable id as acomparison it cannot make, and fall back to the RTP2b1 timestamp path instead of
raising.
is_synthesized()should return for a message with no id,and make it depend on what arrived rather than on a fabricated value.
Reproduced on
mainat 63b7812.🤖 Generated with Claude Code
┆Issue is synchronized with this Jira Task by Unito