Skip to content

fix(studio-server): save an edit Windows briefly holds without freezing the app - #7744

Merged
miguel-heygen merged 3 commits into
mainfrom
dwinedit/held-write-retry
Nov 14, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
dwinedit/held-write-retry

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

What

On Windows, an edit to a project file that another program holds for a moment (a virus scan or a sync client opening the file just written) is now saved once the hold clears, up to about 5 s later, and the server keeps answering other requests while it waits.

Before, the edit gave up after about 1.3 s and the person's edit was lost (423 busy). During those 1.3 s the whole server process slept, so every other request, the preview included, stalled with it.

Why

renameWithRetry rides out a Windows hold by sleeping the thread between attempts (Atomics.wait, 10 ms doubling to 640 ms). Every edit route writes through it synchronously, so a hold froze the process for up to 1.3 s, and a hold longer than that lost the edit.

Related work

Follows #7731, which made the edit routes answer a held file 423 and a read-only one 403 instead of a bare 500.

How

  • core/renameWithRetry.ts: catchHeldRename(work) runs work so that a Windows hold on a sync rename inside it is thrown at once instead of slept on, and hands back that hold. The sync write stays one attempt, so the order of a write and its receipt is unchanged. The hold is thrown as a non-Error value, so a host's onError does not hear about attempts that are going to be retried. Only the first hold in an edit is handed over this way: it is also recorded, so a route that catches every error and answers a failed write itself (a cut) is still sent again, and every rename after it (a rollback of files already written) rides holds out with the sync retry, as before.
  • studio-server/routes/files.ts: a middleware on the edit routes (POST and PUT to files/*, file-mutations/* and the GSAP mutation routes; multipart uploads excepted) keeps a copy of the request, and when its write is held, waits (awaited, 10 ms doubling to 2,560 ms, about 5 s in all) and sends the same edit again. The first answer that is not held is the answer. A route that caught the hold and still answered success is not sent again. Once the waits are spent it throws the hold, so the host's onError reports a refused edit once; the existing middleware then replaces whatever onError answered with 423 {"error":"file in use","why":"busy"}, the words the client shows.
  • While an edit to a project waits out a hold, later edits to the same project wait behind it, so a later edit is never overwritten by an earlier one sent again. The queue is per project, not per file: holds are rare and short, and an edit can touch several files.
  • Renaming or deleting a file (PATCH, DELETE) is not sent again: a rename that half ran cannot be replayed safely.
  • A hold is retried only when a sync rename was refused. A read-only file is refused before any rename, so it still answers 403 at once.
  • studio-server/history/blobStore.ts: undo and restore write back with renameWithRetryAsync, checking that the project is still writable right before each attempt.
  • What an edit route does before its rename, and what sending it again does to each:
    • reads the file and checks its version: read again, so the replay patches the file as it is;
    • copies the file to .hyperframes/backup (10 kept per file): snapshotBeforeWrite now keeps no second copy when the newest backup of that file already holds the same bytes, so a replayed edit no longer pushes out the file's older backups;
    • freeze-frame extracts a still image: it is now removed when the write throws, as it already was when the route answered an error;
    • write receipts are recorded after the rename only; edits that write several files roll back the ones written when one fails;
    • history entries come from the client's claim after the one answer it gets, and from the watcher seeing the one change that lands.
  • docs/contributing/testing-local-changes.mdx: a hyperframes preview started through npx keeps its server running when npx is stopped; stop it by the process listening on its port.

Test plan

  • Unit tests added/updated. In files.heldFile.test.ts, Windows is simulated by a rename that answers EBUSY until a release time:
    • the edit is saved when the hold clears after 200 ms and after 3 s, and the host's error handler hears nothing;
    • the event loop never stalls 100 ms while a 1.5 s hold is waited out;
    • a hold that outlasts the waits answers 423, leaves the file as it was, and is reported once;
    • a read-only file answers 403 in under 500 ms;
    • an edit sent 50 ms after the hold clears waits for the held one, both answer 200 in order, and the file ends with the later edit;
    • files.heldReplay.test.ts: a held then saved element edit, with a real project history, applies once, leaves one backup and one history entry;
    • a one-file cut, whose route answers a failed write itself, is saved when a 200 ms hold clears;
    • renameWithRetry.test.ts: a hold a caller swallowed is still handed back, and the next rename in the same edit rides its hold out;
    • freezeFrame.test.ts: a freeze whose write throws leaves no still behind.
  • fails without the fix: on main's code, "still saves the edit when the hold clears after 3000 ms" (423, edit lost) and "keeps the app answering while it waits" (423, the process slept). Earlier versions of this change failed the rest: retrying without the non-Error hold failed the three "reports" checks (the host's error handler heard the edit 10 times); without the queue, the later edit answered first and the replay overwrote it; without the backup check, one held edit left 4 identical backups; without the freeze-frame cleanup, the still stayed; before the first hold was recorded, the cut answered 500 and a second rename in the same edit was refused at once.
  • With the change: files.heldFile.test.ts, files.heldReplay.test.ts, freezeFrame.test.ts and blobStore.test.ts pass 3 runs in a row on Linux; the full studio-server suite passes (106 files, 1,886 tests); core's renameWithRetry and atomicFile tests pass; tsc --noEmit, oxfmt and oxlint are clean on the changed files.
  • On Windows: the "Studio server files on Windows" check on this PR runs files.heldFile.test.ts on windows-latest, real held files included. main's code was not run on Windows with these tests.
  • Documentation updated.
  • Comments follow CONTRIBUTING.md "Comments".

@mintlify

mintlify Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Nov 14, 2026, 3:00 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 107 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 (8)

@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 · 0 important · 0 nits · head 10aa4348

The 'fails without the fix:' line is present. The blocker: a route that catches everything now swallows the hold, so a cut fails at once where main rode the hold out. ↓ inline

Scope

Read in full: renameWithRetry.ts, the new middleware, backupJournal.ts, blobStore.ts, freezeFrame.ts, and the in-scope write and catch sites (split-batch, applyFileMutations rollback, PUT files/*). I ran files.heldFile.test.ts with an extra split-batch probe, at this head and on main's code. CI was still running when I posted this; Studio server files on Windows is green.

— Genesis

@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 · 10aa4348 → f55285ee. CI is green on this head, including Studio server files on Windows. My split-batch probe now answers 200, and the held-file suites pass locally.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit a92498b Nov 14, 2026
144 of 145 checks passed
@miguel-heygen
miguel-heygen deleted the dwinedit/held-write-retry branch November 14, 2026 16:26
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