Skip to content

fix(studio-server): undo and redo on Windows restore a file something holds open - #6628

Merged
miguel-heygen merged 3 commits into
mainfrom
fix/studio-server-restore-held-file-windows
Oct 28, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
fix/studio-server-restore-held-file-windows

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

What changed

On Windows, undo and redo could fail with EPERM when they put a file back. The history writes the earlier copy beside the file and renames it over the file. Windows refuses that rename while anything has the file open, even a read handle that allows deleting. #6559 added a short retry, but some holders keep the file open for longer than that.

Two holders, two fixes:

  • The preview itself. It serves video and audio as ranged read streams, and a loaded <video> keeps its read open. The preview now registers each stream it serves (helpers/servedFiles.ts), and a history restore ends the open reads of a file and waits for them to close before it replaces it. The player asks again and gets the new bytes.
  • Something outside the app (antivirus, a sync client, another program). On Windows only, and only after the rename retry has given up, the restore writes the bytes into the file in place, syncs it to disk, and checks its hash against the copy history kept. If the hash differs, the restore fails with an error and history keeps its copy. It never reports success for bytes it did not check. onRestoredInPlace lets an app count how often this happens and how it ends.

Every other platform and every other error still fails at once, as before. isWindowsHold in core is now the single check for "Windows is holding this file", shared by the rename retry and the restore.

What I verified

  • Windows runner probe (Node 22). With the file held open, a rename over it failed with EPERM for every holder tried: a Node read handle in the same process or another, a paused Node read stream, and PowerShell handles with each share mode. Writing in place worked whenever the holder shared write access, which every Node handle does.
  • fails without the fix: src/history/blobStore.test.ts:
    • "writes a file a holder keeps open in place, once the rename is still refused, and checks it", and "fails a write in place that does not match the kept copy, and keeps the copy": both fail when the in-place write is removed, and the second also fails when the hash check is removed;
    • "ends the preview's open read of it first, which Windows would not replace": fails when the restore does not end the preview's reads, and when it ends them without waiting for them to close.
  • "off Windows, a refused rename fails the restore at once and writes nothing in place" pins the boundary.
  • blobStore.test.ts passes three runs in a row (6 tests). tsc is clean for core and studio-server.

What I did NOT exercise

  • No run on a real Windows machine with antivirus or a sync client holding a file; the holders above are the probe's.
  • The in-place write is not atomic. A reader mid-read can see the new bytes, and a crash mid-write leaves a file the next undo or redo can still restore, since history keeps its copy.
  • A restore matches the preview's streams by resolved path. A project reached through a different link than the one the preview served from would not match.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 1 important · 0 nits · 0c8c4b23

  • blocker: Comments is red at this head. The comment ratchet flags core/src/renameWithRetry.ts (6.1% → 7.0%), studio-server/src/history/blobStore.ts (4.6% → 5.5%), and the new studio-server/src/helpers/servedFiles.ts, which starts at 12.0% against the package's 6.6%. Cut those comment lines or move what they say into names or types.
Scope

Read every changed file. The fails without the fix: tests check out locally: removing the in-place write fails the first two, removing the hash check fails the mismatch test, and removing stopServing fails the served-read test. All 6 pass at head. CI and Windows render verification were still running when I posted this.

async function writeInPlace(temp: string, to: string, hash: string): Promise<void> {
let outcome: InPlaceOutcome = "failed";
try {
await copyFile(temp, to);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: this relies on fs.copyFile (libuv CopyFileW) opening a destination another process holds. CopyFileW asks for its own access and share mode on the destination, which may differ from a plain write through a handle. The tests mock copyFile, so CI never runs it against a real hold. Did the Windows probe run copyFile itself? If it didn't, writing through open(to, "r+") + truncate + write is the in-place write the probe showed works. When this fails, the restore errors as it did before, so nothing is lost. The point is whether the fallback ever succeeds.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The Windows probe ran fs.copyFileSync (the same libuv uv_fs_copyfile → CopyFileW as the async copyFile here) against each holder. It worked against a Node read handle in the same process or another, a paused Node read stream, and PowerShell handles sharing ReadWrite or ReadWrite+Delete. It failed with EBUSY only when the holder shared read alone, and then the restore errors as before. So the fallback succeeds against every holder that allows a write through a handle.

@heygengenesis heygengenesis Bot Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Answered: the Windows probe ran copyFileSync (CopyFileW) against each holder.

beforeReplace?.();
renameWithRetry(temp, to);
} catch (error) {
stopServing(to);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking: destroy() closes the fd asynchronously. If a read is in flight, the close waits for that read's callback on the main thread, and the synchronous renameWithRetry below blocks the main thread for its whole ~1.3 s retry. So that rename can't succeed, and the restore falls through to the in-place path. The test checks read.destroyed, not whether the fd is closed. awaiting the streams' close events here (or using renameWithRetryAsync) would make the fix hold.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, fixed at 1468e5c. stopServingAndClose destroys each open read and resolves on its close event, and the restore awaits it before the synchronous rename. The test now checks read.closed at the rename, not read.destroyed. A break check that calls it without awaiting fails that test.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in 1468e5c

@github-actions

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 143 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

Unstable (2)

  • seqplainkeys-none-pct-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • seqplainkeys-none-pct-r0-nested-z100: tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo false, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true / tracking 0.05, pressJump 0, drop 0, reload 0, render 0.02, renderKey -, undo true, teleport true

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Blocker fixed at 1468e5c: the new comments are down to one line per file, and the doc comment in renameWithRetry.ts is gone (the name says it). comment-ratchet reports all 5 changed source files at or under their share at the merge-base, and check-comment-citations is clean. On the devbox: blobStore.test.ts passes three runs in a row; removing the in-place write, the hash check or the close, or not awaiting the close, each fails a test. tsc, oxlint, oxfmt and fallow are clean.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE — 2 resolved, 0 open, 1 new (important, not blocking) · 0c8c4b23 → 1468e5cc

Comment on lines +75 to +76
beforeReplace?.();
renameWithRetry(temp, to);
} catch (error) {
await stopServingAndClose(to);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

important: beforeReplace is assertWritable(), the check that the project folder is still this project. It ran right before the synchronous rename. This await now sits between them, so if the preview holds reads of the file, the folder can be swapped while they close and the rename still lands. Close the reads first:

Suggested change
beforeReplace?.();
renameWithRetry(temp, to);
} catch (error) {
await stopServingAndClose(to);
await stopServingAndClose(to);
beforeReplace?.();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken at fce3bb3: the reads close first, then beforeReplace runs right before the synchronous rename, as before. The in-place fallback starts with no yield after that rename, so nothing runs between the check and the write. blobStore.test.ts passes three runs in a row; tsc, oxfmt and the comment ratchet are clean.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Fixed in fce3bb3

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 28, 2026
@miguel-heygen
miguel-heygen removed this pull request from the merge queue due to a manual request Oct 28, 2026

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE — 1 resolved, 0 open, 0 new · 1468e5cc → fce3bb36

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 28, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 28, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 30df5a4 Oct 28, 2026
122 of 123 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-server-restore-held-file-windows branch October 28, 2026 09:50
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