fix(desktop): retry a save whose rename an SMB share refused (#5322) by Jocs · Pull Request #5520 · marktext/marktext · GitHub
Skip to content

fix(desktop): retry a save whose rename an SMB share refused (#5322) - #5520

Merged
Jocs merged 3 commits into
developfrom
fix/5322-smb-save-retry
Sep 23, 2026
Merged

Jocs merged 3 commits into
developfrom
fix/5322-smb-save-retry

Conversation

@Jocs

@Jocs Jocs commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Closes #5322

Summary

Saving a document opened from a Windows network share fails at the atomic save's rename with EPERM: the temp file is written, the rename over the document is refused, and the edit is lost.

fs.rename compiles to MoveFileEx(REPLACE_EXISTING), which Windows refuses with ACCESS_DENIED (libuv surfaces it as EPERM) whenever any handle is open on the target. MarkText's own file watcher stats the open document, so a save races its own watcher.

This backs off and retries the save three times (50/150/300 ms) when the failure is one a retry can clear (EPERM/EACCES/EBUSY). Retrying re-runs the whole atomic write, which is safe — write-file-atomic removes its temp file on failure.

What actually decides whether it fails

Measured on windows-latest, against one folder reached two ways — by its local path and through a loopback SMB share:

probe refused renames
local path, one open read handle held 50/50
SMB path, one open read handle held 50/50
local path, no handle 0/50
SMB path, stat storm at 1 ms (~12 000 stats) 102/300
SMB path, watcher polling as the app configures it (usePolling: true) 1/200
SMB path, usePolling: false — i.e. rc.1's behaviour 1/200
SMB path, no handle 0/50

And with MoveFileEx called directly, controlling the share mode the holder asks for:

LOCAL  no handle held                  moved=True  err=0
LOCAL  held, share=RW+DELETE           moved=False err=5
LOCAL  held, share=RW (no DELETE)      moved=False err=5
SMB    no handle held                  moved=True  err=0
SMB    held, share=RW+DELETE           moved=False err=5
SMB    held, share=RW (no DELETE)      moved=False err=5

So the refusal is not specific to SMB, and does not depend on FILE_SHARE_DELETE: an open handle blocks the replace on local NTFS just the same. What separates the two is how long the handle stays open — a stat costs microseconds locally but a network round trip over SMB, so only on a share does the watcher's polling keep landing inside the rename. That also explains the reporter's "every save fails" against my 1/200 on a loopback share: a real LAN round trip is far slower again, so the window is proportionally wider.

The same probes run against Samba from a Linux client never fail, because POSIX rename semantics allow replacing an open file at all.

The root cause is not #4778

The issue assumed the regression came from #4778 forcing usePolling for UNC paths. usePolling: false fails too, at the same rate — polling only raises the stat rate, and the failure rate tracks that rate. It amplifies the race; it does not cause it.

The actual regression is 0.19 → 0.20:

Two consequences worth stating: reverting #4778 would only push the failure back below the reporter's threshold rather than fix it, and the blast radius is wider than UNC — a network drive mapped to a letter (Z:\) is not a UNC path, so it is never force-polled, yet it fails the same way.

Verification

Three retries take the 1 ms-stat-storm failure rate from 102/300 to 3/300; at the rate the app actually stats, the remaining window is far smaller again. A process that holds a handle open continuously (an antivirus scanner, a sync client) is out of reach of any retry — and, per the local-path row above, that applies on a local disk too. Those saves still report mt::tab-save-failure and the tab stays dirty, so the edit is never silently lost.

The measurements came from a throwaway harness on a temporary branch (loopback New-SmbShare on windows-latest, Samba on ubuntu-latest, and a MoveFileEx P/Invoke probe); it is not part of this PR.

Type of change

  • Bug fix (non-breaking, fixes an issue)
  • New feature (non-breaking, adds functionality)
  • Breaking change (causes existing functionality to change)
  • Documentation update

Test plan

  • packages/desktop/test/unit/specs/write-file-smb-retry.spec.ts — retries a refused rename and saves; gives up bounded; does not retry a failure retrying cannot clear (ENOSPC).
  • Existing write-file-atomic.spec.ts / write-file-missing-dir.spec.ts still pass.
  • Measured before/after on Windows, numbers above.

🤖 Generated with Claude Code

On a Windows SMB share, MoveFileEx(REPLACE_EXISTING) — what fs.rename
compiles to, and the last step of the atomic save — is refused with
ACCESS_DENIED, surfaced as EPERM, while any handle is open on the target.
MarkText's own file watcher stats the open document, so a save races its
own watcher: the temp file is written, the rename over the document is
rejected, and the edit is lost.

Measured on a real Windows SMB share (loopback share, windows-latest):

  one open read handle held across every save   50/50 renames refused
  watcher stat storm at 1ms (~12000 stats)     102/300 refused
  watcher polling as the app configures it       1/200 refused
  no handle open at all                          0/50  refused

The same probes against Samba from a Linux client never fail, so this is
the Windows SMB redirector's share-mode check, not a server-side one.

This is a 0.19 -> 0.20 regression, and not from #4778 as the issue
assumed. 0.19.x wrote the document in place with fs-extra's outputFile
and never renamed, so it could not hit this. #4852 introduced the
temp+rename atomic save (for #3786/#3828), which is what races; #4778
then forced usePolling for UNC paths, which raised the stat rate enough
to turn an occasional lost save into a reliable one. Reverting #4778
would only push the failure back below the reporter's notice — it would
not fix it, and it would leave mapped network drives (Z:\, not a UNC
path, so never force-polled) failing occasionally either way.

The handle is released within milliseconds, so back off and try the save
again instead of dropping the edit. Retrying re-runs the whole atomic
write, which is safe: write-file-atomic cleans up its temp file on
failure. Three retries at 50/150/300ms take the 1ms-stat-storm failure
rate from 102/300 to 3/300; at the app's real stat rate the remaining
window is far smaller still. A process that holds a handle open
continuously (an antivirus scanner, a sync client) is out of reach of
any retry — that save still reports mt::tab-save-failure and the tab
stays dirty, rather than silently losing the edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Build artifacts for PR #5520:

Run: https://github.com/marktext/marktext/actions/runs/35808544485

Artifact Size Link
marktext-windows-arm64 280.1 MB Download
marktext-macos-x64 298.5 MB Download
marktext-macos-arm64 288.2 MB Download
marktext-windows-x64 288.2 MB Download
marktext-linux 640.4 MB Download

Jocs and others added 2 commits September 23, 2026 09:49
A follow-up probe showed the earlier explanation was wrong on two counts:
MoveFileEx(REPLACE_EXISTING) is refused for an open target on local NTFS
just as on a share (50/50 either way), and asking for FILE_SHARE_DELETE
makes no difference (err=5 with and without it). What separates the two
is how long the handle stays open - microseconds for a local stat, a
network round trip for one over SMB - so only shares lose the race in
practice. The retry itself is unchanged; only the reasoning it records.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The watcher stats on a fixed period, so a retry only helps if its
cumulative offset lands on a phase the stat is not holding. 50/150/300
accumulates to 50/200/500 - the last two are multiples of chokidar's
100ms interval, i.e. exactly the phase that just failed, so only the
first retry was ever doing anything. Simulated against both periods the
app uses (100ms watchFile, 150ms awaitWriteFinish), that loses 33/57/75%
of first failures once a stat holds the file for 60/70/80ms.

50/75/50 accumulates to 50/125/175 - three distinct phases - and clears
every first failure up to a ~70ms stat, while finishing in 175ms instead
of 500ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs merged commit 548f6d9 into develop Sep 23, 2026
11 checks passed
@Jocs
Jocs deleted the fix/5322-smb-save-retry branch September 23, 2026 02:14
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.

[Bug] 0.20.0-rc.2: saving files on an SMB/UNC path fails with EPERM (regression from #4778)

1 participant