@@ -3260,16 +3260,34 @@ private fun buildMessageListItems(
32603260 * synchronized access-ordered LinkedHashMap reordered its linked list node on every
32613261 * `getOrPut` hit, which is real work multiplied by N messages × ~20 snapshots/sec during
32623262 * streaming. A ConcurrentHashMap has no access-order bookkeeping and is lock-free for
3263− * reads, so the common case (a hit) is a single volatile read + hash probe. The bound
3264− * is enforced via [removeEldestEntry] is not available on ConcurrentHashMap, so we cap
3265− * via `compute`-guarded size checks; in practice the cache size is bounded by the number
3266− * of distinct message timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]).
3263+ * reads, so the common case (a hit) is a single volatile read + hash probe.
3264+ * [removeEldestEntry] is not available on ConcurrentHashMap, so the bound is enforced via
3265+ * a size check in [dayKey]: once the cap ([DAY_KEY_CACHE_MAX]) is reached the cache is
3266+ * cleared; in practice the cache size is bounded by the number of distinct message
3267+ * timestamps in a session (≤ [NetworkConfig.maxInMemoryMessages]) and the cap is a
3268+ * safety valve for the process-lifetime cache across many sessions.
32673269 */
32683270private val dayKeyCache = java.util.concurrent.ConcurrentHashMap<Long, String>(256)
32693271
3270−private fun dayKey(epochMillis: Long): String = dayKeyCache.getOrPut(epochMillis) {
3271− java.time.Instant.ofEpochMilli(epochMillis)
3272+/** Upper bound on [dayKeyCache] entries. Message timestamps are immutable once assigned, so
3273+ * the natural bound is the number of distinct timestamps in a session (≤
3274+ * [NetworkConfig.maxInMemoryMessages]); this cap is a safety valve for the process-lifetime
3275+ * cache across many sessions, keeping a runaway leak from accumulating unbounded memory. */
3276+private const val DAY_KEY_CACHE_MAX = 1024
3277+
3278+private fun dayKey(epochMillis: Long): String {
3279+ val cached = dayKeyCache[epochMillis]
3280+ if (cached != null) return cached
3281+ val value = java.time.Instant.ofEpochMilli(epochMillis)
32723282 .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
3283+ // Guard the size under the cache's concurrency: only the first thread to see the cap
3284+ // exceeded evicts (draining is O(N) but rare and idempotent), so concurrent inserts
3285+ // past the cap don't each pay the scan. computeIfAbsent would hold the segment lock
3286+ // during the (cheap) date computation but can't express the size guard, so use put
3287+ // after the fact; a duplicate put of the same key/value is harmless.
3288+ if (dayKeyCache.size >= DAY_KEY_CACHE_MAX) dayKeyCache.clear()
3289+ dayKeyCache[epochMillis] = value
3290+ return value
32733291}
32743292
32753293// The medium-date formatter is an ICU locale lookup; cache one instance instead of