Skip to content

resync silently observes nothing on macOS when any catalog ancestor is a symlink (FSEvents realpath vs carrier key) #384

Description

@schickling-assistant

Summary

On macOS, FSEvents delivers event paths as realpaths. resync::Worker::carriers is
keyed by the carrier path exactly as the declaration produced it. When any ancestor of the
catalog is a symlink, no delivered event ever matches a carrier key, so mark_mutated
matches nothing and the resync watcher silently observes nothing for every agent on that
host.

There is no error, no diagnostic and no degraded-mode signal. The watcher is installed, the
subscriptions are live, st2 reports coverage as immediate — and no resync event is ever
emitted for a real carrier change. It fails exactly the way this module is built not to fail.

This is distinct from #368 (a purge that drops queued events when the registration set
changes, fixed in #383). #368 loses events that were in flight; this loses every event,
permanently, for the whole host.

Where

  • Worker::mark_mutated (src/resync.rs) matches on *carrier == path and
    carrier.starts_with(&path).
  • Worker::watched / refresh_watches register the parent directory as given.
  • notify's FSEvents backend keys its own recursive_info by path.canonicalize(), and the
    paths it hands to the callback come from FSEvents itself, which resolves symlinks.

So notify matches internally and emits, but it emits /private/var/... where st2 stored
/var/..., and the two never meet.

Measurement

The easiest demonstration is the default macOS TMPDIR (/var/folders/..., where /var is
a symlink to private/var). Running
run::tests::compile_invalid_seat_does_not_block_existing_live_resync_watch on
aarch64-darwin with that TMPDIR:

Point TMPDIR at a path with no symlink component (e.g. /private/tmp/...) and the first
assertion passes.

Why CI never caught it

The darwin job is nix build .#st2, and a nix build's TMPDIR on darwin is
/nix/var/nix/builds/nix-<pid>-<n> — measured, and identical to its own realpath. No symlink
component, so the test gets past the first assertion in CI. That is also why #368 presents in
CI as a failure at the second assertion rather than the first: the two defects are
distinguishable by exactly which assertion fails.

Production catalogs under a plain $HOME path are usually unaffected for the same reason,
but nothing guarantees it — a catalog reached through any symlinked ancestor (a symlinked
home, an external volume via /Volumes, a bind-style symlink into a repo checkout) silently
loses resync on macOS.

Note for whoever picks this up

Do not fix this by following symlinks. src/resync.rs is deliberately built the other
way: notify::Config::default().with_follow_symlinks(false), O_NOFOLLOW in read_regular,
and per-component O_NOFOLLOW in read_confined against a containment root. The carrier a
symlink points at is intentionally not the carrier st2 reads.

The fix is about the event-matching key, not the read policy — the delivered path needs to
be reconciled with the stored key without changing which bytes get digested, and whatever
canonical form is chosen has to stay consistent with containment_root confinement and with
the dir_identity (dev, ino) staleness check. Worth settling deliberately rather than
patching mark_mutated in isolation.

Related: #368, #383.

Posted on behalf of @schickling
field value
agent_identity dev3.direct.claude.paqjmjfq
session dev3.paqjmjfq
agent_persona generalist
agent_supervisor unavailable
agent_tool Claude Code
agent_tool_version 2.1.250
agent_runtime Claude Code 2.1.250
tooling_profile dotfiles@a1a5f89

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions