Skip to content

Address issue #371: bypass atomic safe-save on remote volumes#502

Merged
schuyler merged 2 commits into
mainfrom
claude/issue-371-rnt7gi
Jul 1, 2026
Merged

Address issue #371: bypass atomic safe-save on remote volumes#502
schuyler merged 2 commits into
mainfrom
claude/issue-371-rnt7gi

Conversation

@schuyler

@schuyler schuyler commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

Follow-up fix for issue #371. The original "spinning out of control" bug (mount collapse during vnode watching on remote/FUSE volumes) was already fixed in a prior PR by having MPFileWatcher skip watching non-local volumes. After validating that fix in an RC build, the reporter found a remaining problem: saving a markdown file on a remote/FUSE-mounted volume (e.g. SSHFS) triggers a spurious "The document could not be saved. The file has been changed by another application." conflict dialog, and choosing "Save Anyway" then hard-fails with "The file couldn't be saved in the folder 'tmp'." "Save As" also shows the "volume does not support permanent version storage" prompt.

Root cause: NSDocument's default "safe save" (writeSafelyToURL:ofType:forSaveOperation:error:) writes to a temp file and swaps it into place via NSFileCoordinator, plus checks the destination's on-disk modification date for conflicts. Both are unreliable on FUSE/network volumes — mtime semantics are inconsistent (false conflict positives), and the temp-file swap can fail outright.

This PR:

  • Extracts the existing NSURLVolumeIsLocalKey-based locality probe out of MPFileWatcher's +canWatchPath: into a new, reusable + (BOOL)pathIsOnLocalVolume:(NSString *)path class method.
  • Overrides MPDocument's writeSafelyToURL:ofType:forSaveOperation:error: to bypass NSDocument's atomic safe-save for non-local destinations (checked against the save destination, not the document's current URL, so cross-volume Save As is classified correctly) and write directly instead via the existing writeToURL:ofType:forSaveOperation:originalContentsURL:error:writeToURL:ofType:error: path. This should also stop the "volume does not support permanent version storage" prompt, since it's raised from the same call chain being bypassed.
  • Adds a volumeLocalityChecker injection seam on MPDocument so the actual bypass-decision logic can be exercised in CI without a real network mount.
  • Local-volume saves are completely unaffected (fall through to [super writeSafelyToURL:...] unchanged).

Files Changed

  • MacDown/Code/Utility/MPFileWatcher.h / .m — new +pathIsOnLocalVolume: class method.
  • MacDown/Code/Document/MPDocument.m — new writeSafelyToURL:ofType:forSaveOperation:error: override, shouldBypassSafeSaveForURL: helper, volumeLocalityChecker injection seam.
  • MacDownTests/MPFileWatcherTests.m — tests for pathIsOnLocalVolume:.
  • MacDownTests/MPDocumentIOTests.m — tests for the save-path bypass decision (including the true bypass branch, via the injection seam).
  • CHANGELOG.md — Unreleased/Fixed entry.

Related Issue

Related to #371

Manual Testing Plan

A real FUSE/network mount isn't available in CI, so the following should be verified manually before/while validating this fix:

  1. Setup: Mount a network/FUSE volume to reproduce the original bug conditions — SSHFS via macFUSE (brew install --cask macfuse && brew install sshfs), or an SMB/AFP share if macFUSE's system-extension approval is impractical (the fix uses a generic "is local volume" check, not SSHFS-specific detection, so SMB/AFP should behave the same way).
  2. Remote-volume fix:
    • Open and edit a .md file on the mounted volume, save repeatedly (Cmd+S). Expected: no "changed by another application" dialog, no "couldn't be saved in folder tmp" error.
    • "Save As" a new document to the mounted volume. Expected: no "volume does not support permanent version storage" prompt.
  3. Local-volume regression:
    • Normal open/edit/save on a local file: behavior unchanged, Versions/autosave still work (check via Finder "Browse All Versions").
    • Genuine external-modification conflict on a local file (edit externally via Terminal while open in MacDown, then save): the "changed by another application" dialog should still appear — confirms the bypass is correctly scoped to non-local volumes only.
  4. Save As across volumes: local → remote and remote → local, confirming each takes the correct path (direct write vs. atomic safe-save) based on the destination.
  5. Autosave-in-place: confirm the fix holds when autosave triggers the save, not just manual Cmd+S.

Review Notes

  • Reviewed by an architectural consult before implementation (confirmed writeSafelyToURL:ofType:forSaveOperation:error: is the correct override point, and that a locality check on the save destination — not self.fileURL — is required for correct Save As handling).
  • Reviewed by a code-review pass after initial implementation; feedback addressed by adding the volumeLocalityChecker injection seam (so the actual bypass branch has real test coverage, not just the local/no-op branches) and softening comments/changelog wording around the versioning-prompt suppression, which is inferred from the code path rather than directly observed (no real network mount available in CI).
  • A documentation pass found nothing in plans/ that needed updating as a result of this change.

Generated by Claude Code

claude added 2 commits July 1, 2026 19:04
NSDocument's default "safe save" (write to a temp file, then swap it
in, plus an mtime-based conflict check) is unreliable on FUSE/network
volumes such as SSHFS: the temp-file swap can fail outright, and mtime
inconsistencies cause a spurious "changed by another application"
conflict dialog even when nothing else touched the file. Choosing
"Save Anyway" then hard-fails with "couldn't be saved in folder tmp".

For destinations on non-local volumes, write directly instead of
going through NSDocument's atomic safe-save path. This also stops the
"volume does not support permanent version storage" prompt, since it's
raised from within the same path being bypassed. Local-volume saves
are unaffected.

Extracted the existing NSURLVolumeIsLocalKey probing logic out of
MPFileWatcher's canWatchPath: into a new pathIsOnLocalVolume: class
method so MPDocument can reuse it for the save-path decision.

Related to #371
Add a volumeLocalityChecker injection seam on MPDocument so tests can
simulate a non-local save destination without a real network mount,
letting the actual bypass branch (not just the local/no-op branches)
be exercised in CI. Soften the versioning-prompt-suppression comment
and changelog wording to note it's inferred from the call chain rather
than directly observed, and note the deliberate trade-off of skipping
NSFileCoordinator-mediated write coordination for non-local volumes.

Related to #371
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Report

Current Coverage: 63.25%

Coverage Details (Summary)
Name                                                                                                                                   Coverage             
-------------------------------------------------------------------------------------------------------------------------------------- -------------------- 
MASPreferences.bundle                                                                                                                  0.00% (0/0)          
MacDown 3000.app                                                                                                                       63.99% (9400/14690)  
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Extension/NSColor+HTML.m                                                   94.05% (332/353)     
        +[NSColor(HTML) colorWithHexString:]                                                                                           94.12% (16/17)       
        +[NSColor(HTML) colorWithHTMLName:]                                                                                            89.13% (164/184)     
        __35+[NSColor(HTML) colorWithHTMLName:]_block_invoke                                                                           100.00% (152/152)    
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Preferences/MPHtmlPreferencesViewController.m                              0.00% (0/113)        
        MPPrismDefaultThemeName                                                                                                        0.00% (0/2)          
        -[MPHtmlPreferencesViewController viewIdentifier]                                                                              0.00% (0/3)          
        -[MPHtmlPreferencesViewController toolbarItemImage]                                                                            0.00% (0/3)          
        -[MPHtmlPreferencesViewController toolbarItemLabel]                                                                            0.00% (0/2)          
        -[MPHtmlPreferencesViewController viewWillAppear]                                                                              0.00% (0/4)          
        -[MPHtmlPreferencesViewController changeStylesheet:]                                                                           0.00% (0/7)          
        -[MPHtmlPreferencesViewController changeHighlightingTheme:]                                                                    0.00% (0/7)          
        -[MPHtmlPreferencesViewController invokeStylesheetFunction:]                                                                   0.00% (0/23)         
        -[MPHtmlPreferencesViewController invokeHighlightingThemeFunction:]                                                            0.00% (0/33)         
        -[MPHtmlPreferencesViewController loadStylesheets]                                                                             0.00% (0/14)         
        -[MPHtmlPreferencesViewController loadHighlightingThemes]                                                                      0.00% (0/15)         
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Utility/FileURLInlining.m                                                  26.60% (25/94)       
        +[FileURLInlining inlineFromIterable:]                                                                                         0.00% (0/11)         
        -[FileURLInlining initWithURL:]                                                                                                0.00% (0/6)          
        -[FileURLInlining init]                                                                                                        100.00% (3/3)        
        -[FileURLInlining inlineContent]                                                                                               0.00% (0/9)          
        -[FileURLInlining imageContent]                                                                                                0.00% (0/12)         
        +[FileURLInlining mimeTypeForFilePath:]                                                                                        100.00% (22/22)      
        -[FileURLInlining clippingContent]                                                                                             0.00% (0/4)          
        +[FileURLInlining isTextClippingAtPath:]                                                                                       0.00% (0/12)         
        +[FileURLInlining textContentFromClipping:]                                                                                    0.00% (0/15)         
    /Users/runner/work/macdown3000/macdown3000/Dependency/peg-markdown-highlight/HGMarkdownHighlightingStyle.m                         89.23% (58/65)       
        +[HGMarkdownHighlightingStyle colorFromARGBColor:]                                                                             100.00% (6/6)        
        -[HGMarkdownHighlightingStyle initWithType:attributesToAdd:toRemove:fontTraitsToAdd:]                                          88.89% (8/9)         
        -[HGMarkdownHighlightingStyle initWithStyleAttributes:baseFont:]                                                               88.00% (44/50)       
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Utility/MPResourceWatcherSet.m                                             93.75% (90/96)       
        -[MPResourceWatcherSet init]                                                                                                   100.00% (6/6)        
        -[MPResourceWatcherSet dealloc]                                                                                                0.00% (0/3)          
        -[MPResourceWatcherSet watchedPaths]                                                                                           100.00% (3/3)        
        -[MPResourceWatcherSet updateWatchedPaths:]                                                                                    100.00% (16/16)      
        -[MPResourceWatcherSet addWatcherForPath:]                                                                                     100.00% (34/34)      
        __42-[MPResourceWatcherSet addWatcherForPath:]_block_invoke                                                                    100.00% (6/6)        
        __42-[MPResourceWatcherSet addWatcherForPath:]_block_invoke.39                                                                 93.33% (14/15)       
        __42-[MPResourceWatcherSet addWatcherForPath:]_block_invoke_2                                                                  75.00% (6/8)         
        -[MPResourceWatcherSet stopAll]                                                                                                100.00% (5/5)        
    /Users/runner/work/macdown3000/macdown3000/MacDown/Code/Extension/hoedown_html_patch.c                                             91.39% (191/209)     
        hoedown_patch_reset_checkbox_index                                                                                             100.00% (3/3)        
        new_growable_buffer                                                                                                            100.00% (3/3)        
        hoedown_patch_get_checkbox_index                                                                                               0.00% (0/3)          
        hoedown_patch_render_blockcode                                                                                                 89.09% (49/55)       
        hoedown_patch_render_listitem                                                                                                  100.00% (45/45)      

