fix(watch): track watches by inode, so a recreated directory is not a corpse
Delete-and-recreate inside one tick killed the watcher and then lost the
recreated contents. `rm -rf src && mkdir src` — or two back-to-back branch
switches — stops the emitter on the delete, but by the time the tick runs the
path exists again, so the old `os.path.isdir(root)` test called it a genuine
death and exited 1. `sync_watches` could not rescue it either, because watches
were keyed by path string: a replacement was indistinguishable from an
untouched directory, so it was neither released nor re-adopted. The crash was
loud; the resulting gap was silent, because the restarted watcher's startup
reconciliation removes stale rows but never adds anything.
Probed on macOS: the emitter is dead 0.2s after the delete, the recreated
directory has a different inode, and files written into it produce no events
at all.
Watches now carry `(st_dev, st_ino, st_birthtime)` captured at schedule time:
- `sync_watches` compares the stored identity against the current stat, so a
replaced directory is released and re-adopted in the same tick, and the
existing dispatch of DirDeleted/DirCreated re-indexes both sides;
- liveness asks whether the dead thread's watch is still the live watch for a
directory that is still the same directory, rather than whether something
with that name exists now;
- the one case identity cannot see — an inode handed straight back on a
platform without `st_birthtime` — is repaired by rescheduling the watch once
per root and re-reading the directory. A second death of the same root is
still reported, and the watcher still exits non-zero, so #811's crash stays
loud.
Also from the same review:
- a directory adopted after startup is planned by `_plan_watch_subtree`, the
same way startup plans the repository, so a module arriving from a branch
switch no longer hands its own `node_modules` and `target` back to the OS;
- promotion to a recursive watch now releases every descendant watch, not just
direct children, so none linger to duplicate events and burn budget slots.
Regression tests: a real `Observer` driven through delete-then-recreate,
asserting both that the watcher survives and that a file written into the
recreated directory reaches the graph. It fails on the previous implementation
with `RuntimeError('watch observer stopped: dead thread(s) Thread-4')`, and
with the exit suppressed it fails instead on "the recreated directory's files
never reached the graph" — the two halves of the same bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fHfGDiZedoxjpKzanHri3 T
Tirth Kanani committed
c4c4d703ae3ff9ec29338bd04ef47f6c2ef6c66b
Parent: db45999