Build FolderWatcher — FSEvents with bracketed suppression

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
This commit is contained in:
2026-07-26 18:49:52 -04:00
parent 048bb8d244
commit cadb62564c
2 changed files with 1130 additions and 0 deletions
+597
View File
@@ -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<Void, Never>?
// 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<CallbackBox>.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<CallbackBox>.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<FSEventStreamEventFlags>,
_ eventIds: UnsafePointer<FSEventStreamEventId>
) {
guard let info, numEvents > 0 else { return }
let box = Unmanaged<CallbackBox>.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..<min(numEvents, paths.count)).map { eventFlags[$0] }
box.deliver(paths, flags)
}
+533
View File
@@ -0,0 +1,533 @@
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)
}
}