Skip to content

Filesystem events ​

Generated by the probe, not written by hand:

cargo run -p retype-watch --example probe_events

The translation it measures is the one that ships: retype-watch holds the collection and translation, retype-core holds only the correlator and knows nothing about notify.

Raw transcripts are kept in docs/dev/measurement/probe/, named <platform>-<date>.txt, so every claim below can be checked against the run that produced it. CI uploads the same output as an artifact.

Each run creates a temporary directory, watches it, and replays a scenario suite. Scenarios are split into a setup and an action so the correlation starts from a primed state, the way it does in the daemon when a file has existed for a while. For each action it prints the raw events, then what the name-pair strategy concluded, then what the identity strategy concluded.

A conclusion is a fact, which says only that this name became that name in the same directory, and the intent the grammar reads from it. intent=none is therefore the answer for a rename that means nothing, such as a change of case: correlation does not know formats, and deciding where a stem ends needs the registry. Every transcript here is from the probe revision that printed it; the 2026-10-29 files are kept as history.

1. Linux (inotify, notify 8) ​

Measured 2026-11-02, transcript docs/dev/measurement/probe/linux-2026-11-02.txt.

1.1 Raw events ​

An in-place rename produces three rename events, not one:

Modify(Name(From))   a.heic
Modify(Name(To))     a.jpg
Modify(Name(Both))   a.heic -> a.jpg

Alongside them, ordinary noise: Create(File), Access(Open(Any)), Modify(Data(Any)), Access(Close(Write)). The dataless events matter only because they must be filtered: a rename followed by a write produces a second data event for the new name, and treating it as a trigger would convert twice.

1.2 Scenario results ​

ScenarioPair strategyIdentity strategy
Rename in placea.heic -> a.jpg, intent jpgSame
Rename case onlyfact, no intentfact, no intent
Rename stem and extensionnonenone
Rename across directoriesnonenone
Two renames in one windowe.heic -> e.webp, intent webpSame
Rename over an existing filef.heic -> f.jpgSame
Twenty renames in one gesturetwenty factstwenty facts
Rename then writeh.heic -> h.jpg, onceSame, once
Rename then deletei.heic -> i.jpgnone
Rename while a job's files appearj.heic -> j.jpgSame

1.3 What this establishes ​

  • Identity correlation and name pairing agree on the simple cases, so the fallback is safe
  • Both report the net rename. A double rename is two moves; identity sees only where the file ended, and pairing sees both and collapses them with coalesce. The user's final intent was webp; the intermediate jpg never existed for long enough to convert, and converting it would produce a file nobody asked for
  • Rename then delete yields nothing under identity, which is correct and is worth relying on: the file is gone, so there is nothing to convert. The pair strategy would report a job for a path that no longer exists, and the dispatcher would have to fail it
  • The filters behave: case-only, stem change and cross-directory produce no fact under either strategy, as Watch scope §7 requires
  • Batches are fine: twenty renames in one gesture yield twenty facts, no loss, which the previous debouncer-based design could not promise
  • Data events must be filtered: a write after a rename arrives as an ordinary modify, and it is not a second trigger
  • A new subdirectory's contents may not be in the batch. In the churn scenario inotify reported the created directory but neither file created inside it, so the rename was still named by identity. The watch for a directory created at runtime is added after the event that announced it, and a file created before that happens is not in the stream

1.4 Duplicates ​

Linux reports one rename as From, To and Both, so a batch yields the same fact up to three times. The feeder dedupes by source and target within a batch, and the daemon has to do the same.

1.5 Feed order ​

Discovered by the probe, and the reason it is written down: the identity strategy silently produced nothing until the feed order was fixed.

Within one debounced batch, appearances must be observed before disappearances. A rename reports the old path as gone; feeding that first discards the identity mapping before the new path can claim it, and the rename is lost. The daemon obligation is:

  1. Observe every path in the batch that exists now
  2. Forget every path in the batch that no longer exists

Getting this backwards is invisible on Linux, because inotify also reports both names, and fatal on any platform that does not.

2. Windows (ReadDirectoryChangesW, notify 8) ​

Measured 2026-11-02, transcript docs/dev/measurement/probe/windows-2026-11-02.txt, from the probe that ships. Earlier runs are in the history and none describes the platform: the first predated cross-event pairing, so its empty pairing column described the probe; the second predated chain coalescing and the file id read through a handle, so it showed a double rename as two facts and identity as empty.

2.1 Raw events ​

Modify(Name(From))   a.heic
Modify(Name(To))     a.jpg

No Both event exists on this platform. A rename is always two events, each carrying one path, adjacent when the batch is small.

2.2 Scenario results ​

ScenarioPairIdentityRaw events of note
Rename in placea.heic -> a.jpgsameFrom, To
Rename case onlyfact, no intentfact, no intentRemove(Any) first
Rename stem and extensionnonenoneFrom, To
Rename across directoriesnonenoneRemove(Any), Create(Any), Modify(Any) on the parent
Two renames in one windowe.heic -> e.webpsamefour events; coalesced to one fact
Rename over an existing filef.heic -> f.jpgsameRemove(Any) for the victim first
Twenty renames in one gesturetwenty factstwenty factsforty events, strictly interleaved
Rename then writeh.heic -> h.jpg, oncesametwo Modify(Any) afterwards
Rename then deletei.heic -> i.jpgnoneRemove(Any) afterwards
Rename while a job's files appearj.heic -> j.jpgnonethe created directory and its contents appear; the hard link takes the identity

