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:
- After rapid consecutive writes, the file with the final content enters the set of inputs to build; callback count is not a pass criterion.
- After a temporary file is renamed over the original, the input at that name is checked again.
- After an input is deleted, a file from the previous snapshot is no longer treated as present.
- If a second change occurs during a build, at least one more rescan runs afterward.
- The output directory is excluded; if the watched directory is replaced, the old watcher is rebuilt.
- Read failures are logged and have a retry path; they are not silently treated as “no change.”
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.