eleven / messenger Public

native: coalesce ThreadStore.refresh — one pass at a time, trailing rerun (#1208)

The 2026-07-20 device trace pinned the felt frame hitches (120-270ms) on
stacked SwiftUI re-render transactions triggered by overlapping refreshes:
refresh() refires from every socket frame, inboxPing, foregrounding, and
pull-to-refresh, while a full pass takes a second-plus — the ledger caught
clusters of 3-4 concurrent duplicate /api/threads fetches whose folds
published back-to-back. CoalescedRunner serializes the passes with
trailing-edge coalescing (mid-pass callers return immediately; exactly one
rerun follows), so a frame burst now costs one pass and one publish.
api.get span details gain the host so multi-space bursts read apart from
one store stampeding in the next dataset.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 415bb302cc44937df2bfec97727244b576d517da parent c937e2f
4 files changed, +92 −2
  • ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift +33 −0
  • ios/LChatCore/Sources/LChatCore/LChatAPI.swift +3 −2
  • ios/LChatCore/Sources/LChatCore/ThreadStore.swift +11 −0
  • ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift +45 −0
diff --git a/ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift b/ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift
new file mode 100644
index 0000000..a2dff4c
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+// 🤖 Serialize fire-and-forget async work with trailing-edge coalescing:
+// while one pass runs, any number of further run() calls collapse into ONE
+// rerun after it finishes (using the running call's closure — callers all
+// request the same work). Built for ThreadStore.refresh, which refires on
+// every socket frame and from several UI paths while a full pass takes a
+// second-plus on a big space: uncoalesced, overlapping passes raced
+// duplicate /api/threads fetches and stacked their folds' publishes into
+// the 120–270ms frame hitches the 2026-07-20 device trace pinned on
+// NativeChatScreen's re-render. Callers that arrive mid-pass return
+// immediately — the trailing rerun guarantees eventual freshness, which is
+// exactly the repaint semantic every caller wants.
+@MainActor
+public final class CoalescedRunner {
+ private var running = false
+ private var queued = false
+
+ public init() {}
+
+ public func run(_ pass: @MainActor () async -> Void) async {
+ if running {
+ queued = true
+ return
+ }
+ running = true
+ defer { running = false }
+ repeat {
+ queued = false
+ await pass()
+ } while queued
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
index 5e317fd..e9a947d 100644
--- a/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
+++ b/ios/LChatCore/Sources/LChatCore/LChatAPI.swift
@@ -469,9 +469,10 @@ public struct LChatAPI {
) async throws -> T {
var comps = URLComponents(url: baseURL.appending(path: path), resolvingAgainstBaseURL: false)!
comps.queryItems = query ?? (authed ? [URLQueryItem(name: "token", value: token)] : nil)
- // The path alone — never the query (it carries the token).
+ // Host + path — never the query (it carries the token). The host is
+ // what tells a multi-space burst apart from one store stampeding.
let span = Perf.begin("api.get", slowMs: 1000)
- defer { span.end(path) }
+ defer { span.end("\(baseURL.host ?? "?") \(path)") }
let (data, _) = try await session.data(from: comps.url!)
return try JSONDecoder().decode(T.self, from: data)
}
diff --git a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
index 9fcb53d..8eb8d19 100644
--- a/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
+++ b/ios/LChatCore/Sources/LChatCore/ThreadStore.swift
@@ -118,7 +118,18 @@ public final class ThreadStore: ObservableObject {
if instanceName.isEmpty { instanceName = snap.name }
}
+ // Overlap-proofing: refresh() is called from every socket frame (via the
+ // list view's debounce, which a >1s pass still outlives), inboxPing,
+ // foregrounding, and pull-to-refresh — uncoalesced, those overlapped into
+ // duplicate concurrent /api/threads fetches whose stacked publishes were
+ // the 2026-07-20 trace's frame hitches (see CoalescedRunner).
+ private let refreshGate = CoalescedRunner()
+
public func refresh() async {
+ await refreshGate.run { await self.refreshPass() }
+ }
+
+ private func refreshPass() async {
guard let api else { loadError = "This instance's address is invalid."; return }
loading = true
// Refires on every socket frame, so the budget is generous — only a
diff --git a/ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift b/ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift
new file mode 100644
index 0000000..afa20ed
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/CoalescedRunnerTests.swift
@@ -0,0 +1,45 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 The refresh gate's contract: overlapping calls collapse into exactly
+// one trailing rerun (not zero — a frame that arrives mid-pass must still
+// produce a fresh pass; not N — the stampede was the bug), and sequential
+// calls each run.
+@MainActor
+final class CoalescedRunnerTests: XCTestCase {
+ func testSequentialCallsEachRun() async {
+ let runner = CoalescedRunner()
+ var passes = 0
+ await runner.run { passes += 1 }
+ await runner.run { passes += 1 }
+ XCTAssertEqual(passes, 2)
+ }
+
+ func testOverlappingCallsCollapseToOneTrailingRerun() async {
+ let runner = CoalescedRunner()
+ var passes = 0
+ var blocked = false
+ var release: CheckedContinuation<Void, Never>?
+ // First pass blocks inside the runner so the overlapping calls below
+ // genuinely arrive mid-pass; the trailing rerun (same closure) must
+ // not block again.
+ let first = Task { @MainActor in
+ await runner.run {
+ passes += 1
+ if !blocked {
+ blocked = true
+ await withCheckedContinuation { release = $0 }
+ }
+ }
+ }
+ while release == nil { await Task.yield() }
+ // Three calls while the pass is running — all coalesce into ONE rerun.
+ for _ in 0..<3 {
+ await runner.run { passes += 1 }
+ }
+ XCTAssertEqual(passes, 1, "mid-pass calls must return immediately, not run")
+ release?.resume()
+ _ = await first.value
+ XCTAssertEqual(passes, 2, "exactly one trailing rerun after the pass ends")
+ }
+}