Files and persistence
Handle file replacement
Treat replacement as a different event from appending bytes and reopen watched files when their identity changes.
12 minute lesson
Suppose the notes app watches an external file — a log the user imported, re-rendered whenever it changes. File watching on macOS has a trap that catches nearly everyone: the file you watch can stop being the file at that path.
Remember the atomic save from two lessons ago? Editors do the same thing. When TextEdit or VS Code saves, it writes a new file and renames it over the old path. The path is unchanged, but the file — the actual inode your watcher holds open — is now an orphan. Your watcher keeps listening to a file nothing will ever write to again.
The low-level tool is a dispatch source attached to an open file descriptor:
let descriptor = open(url.path, O_EVTONLY)
let watcher = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: descriptor,
eventMask: [.write, .rename, .delete],
queue: .main
)
watcher.setEventHandler {
handle(watcher.data)
}
watcher.resume()
The event mask is the key decision. .write alone covers direct appends. .rename and .delete are how atomic replacement shows up — the old file gets renamed or removed, and that event is your signal that the descriptor is dead.
When one of those events arrives, do not keep reading. Tear down and reattach:
func handle(_ event: DispatchSource.FileSystemEvent) {
if event.contains(.rename) || event.contains(.delete) {
watcher.cancel()
close(descriptor)
// the path may briefly not exist during the swap
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
startWatching(url)
}
} else {
readNewData()
}
}
After reopening, be careful with your read position. If you were tailing the file from a saved offset, the replacement file may be shorter than that offset. Check the new size first, and treat “smaller than before” as truncation: start from zero instead of reading from a position past the end.
Debounce, too. Editors often produce a burst of events per save, and collapsing a burst into one reload is good. What you must not do is collapse a rename into a plain write, because they require different responses.
Test both paths deliberately. Running echo hello >> watched.txt in Terminal produces a pure append — your .write handler should fire. Saving the same file from TextEdit produces the rename dance — your reattach path should fire. If your watcher survives the first test but goes silent after the second, you are watching the descriptor and ignoring identity, which is exactly the bug this lesson exists to prevent.
Lesson completed