... (3471 more lines truncated)

📊 **Full coverage report available in workflow artifacts**

@schuyler schuyler merged commit acd0d7f into main Jul 1, 2026
10 checks passed
@schuyler schuyler deleted the claude/issue-371-rnt7gi branch July 1, 2026 19:23
schuyler added a commit that referenced this pull request Jul 1, 2026
## Summary

Follow-up fix for issue #371. The original "spinning out of control" bug
(mount collapse during vnode watching on remote/FUSE volumes) was
already fixed in a prior PR by having `MPFileWatcher` skip watching
non-local volumes. After validating that fix in an RC build, the
reporter found a remaining problem: saving a markdown file on a
remote/FUSE-mounted volume (e.g. SSHFS) triggers a spurious "The
document could not be saved. The file has been changed by another
application." conflict dialog, and choosing "Save Anyway" then
hard-fails with "The file couldn't be saved in the folder 'tmp'." "Save
As" also shows the "volume does not support permanent version storage"
prompt.

Root cause: `NSDocument`'s default "safe save"
(`writeSafelyToURL:ofType:forSaveOperation:error:`) writes to a temp
file and swaps it into place via `NSFileCoordinator`, plus checks the
destination's on-disk modification date for conflicts. Both are
unreliable on FUSE/network volumes — mtime semantics are inconsistent
(false conflict positives), and the temp-file swap can fail outright.