2.3 What this establishes ​

  • Pairing must work across events, not within one. Correlator::rename_from and rename_to exist for this. Before they did, this platform looked like it reported no renames at all
  • Interleaving is orderly. Twenty renames arrive as alternating From and To pairs, so a single pending slot would happen to work here; matching by directory and oldest first survives either interleaving, and is what the correlator does
  • Chains must be collapsed. A double rename arrives as two facts with an intermediate file the user never asked for. Converting the intermediate would re-encode twice, which is why coalesce exists and why it lives in the library rather than in a caller
  • Moves are not renames on Windows, which satisfies the in-place rule for free. The opposite of Linux, where the same gesture arrives as a rename and is rejected only by the parent check
  • Victims of a rename produce removals, so a removal is not a failure and not a trigger
  • rename then delete produces a fact here, exactly where Linux suppresses it: pairing names both paths while identity has no sighting of the target, because the target is gone. Dispatch therefore has to check that the target still exists and is a regular file, which docs/reference/watch.md §7 requires and this transcript demonstrates. The gesture is rare, but the failure it prevents is not: a deleted name can be recreated within the same window by an unrelated file, and dispatching on the name alone would convert bytes the user never touched
  • File identity comes from a handle. std metadata has no file id on Windows, so the watcher reads the volume and file index through FileIdInfo, with BY_HANDLE_FILE_INFORMATION as the fallback, the same source the journal uses for a produced output. The probe measures it now, and it agrees with pairing wherever the target still exists. The hard-link rule in docs/reference/watch.md §7 is what keeps a link made beside the file from being read as a rename

2.4 Consequences ​

Three rules were written because of this platform, not guessed from it: cross-event pairing, chain coalescing within a batch, and a dispatch-time existence check. Linux needed none of them to work, and Linux could not have revealed any of them.

3. macOS (FSEvents, notify 8) ​

Measured 2026-11-02, transcript docs/dev/measurement/probe/macos-2026-11-02.txt. Paths in the transcript are normalised to <tmp>.

3.1 Raw events ​

Modify(Name(Any))   a.heic
Modify(Data(Content)) a.heic
Modify(Name(Any))   a.jpg

One event per path, never two, and no From, To or Both. A rename is indistinguishable from any other name change except by the paths themselves. Writes appear as Modify(Data(Content)), and a rename is usually followed by Modify(Metadata(Any)), because the system attaches attributes.

3.2 Scenario results ​

ScenarioPairIdentity
Rename in placenonea.heic -> a.jpg
Rename case onlyfact, no intentfact, no intent
Rename stem and extensionnonenone
Rename across directoriesnonenone
Two renames in one windownonee.heic -> e.webp
Rename over an existing filenonef.heic -> f.jpg
Twenty renames in one gesturenonetwenty facts
Rename then writenoneone fact
Rename then deletenonenone
Rename while a job's files appearnonenone, the hard link takes the identity

3.3 What this establishes ​

  • Identity is the only mechanism here. There is no pair of names to match, so rename_from and rename_to are dead code on this platform and the feed-order rule from §1.5 is load bearing rather than defensive
  • No fallback exists. On Linux and Windows a lost pair or a missing identity still leaves the other strategy. On macOS, a file whose identity is unreliable, such as one on a network mount or a dataless iCloud placeholder, is silently not converted. That is one more reason network filesystems are a separate stage rather than an afternoon of work
  • Cross-directory moves are reported as renames and rejected only by the parent check, which is the third distinct platform behaviour for the same gesture: a rename on Linux, delete plus create on Windows, a rename-shaped event here
  • The watched root is reported. Creating a subdirectory inside it delivers metadata events for the root itself, Modify(Metadata(Any)) and sometimes Create(Folder) as well, and the daemon sees the same around the time a job creates its undo record. A directory is not a sighting, so the correlator forgets it; treating it as a file would make a directory whose name carries an extension a conversion, which Sighting::of already refuses
  • Our own link under the watch takes the identity. In the churn scenario the hard link is observed before the user's target, so the identity moves to a name in the subdirectory, where no in-place rename can be reported, and the target is then a second name the identity already has. That is why the daemon drops its state directory from the batch, docs/reference/watch.md §7
  • A rename followed by a delete yields nothing, because identity correlation refuses a fact whose target is gone. The dispatch-time existence check is therefore redundant here and necessary on Windows, which is why it lives in the dispatcher rather than in the correlator
  • Paths must be canonical. FSEvents reports resolved paths, so /var arrives as /private/var. A watch root that is a symlink, or a path that traverses one, would otherwise produce two identities for one file. The daemon canonicalises roots at registration and canonicalises event paths before lookup. This is also why the probe canonicalises its temporary root, or every printed path is absolute

4. BSD (kqueue) ​

Not yet measured, and not before the port. The backend reports a single-path Any event, which makes identity correlation load bearing, as on macOS.

5. Cross-platform summary ​

LinuxWindowsmacOSBSD
Rename as one event with both pathsyes (Both)nonono (expected)
Rename as separate From and Toyes, in additionyesnono (expected)
Cross-directory move reported as renameyesnoyesunknown
Rename followed by delete yields a factnoyesnounknown
Directory paths in the streama created directory onlya created directory, and the target's parent on a movea created directory, and the watched root when one is createdunknown
File identity availableyesyes, from a handleyesyes
Primary strategyidentity, pairing as backuppairing, identity as backupidentity onlyidentity only (expected)
Rules the platform forcedduplicate suppression, feed ordercross-event pairing, chain coalescing, existence checkcanonical paths; no fallback when identity is unavailablepending

No single event shape covers all three, so the correlator accepts a two-path event, a pair of events, and a batch of single-path events with no rename kind at all. The translation lives in one place, crates/retype-watch/src/lib.rs, and is asserted twice: by unit tests over synthetic events for every shape, including shapes the running platform never produces, and by an integration test against a real watcher for the three outcomes that matter, in crates/retype-watch/tests/rename_detection.rs. docs/reference/watch.md §7 records the resulting obligations.