@@ -610,7 +610,23 @@ internal class MessageStore {
610 // behavior for a genuinely new message.
610 // behavior for a genuinely new message.
611 else -> {
611 else -> {
612 val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
612 val mapKey = resolveMapKey(rawMessageId) ?: rawMessageId
613+ // An evicted UnknownMessage with an empty id was recorded in
614+ // evictedMessageIds under its synthetic "unknown-c<created>" /
615+ // "unknown-h<hash>" key (see evictOldMessages). resolveMapKey("")
616+ // returns null (the holder is gone), so mapKey falls back to "" —
617+ // which isn't in the set. Drop the late part instead of re-inserting
618+ // a partial bubble. The set could contain synthetic keys for any of
619+ // possibly-several evicted unknown messages; without the message's
620+ // creation time on the Part we can't disambiguate, so treat ANY
621+ // evicted synthetic key as a match. This is over-broad (a genuinely-
622+ // new unknown message's first part would be dropped if an unrelated
623+ // one was just evicted), but UnknownMessages from unrecognized server
624+ // roles are rare, and a following MessageUpdated (which always
625+ // carries full info) re-creates the holder correctly either way.
626+ if (rawMessageId.isEmpty() && mapKey.isEmpty() &&
627+ evictedMessageIds.any { it.startsWith("unknown-") }) {
613− if (partSession != null) {
629+ } else if (partSession != null) {
614 if (partSession == sessionId) upsertPart(mapKey, part) else false
630 if (partSession == sessionId) upsertPart(mapKey, part) else false
616 // No session id anywhere on the event: a null session is NOT a wildcard
632 // No session id anywhere on the event: a null session is NOT a wildcard
@@ -656,7 +672,11 @@ internal class MessageStore {
656 // A MessageUpdated re-introduces the message into the store (it was either evicted
672 // A MessageUpdated re-introduces the message into the store (it was either evicted
657 // by evictOldMessages or genuinely new). Drop any eviction record so a following
673 // by evictOldMessages or genuinely new). Drop any eviction record so a following
658 // MessagePartUpdated updates this holder instead of being dropped as "late".
674 // MessagePartUpdated updates this holder instead of being dropped as "late".
675+ // For an empty-id UnknownMessage the record was stored under the synthetic key (see
676+ // evictOldMessages), so remove that too — otherwise the over-broad empty-id guard in
677+ // reduce() would keep dropping parts for this message even after it's back in memory.
659 info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
678 info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
679+ if (info.id.isEmpty()) evictedMessageIds.remove(key)
660 if (existing == null) {
680 if (existing == null) {
661 messages[key] = Holder(info, LinkedHashMap())
681 messages[key] = Holder(info, LinkedHashMap())
@@ -705,6 +725,9 @@ internal class MessageStore {
705 // The message is gone from the store; drop any eviction record for it so the set
725 // The message is gone from the store; drop any eviction record for it so the set
706 // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
726 // doesn't retain ids of long-deleted messages. (A re-seed would also clear this,
707 // but a quiet session that never reconnects could otherwise accumulate entries.)
727 // but a quiet session that never reconnects could otherwise accumulate entries.)
728+ // For an empty-id UnknownMessage the record was stored under the synthetic key (see
729+ // evictOldMessages), so remove that too — keep the set bounded and prevent the
730+ // over-broad empty-id guard in reduce() from dropping parts for a reused id.
708 removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
731 removed.info.id.takeIf { it.isNotEmpty() }?.let { evictedMessageIds.remove(it) }
709 // Derive the same key handleMessageUpdated would have stored this holder under. For an
732 // Derive the same key handleMessageUpdated would have stored this holder under. For an
710 // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
733 // UnknownMessage with an empty id, that's the synthetic "unknown-c<created>" /
@@ -718,6 +741,7 @@ internal class MessageStore {
744+ if (removed.info.id.isEmpty()) evictedMessageIds.remove(pivotKey)
721 messageInfoUpdatedSincePivot.remove(pivotKey)
745 messageInfoUpdatedSincePivot.remove(pivotKey)
722 removed.parts.keys.forEach { streamedSincePivot.remove(it) }
746 removed.parts.keys.forEach { streamedSincePivot.remove(it) }
723 return true
747 return true
@@ -786,10 +810,19 @@ internal class MessageStore {
786 // Record the evicted message's id so a late MessagePartUpdated for it is dropped
810 // Record the evicted message's id so a late MessagePartUpdated for it is dropped
787 // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
811 // instead of re-inserting a single-part bubble (see upsertPart). Also clear any
788 // prior eviction record for this id from an older cycle (the message re-entered
812 // prior eviction record for this id from an older cycle (the message re-entered
789− // via seed/MessageUpdated and has now been evicted again).
813+ // via seed/MessageUpdated and has now been evicted again). Record the MAP KEY
814+ // (oldestKey), not just info.id: an UnknownMessage with an empty id is stored
815+ // under a synthetic "unknown-c<created>" / "unknown-h<hash>" key, so recording
816+ // only info.id (which is "") would never match — a late MessagePartUpdated for
817+ // it would resolveMapKey("") → null → fall back to upsertPart("") and re-insert
818+ // a partial bubble. Recording the synthetic key lets the empty-id guard below
819+ // (in reduce) catch it.
790 removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
820 removed?.info?.id?.takeIf { it.isNotEmpty() }?.let { id ->
791 evictedMessageIds.add(id)
821 evictedMessageIds.add(id)
823+ if (removed != null && removed.info.id.isEmpty()) {
824+ evictedMessageIds.add(oldestKey)
793 // Prune the evicted message's pivot bookkeeping so these sets don't grow
826 // Prune the evicted message's pivot bookkeeping so these sets don't grow
794 // unbounded over a long-running session that never reconnects (they're only
827 // unbounded over a long-running session that never reconnects (they're only
795 // otherwise cleared on reseed/seed).
828 // otherwise cleared on reseed/seed).