This PR:
- Extracts the existing `NSURLVolumeIsLocalKey`-based locality probe out
of `MPFileWatcher`'s `+canWatchPath:` into a new, reusable `+
(BOOL)pathIsOnLocalVolume:(NSString *)path` class method.
- Overrides `MPDocument`'s
`writeSafelyToURL:ofType:forSaveOperation:error:` to bypass NSDocument's
atomic safe-save for non-local destinations (checked against the save
destination, not the document's current URL, so cross-volume Save As is
classified correctly) and write directly instead via the existing
`writeToURL:ofType:forSaveOperation:originalContentsURL:error:` →
`writeToURL:ofType:error:` path. This should also stop the "volume does
not support permanent version storage" prompt, since it's raised from
the same call chain being bypassed.
- Adds a `volumeLocalityChecker` injection seam on `MPDocument` so the
actual bypass-decision logic can be exercised in CI without a real
network mount.
- Local-volume saves are completely unaffected (fall through to `[super
writeSafelyToURL:...]` unchanged).

## Files Changed

- `MacDown/Code/Utility/MPFileWatcher.h` / `.m` — new
`+pathIsOnLocalVolume:` class method.
- `MacDown/Code/Document/MPDocument.m` — new
`writeSafelyToURL:ofType:forSaveOperation:error:` override,
`shouldBypassSafeSaveForURL:` helper, `volumeLocalityChecker` injection
seam.
- `MacDownTests/MPFileWatcherTests.m` — tests for
`pathIsOnLocalVolume:`.
- `MacDownTests/MPDocumentIOTests.m` — tests for the save-path bypass
decision (including the true bypass branch, via the injection seam).
- `CHANGELOG.md` — Unreleased/Fixed entry.

## Related Issue

Related to #371

## Manual Testing Plan

A real FUSE/network mount isn't available in CI, so the following should
be verified manually before/while validating this fix:

1. **Setup:** Mount a network/FUSE volume to reproduce the original bug
conditions — SSHFS via macFUSE (`brew install --cask macfuse && brew
install sshfs`), or an SMB/AFP share if macFUSE's system-extension
approval is impractical (the fix uses a generic "is local volume" check,
not SSHFS-specific detection, so SMB/AFP should behave the same way).
2. **Remote-volume fix:**
- Open and edit a `.md` file on the mounted volume, save repeatedly
(Cmd+S). Expected: no "changed by another application" dialog, no
"couldn't be saved in folder tmp" error.
- "Save As" a new document to the mounted volume. Expected: no "volume
does not support permanent version storage" prompt.
3. **Local-volume regression:**
- Normal open/edit/save on a local file: behavior unchanged,
Versions/autosave still work (check via Finder "Browse All Versions").
- Genuine external-modification conflict on a **local** file (edit
externally via Terminal while open in MacDown, then save): the "changed
by another application" dialog should still appear — confirms the bypass
is correctly scoped to non-local volumes only.
4. **Save As across volumes:** local → remote and remote → local,
confirming each takes the correct path (direct write vs. atomic
safe-save) based on the destination.
5. **Autosave-in-place:** confirm the fix holds when autosave triggers
the save, not just manual Cmd+S.

## Review Notes

- Reviewed by an architectural consult before implementation (confirmed
`writeSafelyToURL:ofType:forSaveOperation:error:` is the correct
override point, and that a locality check on the save destination — not
`self.fileURL` — is required for correct Save As handling).
- Reviewed by a code-review pass after initial implementation; feedback
addressed by adding the `volumeLocalityChecker` injection seam (so the
actual bypass branch has real test coverage, not just the local/no-op
branches) and softening comments/changelog wording around the
versioning-prompt suppression, which is inferred from the code path
rather than directly observed (no real network mount available in CI).
- A documentation pass found nothing in `plans/` that needed updating as
a result of this change.


---
_Generated by [Claude
Code](https://claude.ai/code/session_01PxTBTWM3vQyy5bW8zu8YaG)_

---------

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants