Debounced FSEvents watcher over the board tree (Kanban/LiveStore/): trailing 200ms debounce coalesces bursts; nestable brackets suppress delivery and close with exactly one app-mediated reload; origins merge reconciling > appMediated > foreign; missed-events flags degrade to a reconciling reload; WatchRoot vanish tears the stream down (streams die and are recreated — reattach() covers rename re-resolution); board-root .git churn is filtered against the symlink-resolved root. The FSEvents release callback must be top-level and non-isolated — a MainActor closure traps in dispatch_assert_queue during stream deallocation. 15 watcher tests (4 consecutive green runs); full suite 265 tests green. Five design findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
534 lines
20 KiB
Swift
534 lines
20 KiB
Swift
import Foundation
|
|
import Testing
|
|
@testable import Kanban
|
|
|
|
/// `FolderWatcher` is the one component here whose correctness is a *timing* claim — "one reload
|
|
/// per burst", "nothing mid-bracket", "always one after the bracket" — so these tests are written
|
|
/// against a real FSEvents stream on a real temp directory rather than against a fake. A fake
|
|
/// would pin the debounce logic and prove nothing about the two things most likely to be wrong:
|
|
/// the flags the stream actually sends and the paths it actually reports.
|
|
///
|
|
/// That makes deadlines the main flakiness risk, and they are handled by asymmetry: **waiting for
|
|
/// something is generous** (poll up to seconds — a slow machine must not fail a test), while
|
|
/// **waiting for nothing is a fixed quiet period** well past the debounce. Assertions are never
|
|
/// weakened to buy stability; the deadlines are.
|
|
|
|
// MARK: - Support
|
|
|
|
/// A temp directory per test, and the only place a test touches the filesystem.
|
|
@MainActor
|
|
private struct WatchFixture {
|
|
let root: URL
|
|
|
|
init(create: Bool = true) throws {
|
|
root = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("FolderWatcherTests-\(UUID().uuidString)", isDirectory: true)
|
|
if create {
|
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
|
}
|
|
}
|
|
|
|
func tearDown() {
|
|
try? FileManager.default.removeItem(at: root)
|
|
}
|
|
|
|
/// Writes a file directly — this is a *foreign* write by construction: no Writer, no bracket,
|
|
/// exactly what an editor or an agent does.
|
|
func write(_ relativePath: String, _ text: String = "x") {
|
|
let fileURL = root.appendingPathComponent(relativePath)
|
|
try? FileManager.default.createDirectory(
|
|
at: fileURL.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true
|
|
)
|
|
try? Data(text.utf8).write(to: fileURL)
|
|
}
|
|
|
|
func makeDirectory(_ relativePath: String) {
|
|
try? FileManager.default.createDirectory(
|
|
at: root.appendingPathComponent(relativePath, isDirectory: true),
|
|
withIntermediateDirectories: true
|
|
)
|
|
}
|
|
|
|
func remove() {
|
|
try? FileManager.default.removeItem(at: root)
|
|
}
|
|
}
|
|
|
|
/// Accumulates what the watcher delivered. Main-actor, like the handler.
|
|
@MainActor
|
|
private final class EventLog {
|
|
private(set) var events: [WatcherEvent] = []
|
|
|
|
var count: Int { events.count }
|
|
|
|
func record(_ event: WatcherEvent) {
|
|
events.append(event)
|
|
}
|
|
|
|
func reset() {
|
|
events.removeAll()
|
|
}
|
|
|
|
var origins: [WatchOrigin] {
|
|
events.compactMap { if case .treeChanged(let origin) = $0 { origin } else { nil } }
|
|
}
|
|
}
|
|
|
|
/// Polls `condition` until it holds or `deadline` elapses. Generous by default: FSEvents delivery
|
|
/// is not a bounded-latency promise, and a busy CI machine can take seconds.
|
|
@MainActor
|
|
private func waitUntil(_ deadline: Duration = .seconds(5), _ condition: () -> Bool) async {
|
|
let start = ContinuousClock.now
|
|
while ContinuousClock.now - start < deadline {
|
|
if condition() { return }
|
|
try? await Task.sleep(for: .milliseconds(25))
|
|
}
|
|
}
|
|
|
|
/// A fixed quiet period — the shape every "and then *nothing* else happened" assertion takes.
|
|
/// Comfortably past `testDebounce` plus `testLatency` plus FSEvents' own delivery slack.
|
|
@MainActor
|
|
private func quiet(_ duration: Duration = .milliseconds(500)) async {
|
|
try? await Task.sleep(for: duration)
|
|
}
|
|
|
|
/// Short enough to keep the suite quick, long enough to coalesce a burst of writes on a slow
|
|
/// machine. Deliberately larger than `testLatency` by an order of magnitude, so the debounce —
|
|
/// not FSEvents — is what does the coalescing under test.
|
|
private let testDebounce = Duration.milliseconds(100)
|
|
private let testLatency = 0.02
|
|
|
|
/// Gives the freshly created stream a beat to register with `fseventsd` before a test writes.
|
|
/// Without it, the first write of a test can land in the window between `FSEventStreamStart` and
|
|
/// the stream actually being live — a real (if rare) source of "the first event never arrived".
|
|
@MainActor
|
|
private func settle() async {
|
|
try? await Task.sleep(for: .milliseconds(300))
|
|
}
|
|
|
|
/// Waits for the stream to go quiet, then throws away whatever arrived before that.
|
|
///
|
|
/// **Observed, and the reason this helper exists**: `kFSEventStreamEventIdSinceNow` is not the
|
|
/// clean line it reads as. Each test creates its temp directory milliseconds before creating the
|
|
/// stream, and `fseventsd` assigns that `mkdir` an event id *after* the stream is already live —
|
|
/// so a freshly started watcher reliably sees one `.foreign` delivery it did nothing to earn.
|
|
/// This is an artefact of watching a directory that was created a moment ago, not a bug: in the
|
|
/// app a stream is created over a board folder that has existed for a while, and a spurious
|
|
/// reload on open would cost one value-equal snapshot swap anyway.
|
|
///
|
|
/// Draining is a *wait for quiet*, not a fixed sleep, so a straggler cannot land just after the
|
|
/// reset and pollute the test that follows.
|
|
@MainActor
|
|
private func drainStartupChurn(
|
|
_ log: EventLog,
|
|
quietFor: Duration = .milliseconds(400),
|
|
deadline: Duration = .seconds(5)
|
|
) async {
|
|
let start = ContinuousClock.now
|
|
var lastCount = -1
|
|
var lastChange = ContinuousClock.now
|
|
while ContinuousClock.now - start < deadline {
|
|
if log.count != lastCount {
|
|
lastCount = log.count
|
|
lastChange = ContinuousClock.now
|
|
} else if ContinuousClock.now - lastChange >= quietFor {
|
|
break
|
|
}
|
|
try? await Task.sleep(for: .milliseconds(25))
|
|
}
|
|
log.reset()
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
@Suite("FolderWatcher")
|
|
struct FolderWatcherTests {
|
|
|
|
// MARK: Ordinary foreign change
|
|
|
|
@Test("A single external write delivers exactly one foreign tree change")
|
|
func singleForeignWrite() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
#expect(watcher.isWatching)
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
fixture.write("card.md")
|
|
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.foreign)])
|
|
}
|
|
|
|
@Test("A burst of writes coalesces into one delivery")
|
|
func burstCoalesces() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
// Ten files back to back: the shape of an agent filing a batch of cards, or a `git
|
|
// checkout` landing a branch's worth of changes.
|
|
for index in 0..<10 {
|
|
fixture.write("card-\(index).md", "body \(index)")
|
|
}
|
|
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.foreign)])
|
|
}
|
|
|
|
// MARK: Brackets
|
|
|
|
@Test("An open bracket suppresses events; closing it delivers one app-mediated reload")
|
|
func bracketSuppressesThenDelivers() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
watcher.beginBracket()
|
|
for index in 0..<3 {
|
|
fixture.write("bracketed-\(index).md")
|
|
}
|
|
// Well past debounce + latency: if a bracket leaked, this is where it would show.
|
|
await quiet(.milliseconds(700))
|
|
#expect(log.events.isEmpty)
|
|
|
|
watcher.endBracket()
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.appMediated)])
|
|
}
|
|
|
|
@Test("Nested brackets deliver once, at the outermost close")
|
|
func nestedBrackets() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
watcher.beginBracket()
|
|
watcher.beginBracket()
|
|
fixture.write("nested.md")
|
|
await quiet(.milliseconds(400))
|
|
#expect(log.events.isEmpty)
|
|
|
|
watcher.endBracket()
|
|
await quiet(.milliseconds(400))
|
|
#expect(log.events.isEmpty, "the inner close is not a close — depth is still 1")
|
|
|
|
watcher.endBracket()
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.appMediated)])
|
|
}
|
|
|
|
@Test("Closing a bracket that saw no filesystem events still delivers the reload")
|
|
func emptyBracketStillReloads() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
// The mandatory post-bracket reload: the bracket's contract is "finish with one full
|
|
// reload", not "finish with one reload if something happened".
|
|
watcher.beginBracket()
|
|
watcher.endBracket()
|
|
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.appMediated)])
|
|
}
|
|
|
|
// MARK: Reconciliation and origin merge
|
|
|
|
@Test("reconcile() delivers a reconciling reload with no filesystem activity")
|
|
func reconcileWithoutFilesystemActivity() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
watcher.reconcile()
|
|
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.reconciling)])
|
|
}
|
|
|
|
@Test("A foreign change folding into a reconcile does not downgrade the origin")
|
|
func originMergeKeepsTheStrongerClaim() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
fixture.write("foreign.md")
|
|
// Inside the debounce window, so the two spans coalesce into one delivery: the merged
|
|
// span is genuinely covered by a reconciling reload, and `reconciling` is the honest
|
|
// label for it.
|
|
watcher.reconcile()
|
|
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.reconciling)])
|
|
}
|
|
|
|
// MARK: .git filtering
|
|
|
|
@Test("Churn inside .git is ignored; ordinary files still arrive")
|
|
func gitInternalChurnIsFiltered() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
fixture.makeDirectory(".git")
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
// What the app's own auto-commit produces, and what an external `git gc` produces: pure
|
|
// history churn that cannot alter the rendered tree.
|
|
fixture.write(".git/index", "fake index")
|
|
fixture.write(".git/objects/ab/cdef", "fake object")
|
|
fixture.write(".git/refs/heads/main", "deadbeef")
|
|
await quiet(.milliseconds(700))
|
|
#expect(log.events.isEmpty)
|
|
|
|
// …and the stream is demonstrably still alive, which is the other half of the claim: the
|
|
// filter drops events, it does not stop the watcher.
|
|
fixture.write("card.md")
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.foreign)])
|
|
}
|
|
|
|
// MARK: Root identity
|
|
|
|
@Test("Deleting the watched root delivers rootChanged and tears the stream down")
|
|
func rootDeletionIsReported() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
fixture.remove()
|
|
|
|
await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) }
|
|
#expect(log.events.contains(.rootChanged))
|
|
// The stream is gone with the root it was watching — recovery is a *fresh* stream via
|
|
// `reattach(to:)`, never a resumed one.
|
|
#expect(!watcher.isWatching)
|
|
}
|
|
|
|
// MARK: Stop
|
|
|
|
@Test("Nothing is delivered after stop()")
|
|
func stopIsFinal() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
await drainStartupChurn(log)
|
|
|
|
watcher.stop()
|
|
#expect(!watcher.isWatching)
|
|
|
|
fixture.write("after-stop.md")
|
|
await quiet(.milliseconds(800))
|
|
#expect(log.events.isEmpty)
|
|
}
|
|
|
|
@Test("stop() cancels a delivery that was already pending")
|
|
func stopCancelsPendingDelivery() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: .milliseconds(600), latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
await drainStartupChurn(log)
|
|
|
|
// Armed but not yet fired — the window where a debounce could outlive its watcher.
|
|
watcher.reconcile()
|
|
watcher.stop()
|
|
|
|
await quiet(.milliseconds(900))
|
|
#expect(log.events.isEmpty)
|
|
}
|
|
|
|
// MARK: Reattach
|
|
|
|
@Test("reattach() follows the root: one reconciling reload, then the new tree, never the old")
|
|
func reattachFollowsTheRoot() async throws {
|
|
let original = try WatchFixture()
|
|
defer { original.tearDown() }
|
|
let destination = try WatchFixture()
|
|
defer { destination.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: original.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
// The rename-absorption path: the consumer re-resolved its bookmark to a new location.
|
|
watcher.reattach(to: destination.root)
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.reconciling)])
|
|
#expect(watcher.isWatching)
|
|
await drainStartupChurn(log)
|
|
|
|
destination.write("moved-card.md")
|
|
await waitUntil { log.count >= 1 }
|
|
await quiet()
|
|
#expect(log.events == [.treeChanged(.foreign)], "the new root is live")
|
|
|
|
// The old location is no longer anyone's board.
|
|
original.write("stale-card.md")
|
|
await quiet(.milliseconds(800))
|
|
#expect(log.count == 1, "the old root is not watched by anything any more")
|
|
}
|
|
|
|
@Test("reattach() works after the root vanished from under the watcher")
|
|
func reattachAfterRootChanged() async throws {
|
|
let original = try WatchFixture()
|
|
defer { original.tearDown() }
|
|
let destination = try WatchFixture()
|
|
defer { destination.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: original.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
#expect(watcher.start())
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
original.remove()
|
|
await waitUntil(.seconds(10)) { log.events.contains(.rootChanged) }
|
|
#expect(!watcher.isWatching)
|
|
|
|
// The stream is torn down, and a fresh one attaches cleanly — the "streams die and are
|
|
// recreated, not merely kept" rule, exercised end to end.
|
|
watcher.reattach(to: destination.root)
|
|
#expect(watcher.isWatching)
|
|
await waitUntil { log.origins.contains(.reconciling) }
|
|
#expect(log.origins.contains(.reconciling))
|
|
await settle()
|
|
|
|
let countAfterReattach = log.count
|
|
destination.write("card.md")
|
|
await waitUntil { log.count > countAfterReattach }
|
|
#expect(log.count > countAfterReattach)
|
|
}
|
|
|
|
// MARK: Nonexistent root
|
|
|
|
@Test("Starting on a path that does not exist is not an error, and the stream stays honest")
|
|
func startOnNonexistentPath() async throws {
|
|
let fixture = try WatchFixture(create: false)
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
|
|
// **Observed FSEvents behaviour, not an aspiration**: `FSEventStreamCreate` and
|
|
// `FSEventStreamStart` both succeed for a path that does not exist — FSEvents watches a
|
|
// path, not an inode, and is perfectly willing to watch one that is not there yet. So
|
|
// `start()` returns `true` and `isWatching` is `true`: the honest answer, because the
|
|
// stream really is live. Existence checking belongs to the caller (the open path already
|
|
// does it); this type's contract is only that it never crashes and never lies about
|
|
// whether it is watching.
|
|
let started = watcher.start()
|
|
#expect(started, "FSEvents watches a path, not an inode — a missing one is fine by it")
|
|
#expect(watcher.isWatching == started)
|
|
defer { watcher.stop() }
|
|
await drainStartupChurn(log)
|
|
|
|
// And the path *appearing* is itself a root change under `WatchRoot` — so a watcher
|
|
// started early does not go deaf, it reports the identity change and hands the consumer
|
|
// its ordinary re-resolve-and-`reattach(to:)` job.
|
|
try FileManager.default.createDirectory(at: fixture.root, withIntermediateDirectories: true)
|
|
await waitUntil(.seconds(10)) { !log.events.isEmpty }
|
|
#expect(log.events == [.rootChanged])
|
|
#expect(!watcher.isWatching, "a root change tears the stream down, appearing or vanishing")
|
|
}
|
|
|
|
@Test("Every method is safe on a watcher whose stream was never started")
|
|
func methodsAreSafeWithoutAStream() async throws {
|
|
let fixture = try WatchFixture()
|
|
defer { fixture.tearDown() }
|
|
let log = EventLog()
|
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
|
log.record($0)
|
|
}
|
|
|
|
// The degraded-but-alive contract: a failed `start()` must not turn every later call into
|
|
// a crash. Brackets and reconciles still behave; they simply have no stream prompting
|
|
// them.
|
|
#expect(!watcher.isWatching)
|
|
watcher.endBracket() // unbalanced: ignored, not a trap
|
|
watcher.beginBracket()
|
|
watcher.endBracket()
|
|
await waitUntil { log.count >= 1 }
|
|
#expect(log.events == [.treeChanged(.appMediated)])
|
|
|
|
watcher.stop()
|
|
watcher.stop()
|
|
#expect(!watcher.isWatching)
|
|
}
|
|
}
|