Skip to content

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
masterfrom
fix/abandoned-temp-files
Open

fix(save): delete the temp file the rename left behind, and the ones earlier runs left#725
PathGao wants to merge 2 commits into
masterfrom
fix/abandoned-temp-files

Conversation

@PathGao

@PathGao PathGao commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

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_write creates 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:

  • Ctrl+S shows no error and the tab's modified dot clears, so save_file_content returned Ok — a rejected invoke leaves the dot and raises a toast. The rename therefore succeeded.
  • The document really does hold the new text.
  • Auto-save off changes nothing, so the 1.5s write cadence and any two-writer race are out.
  • A VM with the same build and the same steps is clean. A normal local folder, no OneDrive, no Controlled Folder Access. The machine runs 亚信安全 TrustOne, an endpoint agent with a filesystem filter.

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

  1. After a successful rename, remove the file the rename should have consumed. One 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.
  2. The handle is closed before the rename and before any cleanup (drop(file)), 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 on the file, and ours joined whatever a scanner was already holding on a file created an instruction ago; a DeleteFileW that 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.
  3. The next save of a document sweeps leftovers for that document, once per document per run: nothing under a minute old, then anything empty, and anything older than an hour. Empty and non-empty are separated on purpose — a temp file with contents can be the only copy of a document from a process that died between the fsync and the rename, and deleting that would be the one way this code could lose someone's work. An empty one can never be anything but garbage, which is what Do not clear the temporary files #722 accumulates.
  4. Startup sweeps the documents Markpad remembers — the recent list and whatever the session restored — through the same function. (3) only ever reaches the folder of a document someone is still editing; a document that collected leftovers and was then put aside keeps them for good, which on the reporter's machine is one per save for as long as they worked on it. At most nine folders plus the open tabs, one read_dir each, off the main thread, nothing on screen waiting.
  5. Cleanup failures are folded into the error the save returns instead of being dropped by 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_dir in 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_dir on 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 cleanup unlink after a failed write essentially cannot fail, so a leftover needs the process to be killed inside the few milliseconds between create_new and the rename. Windows is where they accumulate, and it is also where they are visible: the leading dot hides them from Finder and ls, 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 between create_new and write_all. Reverting the empty-file rule turns the second assertion red; reverting the sweep turns the first red. The fourth sweeps a document through sweep_document_temps without 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_file and concurrent_atomic_writes_to_one_target_all_succeed both still pass, which is what says (1) and (2) broke nothing on the platforms that behave.

Verification

cargo test 167 pass, cargo fmt and cargo clippy --all-targets clean. npm run check 816 files / 0 errors, npm test 987 pass, npm run test:vitest unchanged from origin/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.

…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
PathGao force-pushed the fix/abandoned-temp-files branch from 2102838 to 96d37ae Compare August 27, 2026 09:27
@PathGao PathGao changed the title fix(save): close the temp file before renaming it, and sweep the ones an earlier run left fix(save): delete the temp file the rename left behind, and the ones earlier runs left Aug 27, 2026
… 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
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.

1 participant