Test file watchers on a cloud Mac without counting callbacks as edits

Test file watchers on a cloud Mac without counting callbacks as edits

A build tool watches a source directory on a cloud Mac and starts compiling when a file is saved. In practice, an editor may write a temporary file and rename it over the original; several rapid saves may also produce just one callback. If the tool treats one event as proof that exactly one file changed, it can miss work. This small experiment tests notifications separately from the resulting file state.

Choose what to watch

Watching an individual file descriptor is useful for observing that file object, but not for detecting whether a new file has taken its place at the same path. Many editors use atomic saves: they write a temporary file, then rename it to replace the original. The path stays the same, but the file object behind it may change.

This experiment watches the parent directory instead. The approach has two steps: when a directory event arrives, mark the directory as possibly changed; then read it again and compare it with the previous snapshot. This does not promise to capture every intermediate write. It answers the question a build tool needs answered: which input files differ from the last check?

A callback is a prompt to rescan, not a ledger of edits. If two writes are combined into one notification, the resulting file must still be checked on the next pass.

Build a minimal runnable watcher

Create an experiment directory from the command line on the cloud Mac, then save the following as watch.swift. The script checks only the directory’s immediate children; it does not recurse. It records each file’s name, size, modification time and file number. The file number helps identify a replacement at the same name.

import Darwin
import Dispatch
import Foundation

struct Entry: Equatable {
    let size: UInt64
    let modified: Date
    let inode: UInt64
}

let path = CommandLine.arguments.dropFirst().first ?? ""
guard !path.isEmpty else {
    fputs("usage: swift watch.swift DIRECTORY\n", stderr)
    exit(2)
}
guard FileManager.default.fileExists(atPath: path) else {
    fputs("directory does not exist\n", stderr)
    exit(2)
}

func snapshot() -> [String: Entry] {
    var result: [String: Entry] = [:]
    let names = (try? FileManager.default.contentsOfDirectory(atPath: path)) ?? []
    for name in names {
        let file = (path as NSString).appendingPathComponent(name)
        guard let attributes = try? FileManager.default.attributesOfItem(atPath: file),
              let size = attributes[.size] as? NSNumber,
              let inode = attributes[.systemFileNumber] as? NSNumber,
              let modified = attributes[.modificationDate] as? Date else {
            continue
        }
        result[name] = Entry(
            size: size.uint64Value,
            modified: modified,
            inode: inode.uint64Value
        )
    }
    return result
}

let fd = open(path, O_EVTONLY)
guard fd >= 0 else {
    perror("open")
    exit(1)
}
let queue = DispatchQueue(label: "watch.lab")
let source = DispatchSource.makeFileSystemObjectSource(
    fileDescriptor: fd,
    eventMask: [.write, .rename, .delete],
    queue: queue
)
var previous = snapshot()
source.setEventHandler {
    let current = snapshot()
    let changed = Set(previous.keys).union(current.keys)
        .filter { previous[$0] != current[$0] }
        .sorted()
    if !changed.isEmpty {
        print("changed: \(changed.joined(separator: ", "))")
        fflush(stdout)
    }
    previous = current
}
source.setCancelHandler { close(fd) }
source.resume()
print("watching: \(path)")
dispatchMain()

The .write callback is not mapped directly to a file name: a directory event only tells us to inspect the directory. snapshot() skips entries it cannot read. A production tool should therefore log read errors separately and retry on the next pass, rather than treating a transient permission problem or rename race as reliable evidence of deletion.

Test the boundaries with three operations

Start the watcher in one terminal, then run the operations in a second. These paths are for the experiment only; do not point the commands at a working repository.

mkdir -p "$HOME/watch-lab"
swift watch.swift "$HOME/watch-lab"
printf 'first\n' > "$HOME/watch-lab/input.txt"
printf 'second\n' > "$HOME/watch-lab/input.txt"
printf 'replacement\n' > "$HOME/watch-lab/.input.tmp"
mv -f "$HOME/watch-lab/.input.tmp" "$HOME/watch-lab/input.txt"
rm "$HOME/watch-lab/input.txt"

Do not expect one output line per command. If a write and a rename occur between snapshots, an intermediate state may never appear. After the final deletion, however, the snapshot should no longer contain input.txt. To inspect the results of creation, replacement and deletion separately, pause after each operation and wait for its output before continuing.

Why a same-name replacement needs the file number

Comparing names alone makes “the old file disappeared and a new file took the same path” look like no change. The sizes could also happen to match. The experiment records the file number as well, so it can usually detect an atomic replacement even when the name and size are unchanged. Modification time helps detect ordinary in-place writes. This snapshot is a lightweight change check, not a content verification method. If a release decision requires byte-for-byte certainty, calculate a content digest once the candidate file is stable.

Bring the experiment into the build workflow

In a real build, starting a compilation immediately for every event is not ideal. A more robust state machine is “receive notification → schedule a rescan → collect changed paths → trigger a build.” If another notification arrives during the rescan, scan again when the current pass finishes. This allows events to be combined without discarding new changes just because a build is running.

Be explicit about directory depth, too. The script above lists only immediate children; it cannot fully detect content changes in nested source directories. To cover an entire tree, use a mechanism suited to recursive directory changes while retaining the “rescan after notification” principle. Exclude build output directories as well, or writing build artifacts may repeatedly trigger another build.

Finally, check the lifecycle of the watched directory itself. If it is deleted or replaced, the old descriptor cannot continue to represent the new path. The tool should verify that the path still points to the expected directory and, when necessary, reopen it and set up the watcher again.

An actionable acceptance checklist

Before deploying the watcher, run these checks in a separate temporary directory:

These checks shift the question from “why did the system deliver one fewer event?” to “have the current inputs been checked completely?” For cloud Mac workflows that trigger tasks from file changes, the latter is the result you can verify.

Frequently asked questions

Why can two quick file edits produce only one callback?

The file system may coalesce events. Treat the callback as a prompt to rescan, not as a count of edits.

Why can watching a file handle miss an atomic save?

Replacement creates a new file object while the handle can remain attached to the old one. Watch the parent directory and inspect the path again.

Get a dedicated Mac for your next experiment

Choose a physical Mac mini, node, and billing period to suit your needs. Models and availability are shown in real time in the console.

View cloud Mac rental plans