fix(save): delete the temp file the rename left behind, and the ones earlier runs left - #725
Open
PathGao wants to merge 2 commits into
Open
fix(save): delete the temp file the rename left behind, and the ones earlier runs left#725PathGao wants to merge 2 commits into
PathGao wants to merge 2 commits into
Conversation
…earlier runs left A save writes a sibling temp file, fsyncs it and renames it over the document. On the machine in #722 a 0-byte `.doc.md.markpad-tmp-…` is left beside the document by every save, and the save itself SUCCEEDS: no error reaches the app, the tab's modified dot clears, and the document holds the new text. So the rename returned success and the source name is still in the directory afterwards — which is not a thing the code can cause, and not a thing that happens on a clean machine or in the reporter's VM. What is different there is an endpoint agent (亚信安全 TrustOne) sitting in the filesystem; auto-save is not involved, since turning it off changes nothing. Three changes, none of which needs to know what the agent is doing: The rename is followed by a check for the file it should have consumed, and removes it if it is there. On every other machine this is one `exists()` on a path that is gone, and it is the only place that knows the name at the moment it appears. The temp file's handle is now closed before the rename and before any cleanup, rather than at the end of the function. `MoveFileExW` and `DeleteFileW` fail with a sharing violation while a handle without `FILE_SHARE_DELETE` is open, and ours joined whatever a scanner was already holding on a file created an instruction ago; a delete that does get through with a handle open only marks the file delete-pending, so it stays in the directory meanwhile. The next save of a document also sweeps leftovers for that document that no live write could own: nothing under a minute old, then anything empty, and anything older than an hour. Empty and not-empty are separated because a temp file with contents can be the only copy of a document, from a process that died between the fsync and the rename, while an empty one can never be anything but garbage. Cleanup failures are also folded into the error the save returns instead of being dropped by `let _ =`, so the next report of this comes with the reason attached. ref #722
PathGao
force-pushed
the
fix/abandoned-temp-files
branch
from
August 27, 2026 09:27
2102838 to
96d37ae
Compare
… startup The sweep a save performs reaches the folder of a document someone is still editing, and no other. A document that accumulated temp files and is then left alone keeps them for good — and the machine in #722 produces one per save, so a document put aside after an afternoon's work is exactly where a pile of them ends up. Startup asks for the documents Markpad already knows about: the recent list, and whatever the session restored. That is at most nine folders plus the open tabs, one `read_dir` each, off the main thread, with nothing on screen waiting for the result. The policy is the one the per-save sweep uses and it stays in one function, so the two cannot come to disagree about what a leftover is. Every window calls it and only the first pays: the sweep is once per document per run and Markpad's windows share a process. Not gated on the version upgrade. A marker file that says "cleaned once, at 2.7.7" is state to keep, to migrate and to get wrong, and what it would buy is skipping a `read_dir` on nine folders at startup. ref #722
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
ref #722— @ProudRabbit gets one 0-byte.验证结果.md.markpad-tmp-<pid>-<nanos>-<seq>beside their document per save, on a Windows 10 work machine, across sessions. This deletes the file at the moment it appears, and cleans up what earlier runs left. It does not explain what the machine is doing, and cannot: see Mechanism.Mechanism
atomic_writecreates a sibling temp file, writes and fsyncs it, then renames it over the target. My first reading of the report was that a leftover means the write or the rename failed and the cleanup failed too. The reporter's answers rule that out:save_file_contentreturnedOk— a rejected invoke leaves the dot and raises a toast. The rename therefore succeeded.A rename that reports success and leaves the source name in the directory is not a state this code can produce. Whatever the agent does around
MoveFileExW— scanning the source, shadowing it, deferring its removal — is where the file comes from, and nothing in this repository can see it. So this PR does not try to; it takes the file away instead, at the two moments Markpad is guaranteed to know the name.What changed
exists()on a path that is normally already gone, and the only place that sees a leftover at the moment it appears rather than an hour later. Best-effort, and deliberately not folded into the save's error: the write has landed and the document is correct, so a save must not start failing over a file that should not be there.drop(file)), rather than at the end of the function.MoveFileExWandDeleteFileWfail with a sharing violation while a handle withoutFILE_SHARE_DELETEis open on the file, and ours joined whatever a scanner was already holding on a file created an instruction ago; aDeleteFileWthat does get through with a handle open only marks the file delete-pending, leaving it visible meanwhile. Not implicated by the evidence above, since nothing failed — it is the same class of problem and it costs one line.read_direach, off the main thread, nothing on screen waiting.let _ =. The failure the user sees was "could not save"; the file they find afterwards was explained by an error nobody kept.Once per document per run for (3) and (4), not per save: the scan only ever finds garbage from earlier runs, and a
read_dirin front of every 1.5s auto-save is a cost that shows up as a stutter while typing on a network folder. Every window calls (4) and only the first pays, since Markpad's windows share a process.(4) is not gated on the version upgrade. A marker saying "cleaned once, at 2.7.7" is state to keep, to migrate and to get wrong, and what it buys is skipping a
read_diron nine folders at startup.How much is out there: on a macOS machine with heavy daily Markpad use, a full-home scan for
*markpad-tmp*finds zero. That matches the code — on POSIX the cleanupunlinkafter a failed write essentially cannot fail, so a leftover needs the process to be killed inside the few milliseconds betweencreate_newand the rename. Windows is where they accumulate, and it is also where they are visible: the leading dot hides them from Finder andls, and hides them from nothing in Explorer.Dropped from an earlier revision of this PR: a retry with backoff around the rename and the cleanup. It was there for a transient scanner lock, and the evidence says nothing is failing — a retry retries nothing. It can come back the day a report actually shows a refused rename.
Scope
This makes the folder stay clean; it does not fix whatever the agent is doing. If the agent recreates the name after our
exists()check, (1) misses it and (3) clears it on the next run instead. The frontend change is one fire-and-forget call at startup; nothing in the save flow or the conflict guard is touched, and there is no Windows-specific code here — the behaviour reasoned about is Windows', but the change is not.Tests
Four in
fs_safety. An old temp file with contents is swept, an empty one only minutes old is swept, and a neighbouring document's leftovers are not touched; a temp file too young to be anything but a live write survives whether it is empty or not — the empty case matters because every write is empty betweencreate_newandwrite_all. Reverting the empty-file rule turns the second assertion red; reverting the sweep turns the first red. The fourth sweeps a document throughsweep_document_tempswithout writing it, which is the startup path, and asserts the document itself is untouched.(1) has no test. Reproducing it means reproducing a filter driver that leaves the source of a successful rename in place, and a test that asserts the call exists in the source would pin today's spelling rather than the behaviour.
atomic_write_replaces_the_target_and_leaves_no_temp_fileandconcurrent_atomic_writes_to_one_target_all_succeedboth still pass, which is what says (1) and (2) broke nothing on the platforms that behave.Verification
cargo test167 pass,cargo fmtandcargo clippy --all-targetsclean.npm run check816 files / 0 errors,npm test987 pass,npm run test:vitestunchanged fromorigin/master(14 failures there on a clean checkout, in tab navigation history, window tags, settings persistence and undo history — neither caused nor fixed here).Not verified on the machine that has the problem — I have no Windows box here, and the reporter's VM does not reproduce it. Whether it works is a question only they can answer, and the answer to look for is an empty folder rather than an absent error.