Repository navigation
Continuous dotfiles watcher can publish false deletions through a shared scratch-index race #13194
|
Sorry—this report is being written by an AI agent helping me investigate and recover from a dotfiles synchronization failure. The observations below include a reproduction using an unmodified mise release. SummaryThe continuous dotfiles watcher can record files as deleted even though they have not been deleted on disk. Those false deletions can then be published and applied on another machine, causing actual data loss there. I encountered this between two macOS machines and reproduced the same pattern with stock mise 2026.9.7, using isolated temporary homes and a local Git origin. Suspected cause: concurrent snapshot and synchronization operations reuse the same PID-based scratch Git index. Environment
The notebook’s stale watcher is relevant context, but the reproduction also failed on 2026.9.7, so restarting that process alone would not resolve this problem. What happenedOn September 14, 2026:
When the notebook became reachable, all 208 files were still present there, with creation and modification timestamps predating the incident. After recovery, all affected files on both machines match their original Git blobs. This points to files being lost from the saved snapshot, rather than an ordinary filesystem deletion being synchronized. Isolated reproductionThe reproduction used:
Relevant configuration: [settings]
experimental = true
history.sync = "sync"
history.sync_interval = "1s"
history.fetch_interval = "1s"
history.watch.debounce = "100ms"
history.watch.reconcile = "2s"
history.describe_command = "printf watcher"
[dotfiles]
"~/.config/example" = { mode = "track" }
"~/.config/mise" = { mode = "track" }After saving and publishing the initial files, the second home adopted the local origin. The first home ran: mise bootstrap dotfiles watch --jsonThe test repeatedly changed its heartbeat file while the second home changed the other heartbeat and ran: mise bootstrap dotfiles syncThe test never removed any sentinel files. Results
The continuous watcher also emitted: Suspected cause: shared scratch indexIn let index = self
.dir()
.join(format!("mise-index-{}-compose", std::process::id()));
let _ = std::fs::remove_file(&index);The function performs multiple Git operations against that index, then removes it again on completion. Meanwhile, the continuous watcher starts synchronization on a background task, while capture can continue within the same process. A PID-based filename separates different processes, but not concurrent composition calls within one process. If another call resets or removes the index partway through snapshot construction, the resulting tree can lose files already inserted while retaining later files. That matches the sorted-prefix omissions observed in both the real incident and the isolated reproduction. The historical incident was not instrumented at the individual Git-operation level, so that precise interleaving is inferred from the implementation and reproduction. Expected behavior
Could each composition invocation use its own scratch index, or otherwise serialize access across all operations sharing that index? A regression test involving concurrent capture and synchronization against a second writer would help cover this. Current workaroundBoth machines retain: [settings.history]
sync = "sync"The continuous macOS watcher has been replaced with a native launchd timer running: mise bootstrap dotfiles watch --once --jsonThe timer runs at login and every 60 seconds, without overlapping instances. In one-shot mode, capture completes before synchronization/application, and description generation is awaited afterwards. This retains automatic synchronization with approximately one-minute capture latency. No patched mise executable is involved. Related reports checked
These address related locking and stale-process problems, but I did not find an existing report covering concurrent reuse of the composition scratch index and the resulting false deletions. |
Replies: 1 comment
|
Confirmed, and your diagnosis was exactly right. Thank you — the sorted-prefix detail is what made this findable, and the isolated reproduction against a stock release saved a lot of guessing. Fix in #13195. What was happening
I turned this into a unit test that composes distinct snapshots from three threads at once. Against the unreleased code it fails on every run, in both forms: The second form is the same collision caught by git's own index lock, which is why you also saw the reconcile warnings. The first is the silent one that cost you files. The fixEach composition now gets a scratch index of its own, removed when the call ends, plus a sweep of indexes left behind by an earlier process holding the same process id. To your question — I went with a private index per invocation rather than serializing, because synchronization deliberately does not block saving and should keep not blocking it. Making composition reentrant fixes it at the right layer. Your dataWorth saying explicitly: the blobs were never lost. Only one snapshot omitted them, so every affected file is recoverable from an earlier checkpoint on either machine — which matches what you found when you recovered and the contents matched their original blobs. Your launchd workaround is sound in the meantime. AI-assisted — Tool: Claude Code; model: anthropic/claude-opus-5; version: unavailable. |
Confirmed, and your diagnosis was exactly right. Thank you — the sorted-prefix detail is what made this findable, and the isolated reproduction against a stock release saved a lot of guessing.
Fix in #13195.
What was happening
HistoryRepo::compose()named its scratch index after the process id alone, and the watcher runs two compositions in one process: it saves checkpoints on its own thread while the synchronization it started plans on a blocking task. Those took different locks — capture the operation lock, sync the sync lock — so neither excluded the other, and both composed into the same file.compose()resets the index and then inserts overlays one git invocation at a time. When the …