sotashimozono_obsidian-remo.../server/internal/watcher
Souta ef58679b6e feat(server): inject synthetic Modified after atomic-rename writes (closes #108, 0.4.77)
Closes the Linux fsnotify race that drops IN_MOVED_TO events for
atomic-rename writes (`tmp + rename` shape) when the watcher has
been alive across an earlier write to the same parent directory.
Per-issue fix B.

Symptom (before this fix)
-------------------------

A second client subscribed to fs.changed (recursive root) misses
`modified` notifications for files modified by the daemon's own
fs.write / fs.writeBinary / fs.copy handlers. Surfaced by the
M6 perf bench's `modify` cell and the M9 sync-reflect E2E case
(both currently `it.skip`'d).

Fix B
-----

After atomicWriteFile succeeds, if the destination existed before
the write, the handler emits a synthetic `Modified` event into the
watcher. Subscribers cannot tell injected events apart from real
fsnotify-derived ones — that's the contract.

Implementation
--------------

server/internal/watcher/watcher.go
  - New public method Watcher.Inject(event Event) — forwards to the
    existing private emit() helper that all real fsnotify events
    flow through.

server/internal/handlers/common.go
  - atomicWriteFile signature widened from (int64, *rpc.Error) to
    (int64, bool, *rpc.Error). The new bool is wasModify (true when
    the destination existed before the write, false on fresh create).
  - Probe is one upfront os.Stat that doubles as the existing
    expectedMtime precondition check.

server/internal/handlers/fs_write.go, fs_write_binary.go, fs_copy.go
  - Constructor signatures take an optional onModify func(relPath
    string) callback. When wasModify && onModify != nil, fires with
    the vault-relative path.

server/cmd/obsidian-remote-server/main.go
  - Wires injectModify closure that calls vaultWatcher.Inject({
    Path: relPath, Type: watcher.EventModified }) and passes it
    into all three atomic-rename handlers.

server/internal/handlers/fs_write_test.go
  - 4 unit tests covering the synthetic-emit contract for FsWrite /
    FsWriteBinary / FsCopy (overwrite vs fresh-create).
  - 1 watcher-integrated test that constructs a real watcher.Watcher,
    subscribes root-recursive, wires the production-shape inject
    closure, and waits up to 500ms for the Modified event to be
    dispatched. Mirrors what main.go does at runtime.

Test infrastructure
-------------------

Existing test fixtures (server_test.go, correlation_test.go,
fs_mutate_test.go) updated to pass nil for the new onModify param —
the synthetic-emit contract is covered by the dedicated tests above.

Verification
------------

- go test ./... — all packages pass (~9s)
- npm run lint — 0 errors, 0 warnings
- npx tsc --noEmit — clean
- npx vitest run — 579/579 pass

Out of scope (deferred, per issue spec)
---------------------------------------

- M9 sync.e2e.test.ts has no `it.skip('modify')` to flip back; the
  modify case isn't even present in the M9 MVP slice. A follow-up
  PR can add it explicitly.
- M6 perf.sync.bench.test.ts OPS array — bench was rated lower
  priority; doesn't block this fix.

What this doesn't fix
---------------------

- An out-of-band writer (user typing on the remote shell) still
  relies on fsnotify and isn't routed through atomicWriteFile.
  That's a niche case; left for a follow-up if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 21:31:11 +09:00
..
watcher.go feat(server): inject synthetic Modified after atomic-rename writes (closes #108, 0.4.77) 2026-04-29 21:31:11 +09:00
watcher_test.go fix(server): close fsnotify auto-watch race for os.MkdirAll (closes #107, 0.4.73) 2026-04-29 15:31:52 +09:00