| 1 | import Foundation |
| 2 | |
| 3 | // 🤖 Serialize fire-and-forget async work with trailing-edge coalescing: |
| 4 | // while one pass runs, any number of further run() calls collapse into ONE |
| 5 | // rerun after it finishes (using the running call's closure — callers all |
| 6 | // request the same work). Built for ThreadStore.refresh, which refires on |
| 7 | // every socket frame and from several UI paths while a full pass takes a |
| 8 | // second-plus on a big space: uncoalesced, overlapping passes raced |
| 9 | // duplicate /api/threads fetches and stacked their folds' publishes into |
| 10 | // the 120–270ms frame hitches the 2026-07-20 device trace pinned on |
| 11 | // NativeChatScreen's re-render. |
| 12 | // |
| 13 | // A caller that arrives mid-pass AWAITS the trailing rerun rather than |
| 14 | // returning early: callers like createChat read the published state right |
| 15 | // after run() and rely on a pass that STARTED after their call (the new |
| 16 | // thread must be in `threads` when run() returns). The early-return first |
| 17 | // cut broke that quietly — repaint callers didn't care, createChat did. |
| 18 | @MainActor |
| 19 | public final class CoalescedRunner { |
| 20 | private var current: Task<Void, Never>? |
| 21 | private var queued = false |
| 22 | |
| 23 | public init() {} |
| 24 | |
| 25 | public func run(_ pass: @escaping @MainActor () async -> Void) async { |
| 26 | if let current { |
| 27 | // Mid-pass: the trailing rerun starts after this call, so |
| 28 | // awaiting the whole task is the freshness guarantee. |
| 29 | queued = true |
| 30 | await current.value |
| 31 | return |
| 32 | } |
| 33 | let task = Task { @MainActor [weak self] in |
| 34 | repeat { |
| 35 | self?.queued = false |
| 36 | await pass() |
| 37 | } while self?.queued == true |
| 38 | // Cleared as the task's LAST act, with no suspension after the |
| 39 | // loop — so no caller can ever observe a finished-but-current |
| 40 | // task and return without its covering pass. |
| 41 | self?.current = nil |
| 42 | } |
| 43 | current = task |
| 44 | await task.value |
| 45 | } |
| 46 | } |
| 47 | |