From 55d0f86392e8cc54ba22cd7ce4b7b0f198b48a80 Mon Sep 17 00:00:00 2001 From: tigerenwork Date: Sun, 19 Jul 2026 15:10:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=94=A8=20FetchGate=20actor=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91=E8=A7=A6=E5=8F=91=E4=B8=A2?= =?UTF-8?q?=E5=A4=B1=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/Services/PollerService.swift | 52 +++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/Sources/Services/PollerService.swift b/Sources/Services/PollerService.swift index 59271c9..e9a026f 100644 --- a/Sources/Services/PollerService.swift +++ b/Sources/Services/PollerService.swift @@ -11,8 +11,8 @@ final class PollerService { private let configService = AppStateService.shared private var timer: Timer? - private var isFetching = false private var lastFetchTime: Date? + private let fetchGate = FetchGate() private(set) var isRunning = false @@ -85,11 +85,53 @@ final class PollerService { // MARK: - Fetch - private func performFetch() async { - guard !isFetching else { return } - isFetching = true - defer { isFetching = false } + /// Serializes fetches and coalesces concurrent triggers. + /// + /// Previously a trigger arriving mid-fetch was silently dropped + /// (`guard !isFetching else { return }`). Rapid successive changes — + /// e.g. an A→B→A reassignment producing two SSE wake-ups — then + /// collapsed into a single fetch, and if that fetch sampled the final + /// state the transition was never observed and no notification fired. + /// Now a trigger arriving mid-fetch queues exactly one follow-up round, + /// so the final state is always observed. The actor also makes the + /// check-then-set race-safe (triggers arrive from the timer, the SSE + /// handler and the UI on different tasks). + private actor FetchGate { + private var fetching = false + private var pending = false + /// Returns true if the caller may start fetching; otherwise queues + /// a follow-up round and returns false. + func tryBegin() -> Bool { + if fetching { + pending = true + return false + } + fetching = true + return true + } + + /// Ends one round. Returns true when another round was requested + /// while fetching; the gate stays held between rounds so no + /// parallel fetch can slip in. + func endRound() -> Bool { + if pending { + pending = false + return true + } + fetching = false + return false + } + } + + private func performFetch() async { + guard await fetchGate.tryBegin() else { return } + repeat { + await fetchOnce() + } while await fetchGate.endRound() + } + + private func fetchOnce() async { lastFetchTime = Date() await MainActor.run {