diff --git a/Kanban/LiveStore/FolderWatcher.swift b/Kanban/LiveStore/FolderWatcher.swift new file mode 100644 index 0000000..053c05c --- /dev/null +++ b/Kanban/LiveStore/FolderWatcher.swift @@ -0,0 +1,597 @@ +import CoreServices +import Foundation + +// MARK: - Vocabulary + +/// Why a reload is being asked for — the provenance of a `WatcherEvent.treeChanged`. +/// +/// The consumer (`BoardStore`, a later milestone) reloads identically for all three; the origin +/// exists so the *policy* around a reload can differ — a reconciling sweep re-probes writability +/// (02-architecture.md § Write-failure surfacing, "Writability re-probes on every reconciling +/// reload"), an app-mediated reload is the one that ends a bracket's read-only lock (§ +/// Live-reload resilience), and a foreign reload is the ordinary external-edit case. Every +/// reload is a full tree walk producing a value-type snapshot, so no origin is ever *less* safe +/// than another — an identical tree swaps in value-equal and costs nothing visible. +public enum WatchOrigin: String, Sendable, Equatable { + /// The change span was produced under an app bracket (`beginBracket()` … `endBracket()`) — + /// the app's own pull-rebase, branch switch, undo restore, or ordinary Writer mutation. + case appMediated + /// The change arrived from outside the app: an editor, an agent, `git` in a terminal. + case foreign + /// A reconciliation sweep rather than an observed change: wake-from-sleep, app re-activation, + /// an FSEvents flag admitting missed events, or a stream re-creation. "Never trusted blindly" + /// (§ Live-reload resilience) — every known blind window ends in one of these. + case reconciling + + /// Precedence when two change spans coalesce into one delivery: `reconciling` outranks + /// `appMediated` outranks `foreign`. Higher means "makes the stronger claim about what the + /// reload has to cover" — a reconciling reload assumes nothing about the tree, an + /// app-mediated one is the tail of an operation the app ran, a foreign one is an observed + /// external edit. + fileprivate var precedence: Int { + switch self { + case .foreign: 0 + case .appMediated: 1 + case .reconciling: 2 + } + } + + /// The origin a coalesced delivery carries: the stronger of the two, never the newer. + /// + /// This is the *never downgrade* rule — a foreign event landing on a pending app-mediated or + /// reconciling delivery does not weaken it. See `FolderWatcher.schedule(_:)` for why the + /// resulting blur is accepted rather than engineered away. + fileprivate static func merged(_ existing: WatchOrigin?, _ incoming: WatchOrigin) -> WatchOrigin { + guard let existing else { return incoming } + return existing.precedence >= incoming.precedence ? existing : incoming + } +} + +/// What the watcher tells its consumer. Deliberately two cases and no more: the watcher reports +/// *that* the tree changed, never *what* changed. Diffing is the loader's job, and a value-type +/// snapshot comparison is both cheaper and more trustworthy than trying to reconstruct a tree +/// mutation from a stream of paths that may have been coalesced, dropped, or reordered. +public enum WatcherEvent: Sendable, Equatable { + /// Debounced and coalesced: "the tree changed — reload". One event may stand for hundreds of + /// filesystem events; `WatchOrigin` says where the span came from. + case treeChanged(WatchOrigin) + /// The watched root itself vanished or changed identity (deleted, renamed, moved, or its + /// volume unmounted). The stream is already torn down when this arrives; the consumer + /// re-resolves its security-scoped bookmark and either calls `reattach(to:)` at the new + /// location or enters the vanished-root read-only lock (02-architecture.md § + /// Write-failure surfacing). + case rootChanged +} + +// MARK: - FolderWatcher + +/// The board's one watching path: an FSEvents stream over the board root, debounced and +/// coalesced into "reload now" (02-architecture.md § Layering ▸ Components). +/// +/// There is deliberately **nothing else** watching — no `NSMetadataQuery` for iCloud Drive, no +/// polling fallback for network volumes. On those warned-against locations FSEvents delivery is +/// unreliable and live reload silently degrades, accepted per 07-sync-collab.md's +/// no-accommodations stance. This type therefore contains not one line of volume special-casing; +/// the only concession is that a stream which cannot be created is a `false` from `start()` +/// rather than a crash. +/// +/// ### The three things this type actually does +/// +/// 1. **Debounce.** Filesystem churn arrives in bursts — a `git checkout`, an agent writing +/// twenty cards, the app's own multi-file move. Events arriving while a delivery is pending +/// restart the timer (a trailing debounce), so a burst costs exactly one reload after quiet. +/// 2. **Brackets.** Operations the app runs itself suspend delivery for their duration and +/// finish with one full reload — half-checked-out trees are never rendered (§ Live-reload +/// resilience, "App-initiated git churn is bracketed"). +/// 3. **Reconciliation.** Anything admitting a blind window — a missed-events flag, a stream +/// re-creation, a wake or activation the consumer reports — degrades to a reload rather than +/// trusting the gap. "A silently stale board is structurally excluded." +/// +/// ### Lifetime contract +/// +/// `start()` and `stop()` are explicit and the documented contract. `stop()` is idempotent, safe +/// to call on a watcher that never started, and guarantees nothing fires afterwards. The C-level +/// stream is owned by a small non-isolated handle whose `deinit` invalidates it, so a watcher +/// dropped without `stop()` still tears its stream down rather than leaking it — but a dropped +/// watcher is not a *quiet* watcher until that release happens, which is why `stop()` stays the +/// contract. +@MainActor +public final class FolderWatcher { + + // MARK: Configuration + + /// The path FSEvents was asked to watch, symlink-resolved — and the only form of the root + /// this type keeps. FSEvents reports canonical paths (`/private/var/...`), so the prefix + /// every incoming path is measured against has to be canonical too, or the `.git` filter + /// below would never match on a temp directory, which is exactly where it is tested. The + /// consumer owns the board's *identity* (its security-scoped bookmark); the watcher only + /// needs somewhere to point. + private var watchedPath: String + private let debounce: Duration + private let latency: TimeInterval + private let handler: @MainActor (WatcherEvent) -> Void + + /// FSEvents callbacks land here before hopping to the main actor. Serial and private: the + /// stream's `info` box is released on this queue's tail by FSEvents itself, so an in-flight + /// callback can never outlive the box it reads from. + private let queue = DispatchQueue(label: "dev.rzen.indie.Kanban.FolderWatcher", qos: .utility) + + // MARK: State + + private var handle: StreamHandle? + + /// Bumped on every stream creation and every teardown. A callback that has already hopped to + /// the main actor when `stop()` (or `reattach(to:)`) runs would otherwise deliver an event + /// from a stream that no longer exists; the generation it captured no longer matches and it + /// is dropped. This is the whole of the "nothing fires after `stop()`" guarantee on the + /// inbound side — the outbound side is the cancelled delivery task. + private var generation = 0 + + /// Nesting depth of `beginBracket()`/`endBracket()`. Brackets nest because the operations + /// that use them do: an undo restore inside a branch switch is one bracketed span, not two. + private var bracketDepth = 0 + + /// The origin a pending delivery will carry, merged per `WatchOrigin.merged(_:_:)`. Non-nil + /// means "a delivery is owed" — which is not the same as "a timer is running": while a + /// bracket is open the origin is remembered with no timer armed, and `endBracket()` merges + /// its `.appMediated` into it and arms the timer then. + private var pendingOrigin: WatchOrigin? + + /// The debounce, as a cancellable `Task` on the main actor rather than a `Timer` or a + /// `DispatchSourceTimer`: the delivery has to happen where the handler and all the state it + /// touches already live, and `Task.sleep` gives cancellation for free at exactly the + /// granularity a trailing debounce needs — cancel, re-arm, done. + private var deliveryTask: Task? + + // MARK: Init + + /// - Parameters: + /// - root: The board root to watch. Need not exist yet (see `start()`). + /// - debounce: Quiet period before a coalesced `.treeChanged` is delivered. The default is + /// the reload debounce: long enough to absorb a `git checkout`'s churn and most momentary + /// invalid states (§ Live-reload resilience, "the reload debounce already absorbs most + /// momentary invalid states before they surface"), short enough that an external edit + /// feels live. + /// - latency: FSEvents' own coalescing latency, in seconds. Kept small and paired with + /// `NoDefer` so the *first* event of a burst arrives promptly; this type's own debounce + /// does the real coalescing, where it can be reasoned about and tested. + /// - handler: Called on the main actor for every delivered event. + public init( + root: URL, + debounce: Duration = .milliseconds(200), + latency: TimeInterval = 0.05, + handler: @escaping @MainActor (WatcherEvent) -> Void + ) { + self.watchedPath = Self.canonicalPath(of: root) + self.debounce = debounce + self.latency = latency + self.handler = handler + } + + deinit { + // The stream is released with `self`; `StreamHandle.deinit` stops, invalidates, and + // releases it. Nothing main-actor-isolated can be touched from here, and nothing needs + // to be: a pending `deliveryTask` holds only a weak reference back and drops its event. + } + + /// Whether a stream is currently attached and delivering. + public var isWatching: Bool { handle != nil } + + // MARK: - Stream lifecycle + + /// Creates and starts the FSEvents stream. Returns `false` if the stream could not be + /// created or started, in which case `isWatching` stays `false` and every other method + /// remains safe to call — brackets, `reconcile()`, and `reattach(to:)` all still behave, they + /// simply have no filesystem prompting them. + /// + /// Calling `start()` on an already-watching watcher replaces the stream (the old one is torn + /// down first), which is what makes it safe to use as a retry. + /// + /// **A nonexistent path is not an error to FSEvents**: the stream is created and started + /// happily, and with `WatchRoot` the eventual *creation* of that path is reported as a root + /// change. `start()` therefore returns `true` for a path that is not there — the honest + /// answer, since the stream really is live and really will report the path appearing. Callers + /// that need existence must check for it themselves; this type's job is watching, not + /// validating. + @discardableResult + public func start() -> Bool { + teardownStream() + + generation += 1 + let generation = self.generation + let box = CallbackBox { [weak self] paths, flags in + // The C callback runs on `queue`; the state it feeds is main-actor-isolated, so the + // hop is unavoidable — and harmless, because the delivery it feeds is debounced + // anyway. `weak self` so a watcher released between the callback and the hop simply + // has no one to tell. + Task { @MainActor in + self?.receive(paths: paths, flags: flags, generation: generation) + } + } + + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passRetained(box).toOpaque(), + retain: nil, + // FSEvents calls this when the stream is deallocated, which it does only after it is + // finished with the callback — so the box outlives every callback that can read it + // without a single manual `release` on our side, and without a teardown race. + // + // It has to be a top-level function, not a closure literal: a closure written inside + // this main-actor-isolated method inherits that isolation, and FSEvents runs the + // release callback on the stream's dispatch queue during + // `_FSEventStreamDeallocate` — which trips the actor's `dispatch_assert_queue` + // check and traps. (Found the hard way; the crash is a `SIGTRAP` inside + // `swift_task_isCurrentExecutor`, not anything that looks like a memory bug.) + release: folderWatcherReleaseCallback, + copyDescription: nil + ) + + let flags = UInt32( + kFSEventStreamCreateFlagUseCFTypes // paths arrive as CFString, not char* + | kFSEventStreamCreateFlagFileEvents // per-path events, so `.git` can be filtered + | kFSEventStreamCreateFlagWatchRoot // the root vanishing is itself an event + | kFSEventStreamCreateFlagNoDefer // first event of a burst arrives promptly + ) + + guard let stream = FSEventStreamCreate( + kCFAllocatorDefault, + folderWatcherStreamCallback, + &context, + [watchedPath] as CFArray, + FSEventStreamEventId(kFSEventStreamEventIdSinceNow), + latency, + flags + ) else { + // Creation failed, so FSEvents never took ownership of the box: release the retain + // taken above by hand, or it leaks. + if let info = context.info { + Unmanaged.fromOpaque(info).release() + } + return false + } + + FSEventStreamSetDispatchQueue(stream, queue) + guard FSEventStreamStart(stream) else { + FSEventStreamInvalidate(stream) + FSEventStreamRelease(stream) + return false + } + + handle = StreamHandle(stream: stream) + return true + } + + /// Tears the stream down and cancels any pending delivery. Nothing is delivered after this + /// returns — not an in-flight FSEvents callback (its generation is stale), not a debounce + /// that was seconds from firing (its task is cancelled). + /// + /// Bracket depth is deliberately *not* reset: `stop()` is about the stream, and an + /// unbalanced bracket is the caller's bug to keep, not this type's to paper over. + public func stop() { + teardownStream() + cancelPendingDelivery() + pendingOrigin = nil + } + + /// Kills the current stream, attaches a fresh one at `newRoot`, and schedules a reconciling + /// reload — "streams die and are recreated, not merely kept", with the reconciling reload + /// that every stream re-creation owes (§ Live-reload resilience). + /// + /// The consumer's path here is the settled rename story (§ Write-failure surfacing): on + /// `.rootChanged` it re-resolves its security-scoped bookmark, and if the bookmark resolves + /// to a new location the move is absorbed transparently — this call is that absorption. + /// + /// The reconciling reload is scheduled **whether or not the new stream came up**. If the new + /// location is unwatchable, the reload is how the consumer finds out (its own load fails, or + /// its writability probe does); silently skipping it would leave a stale board on screen, + /// which is the one outcome this design excludes. + public func reattach(to newRoot: URL) { + stop() + watchedPath = Self.canonicalPath(of: newRoot) + _ = start() + schedule(.reconciling) + } + + private func teardownStream() { + guard let handle else { return } + // Bump before invalidating: a callback that already hopped to the main actor and is + // queued behind this call must find its generation stale. + generation += 1 + handle.invalidate() + self.handle = nil + } + + // MARK: - Brackets + + /// Opens a bracket. Nestable — brackets count, they do not toggle. + /// + /// While any bracket is open **nothing is delivered**: incoming FSEvents are swallowed and + /// any already-armed debounce is disarmed, because a bracketed operation rewrites the tree + /// wholesale and a reload landing in the middle of it would render a half-checked-out tree + /// (§ Live-reload resilience). + /// + /// A delivery that was already *owed* when the bracket opened is not thrown away — its origin + /// is kept and folds into the post-bracket reload through the ordinary merge. Losing it would + /// be harmless for the reload itself (the post-bracket reload is a full tree walk either way) + /// but would lose the *origin*, and a `.reconciling` that becomes an `.appMediated` is a + /// downgrade of provenance for no gain. + public func beginBracket() { + bracketDepth += 1 + cancelPendingDelivery() + } + + /// Closes a bracket. At depth 0 this **always** schedules the debounced + /// `.treeChanged(.appMediated)` — the mandatory single post-bracket reload — even if not one + /// filesystem event was seen inside the bracket. The bracket's contract is "finish with one + /// full reload"; making that conditional on having observed events would make correctness + /// depend on FSEvents delivery, which is precisely the thing this design refuses to trust. + /// + /// FSEvents produced by the bracketed operation and still in flight when it closes (kernel + /// latency does not respect our brackets) simply coalesce into that pending delivery via the + /// origin merge, arriving as `.appMediated` rather than as a second `.foreign` reload. + /// + /// An unbalanced call — `endBracket()` at depth 0 — is ignored rather than trapping: the + /// consumer's brackets wrap `do`/`catch` spans over git operations, and a bug there should + /// not take the app down. + public func endBracket() { + guard bracketDepth > 0 else { return } + bracketDepth -= 1 + guard bracketDepth == 0 else { return } + schedule(.appMediated) + } + + // MARK: - Reconciliation + + /// Schedules a debounced reconciling reload with no filesystem prompt: the "self-reconciling, + /// never trusted blindly" rule made callable. The consumer drives it from wake-from-sleep and + /// app re-activation; the watcher drives it itself from missed-events flags and stream + /// re-creation. + /// + /// Cheap by construction — an identical tree reloads to a value-equal snapshot and nothing + /// visible happens — so callers are meant to be liberal with it. + /// + /// Respects brackets like everything else: called inside one, it is remembered and delivered + /// with the post-bracket reload rather than mid-operation. + public func reconcile() { + schedule(.reconciling) + } + + // MARK: - Delivery + + /// Records the origin a pending delivery owes and (re-)arms the debounce. + /// + /// Two rules live here: + /// + /// - **Trailing debounce**: every call cancels the armed task and arms a fresh one, so a + /// burst of events delivers once, `debounce` after the last of them. + /// - **Origin merge**: `reconciling > appMediated > foreign`. A foreign event folding into a + /// pending app-mediated (or reconciling) delivery does not downgrade it. **This blur is + /// accepted by design**: once two change spans have been coalesced into one reload, the + /// reload genuinely covers both, and the honest label for the merged span is the strongest + /// claim either half made. The alternative — splitting the delivery to keep origins pure — + /// would trade the coalescing that makes bursts affordable for a distinction no consumer + /// acts on differently. + private func schedule(_ origin: WatchOrigin) { + pendingOrigin = WatchOrigin.merged(pendingOrigin, origin) + + // Inside a bracket the origin is banked but no timer is armed; `endBracket()` arms it. + guard bracketDepth == 0 else { + cancelPendingDelivery() + return + } + + deliveryTask?.cancel() + deliveryTask = Task { [weak self, debounce] in + try? await Task.sleep(for: debounce) + guard !Task.isCancelled else { return } + self?.fire() + } + } + + private func fire() { + deliveryTask = nil + // A bracket that opened during the sleep already cancelled this task, but the cancel and + // the wake can race; the depth check is the authority. + guard bracketDepth == 0, let origin = pendingOrigin else { return } + pendingOrigin = nil + handler(.treeChanged(origin)) + } + + private func cancelPendingDelivery() { + deliveryTask?.cancel() + deliveryTask = nil + } + + // MARK: - Inbound events + + /// The main-actor landing point for one FSEvents callback batch. + private func receive(paths: [String], flags: [FSEventStreamEventFlags], generation: Int) { + guard generation == self.generation, handle != nil else { return } + + var sawRootChange = false + var sawMissedEvents = false + var sawRelevantPath = false + + for index in paths.indices { + let flag = index < flags.count ? flags[index] : 0 + + if flag & FSEventStreamEventFlags(kFSEventStreamEventFlagRootChanged) != 0 { + sawRootChange = true + continue + } + if flag & Self.missedEventsFlags != 0 { + sawMissedEvents = true + } + if !isGitInternal(paths[index]) { + sawRelevantPath = true + } + } + + if sawRootChange { + deliverRootChanged() + return + } + + // Brackets swallow everything below this line, missed-events flags included. That is not + // a gap: the post-bracket reload is already a full tree walk, which covers any span the + // flag was warning about, so `.appMediated` stays the correct label for it (§ Live-reload + // resilience — recovery from any blind window is always the same act, reload). + guard bracketDepth == 0 else { return } + + if sawMissedEvents { + // `MustScanSubDirs`, a dropped kernel or user queue, wrapped event ids: the stream is + // telling us it does not know what happened. Degrade to the reload rather than trust + // the gap. Note this bypasses the `.git` filter deliberately — a batch we cannot + // trust the contents of cannot be filtered by its contents either. + schedule(.reconciling) + return + } + + // Nothing but `.git` churn in the whole batch: schedule nothing at all (see + // `isGitInternal`). + if sawRelevantPath { + schedule(.foreign) + } + } + + /// The root vanished or changed identity. The stream is torn down (it is watching a path that + /// no longer means what it meant) and the event is delivered **immediately** — bypassing both + /// the debounce and any open bracket. + /// + /// Bypassing the bracket is deliberate and is the one place the "nothing fires mid-bracket" + /// rule yields: a bracketed operation whose root disappeared underneath it cannot finish, and + /// its post-bracket reload would resolve against a path that is gone. The consumer must know + /// now, so it can re-resolve its bookmark and either `reattach(to:)` or enter the + /// vanished-root read-only lock (02-architecture.md § Write-failure surfacing). + /// + /// Any pending debounced delivery is dropped: it was going to report on a tree that no longer + /// exists at that path, and whatever the consumer does next — `reattach(to:)` or the lock — + /// ends in a reload of its own. + private func deliverRootChanged() { + teardownStream() + cancelPendingDelivery() + pendingOrigin = nil + handler(.rootChanged) + } + + // MARK: - .git filtering + + /// Whether `path` lives inside the board root's `.git` directory. + /// + /// **Why filter at all**: the app auto-commits a couple of seconds after every change + /// (06-history-undo.md), so every single edit the user makes is followed by a burst of writes + /// to `.git/index`, `.git/objects/…`, and `.git/refs/…` — a second wave of events for a + /// change already reloaded, arriving just late enough to miss the debounce and cost a + /// redundant full tree walk. None of that churn can alter the rendered tree: the loader walks + /// UUID-shaped folders and `index.md` files, and `.git` contains neither. + /// + /// **Why it is safe**: this filters `.git`'s *internals*, not git's effects. An external + /// `git checkout`, `git pull`, or `git stash` rewrites working-tree files, and those events + /// arrive unfiltered in the same batch as the `.git` writes — the batch has a relevant path, + /// so it schedules. The one thing lost is the ability to notice a pure-history change (a + /// commit that touched no working-tree file), which changes nothing on screen anyway. + /// + /// A path that does not sit under the watched root at all is *not* filtered — better a + /// redundant reload than a missed one. + private func isGitInternal(_ path: String) -> Bool { + guard path.hasPrefix(watchedPath) else { return false } + let relative = path.dropFirst(watchedPath.count) + guard relative.hasPrefix("/") else { return false } + return relative.dropFirst().hasPrefix(".git/") || relative.dropFirst() == ".git" + } + + // MARK: - Helpers + + private static let missedEventsFlags = FSEventStreamEventFlags( + kFSEventStreamEventFlagMustScanSubDirs + | kFSEventStreamEventFlagUserDropped + | kFSEventStreamEventFlagKernelDropped + | kFSEventStreamEventFlagEventIdsWrapped + ) + + /// FSEvents reports canonical, symlink-resolved paths with no trailing slash; every path + /// comparison this type makes has to be against the same shape or it silently never matches. + private static func canonicalPath(of url: URL) -> String { + let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path + return resolved.count > 1 && resolved.hasSuffix("/") ? String(resolved.dropLast()) : resolved + } +} + +// MARK: - C-level plumbing + +/// Owns the `FSEventStreamRef` outside the main actor so that releasing a `FolderWatcher` — from +/// wherever the last reference happens to die — still tears the stream down. +/// +/// `@unchecked Sendable` is honest here rather than a shrug: the handle is created and +/// invalidated only on the main actor, and the one call that can happen anywhere else is `deinit` +/// — which by definition runs after every other reference is gone, so there is no concurrent +/// reader to race with. FSEvents' own stop/invalidate/release are safe to call from any thread. +private final class StreamHandle: @unchecked Sendable { + private var stream: FSEventStreamRef? + + init(stream: FSEventStreamRef) { + self.stream = stream + } + + /// Idempotent: the second call finds `stream` already nil and does nothing, so an explicit + /// `stop()` followed by deallocation is not a double release. + func invalidate() { + guard let stream else { return } + self.stream = nil + FSEventStreamStop(stream) + FSEventStreamInvalidate(stream) + // Drops the last reference, which is what makes FSEvents run the context's release + // callback and free the `CallbackBox`. + FSEventStreamRelease(stream) + } + + deinit { + invalidate() + } +} + +/// The stream's `info` payload: a closure and nothing else. +/// +/// Strict concurrency has no way to reason about a `void *` round-tripping through C, so the box +/// keeps the unchecked part as small as it can possibly be — one immutable `@Sendable` closure, +/// no mutable state, no reference to the watcher (the closure captures it weakly). Its lifetime +/// is FSEvents': retained into `FSEventStreamContext.info`, released by the context's release +/// callback when the stream is deallocated. +private final class CallbackBox: @unchecked Sendable { + let deliver: @Sendable ([String], [FSEventStreamEventFlags]) -> Void + + init(deliver: @escaping @Sendable ([String], [FSEventStreamEventFlags]) -> Void) { + self.deliver = deliver + } +} + +/// Balances the `Unmanaged.passRetained` that put the box into `FSEventStreamContext.info`. +/// Top-level and therefore non-isolated by construction — see the comment at the call site for +/// why that matters. +private func folderWatcherReleaseCallback(_ pointer: UnsafeRawPointer?) { + guard let pointer else { return } + Unmanaged.fromOpaque(pointer).release() +} + +/// The `@convention(c)` trampoline. Captures nothing (it cannot), unpacks the `UseCFTypes` path +/// array and the parallel flags array, and hands both to the box. +private func folderWatcherStreamCallback( + _ stream: ConstFSEventStreamRef, + _ info: UnsafeMutableRawPointer?, + _ numEvents: Int, + _ eventPaths: UnsafeMutableRawPointer, + _ eventFlags: UnsafePointer, + _ eventIds: UnsafePointer +) { + guard let info, numEvents > 0 else { return } + let box = Unmanaged.fromOpaque(info).takeUnretainedValue() + + // With `kFSEventStreamCreateFlagUseCFTypes` this is a CFArray of CFString — the documented + // shape, and the reason that flag is set: no manual `char *` decoding, no encoding guesswork + // on paths. + guard let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] else { return } + let flags = (0.. 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) + } +}