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>
4 files changed,
+92
−2
ios/LChatCore/Sources/LChatCore/CoalescedRunner.swift+33 −0ios/LChatCore/Sources/LChatCore/LChatAPI.swift+3 −2ios/LChatCore/Sources/LChatCore/ThreadStore.swift+11 −0ios/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") |
| + } |
| +} |