From 90b418c4c60c7c30705d129dcac58c6888c13a4a Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 27 Oct 2026 09:40:44 -0700 Subject: [PATCH] cmux-next: FolderChanges arms its watches before events() returns BackdropCatalogStoreTests.theCatalogIsBuiltOnceAndRebuiltOnlyWhenAFolderChanges hung for its 60 s limit under the parallel run: the watch was armed by a utility-priority detached task, which under load could start after the test added mine.png, so no event ever came. events() now arms before it returns, so a change after the call is always seen; FolderChangesTests drops the 100 ms head start it gave the loop. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?id=aid-cc8bff1c3ddb4ec353c5 --- .../Sources/CmuxNextWakeups/FolderChanges.swift | 13 +++++++++---- .../CmuxNextControlTests/FolderChangesTests.swift | 3 +-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/FolderChanges.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/FolderChanges.swift index b951baa4748e..c3934a6e2257 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/FolderChanges.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextWakeups/FolderChanges.swift @@ -11,9 +11,11 @@ public import Foundation /// move down to it); otherwise it is dropped, and counted in the ledger under /// `FolderChanges/stand-in`. Each element is counted under `FolderChanges/change`. /// -/// Each element comes after the watch is armed again when it had to move, so a change between -/// two elements is either seen by the watch or happened before the element, and the consumer's -/// work for the element (re-reading the folders) covers it. +/// The watches are armed before ``events()`` returns, so a change made after that call is seen +/// even when the watch loop has not run yet (a store that calls ``events()`` and then reads the +/// folders misses nothing in between). Each element comes after the watch is armed again when it +/// had to move, so a change between two elements is either seen by the watch or happened before +/// the element, and the consumer's work for the element (re-reading the folders) covers it. /// /// ```swift /// for await _ in FolderChanges(folders: [wallpapers, userImages]).events() { rebuild() } @@ -37,9 +39,12 @@ public struct FolderChanges: Sendable { let targets = folders.map(\.path) let ledger = ledger let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + // Armed here, not in the loop: a utility task can start long after this returns under load, + // and a change made before it armed was never seen (BackdropCatalogStoreTests hung on it). + let first = Self.arm(targets) // task-owner: the stream's watch loop; cancelled when the consumer stops iterating let loop = Task.detached(priority: .utility) { - var armed = Self.arm(targets) + var armed = first // wakeup-allow: each pass waits for one file-system event in a watched folder, never polls while !Task.isCancelled { var iterator = armed.events.makeAsyncIterator() diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextControlTests/FolderChangesTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextControlTests/FolderChangesTests.swift index 7d57db94310c..4ea1060052ac 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextControlTests/FolderChangesTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextControlTests/FolderChangesTests.swift @@ -24,9 +24,8 @@ import Testing defer { try? FileManager.default.removeItem(at: parent) } let missing = parent.appendingPathComponent("Backgrounds", isDirectory: true) let ledger = WakeupLedger() + // events() arms the watch before it returns, so the first write needs no head start. var changes = FolderChanges(folders: [missing], ledger: ledger).events().makeAsyncIterator() - // Let the watch arm before the first write (the loop task opens it when it starts). - try await Task.sleep(for: .milliseconds(100)) // The app saves its own file next to the missing folder. try Data("{}".utf8).write(to: parent.appendingPathComponent("unsent-prompts.json"), options: .atomic)