Files and persistence

Handle file replacement

Treat replacement as a different event from appending bytes and reopen watched files when their identity changes.

Say 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.

Watch a file descriptor

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.

Reattach on rename or delete

When one of those events arrives, don’t 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()
  }
}

The short delay matters. During the swap there’s a moment where the path points at nothing yet. Reopen too early and you get a failed open call.

Watch your read position

After reopening, be careful with your offset. If you were tailing the file from a saved position, the replacement file may be shorter than that offset.

Check the new size first. Treat “smaller than before” as truncation and start from zero, instead of reading from a position past the end.

Debounce, but don’t merge event types

Editors often produce a burst of events per save. Collapsing a burst into one reload is good.

What you must not do is collapse a rename into a plain write. They need different responses. One means “read more”, the other means “reopen”.

Test both paths

Run echo hello >> watched.txt in Terminal. That’s a pure append, and your .write handler should fire.

Then save the same file from TextEdit. That’s the rename dance, and your reattach path should fire.

If your watcher survives the first test but goes silent after the second, you’re watching the descriptor and ignoring identity. That’s exactly the bug this lesson exists to prevent.

Lesson completed