Skip to content

fix(studio-server): history refuses once closed and reopens when a new project takes its folder - #4537

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/history-engine-closed-or-moved
Sep 26, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
fix/history-engine-closed-or-moved

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

What this fixes

Two ways a project's history could reach the wrong project, both rooted in the history being tied to a folder path rather than the folder itself:

  1. A closed history kept working. A handle taken before close() (an agent turn or edit window left open across a project rename) could still record and restore. If a new project had been created at the old path, its content could be recorded into the old project's history, or the old history restored into it.
  2. A renamed folder left the server's history stuck. Rename a project folder, create a new project at the old path, press undo there: the preview server (and Studio's dev server) kept the old history open, and undo failed until restart.

Now:

  • After close() every call refuses with HistoryClosedError, including windows begun before it. close() is safe to call twice: both calls settle together, and the ownership lock is released only after both.
  • The history records the folder's identity (device and inode) when it opens. Where the engine touches project files (the sweep that reads them, and the one writer behind undo, step and restore), it checks that the folder at the path is still that folder:
    • a new folder at the path, or one carrying another project's history id, is another project: sweeps ignore it, writes refuse with HistoryClosedError, and list, peek, next, pin and readBlob refuse too;
    • a folder that is gone (moved away, nothing at the path) is no change for sweeps, and writes refuse instead of recreating the old path;
    • a folder whose .hyperframes/history-id is missing is another project too. A folder deleted and recreated at the same path can reuse the old inode number (ext4 does), so the id is what tells them apart. A project whose id file was removed (for example by git clean) gets a fresh history on reopen; its old history stays on disk.
  • The history id's record now also keeps the folder's inode and creation time (not its device number, which changes when a drive is remounted), and only the folder whose inode and creation time both match the record keeps that history. An inode alone can be reused after a folder is deleted; the creation time tells a recreation apart. Anything that cannot prove it is that folder gets an id and an empty history of its own: a copy carrying the id (in either opening order; the moved original keeps its history), a project restored from a backup, a project moved to another disk, and a record written before this change, which has no inode. Before, a copy standing where the original was recorded took over the original's history. The old histories stay on disk; cleaning them up is a follow-up. A history root with no history under the id at all (another OS user's, or a different home folder) keeps the id instead of rewriting it, so a history already open elsewhere keeps recording. The record is written atomically, so a second process opening the project at the same moment never reads it half-written.
  • A swap in the middle of an operation stops at the next file. Undo, step and restore check the folder synchronously right before each file they replace or delete, so none of the old project's bytes land in the new one and none of its files are removed. A sweep checks it again after copying each changed file and drops a file read from a replaced folder, so the old history never files the new project's content.
  • Opening checks the folder's identity again after waiting for the history's ownership lock, so a copy that took the path during the wait is refused. The first baseline is dropped if the folder changes while it is read, and a file deleted meanwhile is skipped rather than failing the open.
  • If the history's own record of its folder is deleted while it is open, the next write to its log puts it back, so the next open still finds its history.
  • Opening refuses, with the same retryable error, when the folder is not there, moved away, or another project took its path while it waited for the history's ownership lock; both servers try that open again on the next request instead of leaving history off.
  • A new historyCache in studio-server keeps a host's histories, one per project folder. When replacedAtPath() says another project now stands at the path, it closes the old history (committing what it had already seen) and opens the new project's own. The preview server and Studio's dev server both use it, instead of each caching an engine forever. A history closed at shutdown is not replaced, so a late request does not reopen it.
  • hyperframes history commands release the history even when the folder changed during the command.

What changes for a user

Each existing project starts with an empty history the first time it is opened after this change, because its record predates the inode. The same happens after a backup restore or a move to another disk. The old history is kept on disk, but it no longer shows in Undo.

Otherwise nothing changes unless a project folder is renamed or replaced while the preview or an agent turn is running. Then undo works on the new project at once, instead of failing until restart.

Known limits

Each of these fails closed (a folder starts a fresh history) or leaves only empty folders; none attaches another project's history or writes its file contents:

  • A folder renamed by another process in the microseconds between the final check and the one write or delete is not caught. Node has no file operations bound to a directory rather than its path.
  • The steps before that check can create or remove empty folders inside a folder swapped in meanwhile, and a swap during a long copy can leave one temporary file in the moved original. A temporary copy of the old project's file can appear in a swapped-in folder while it is refused, and stays only if the process dies at that instant.
  • Opening writes a new id into the folder a few microseconds after checking it; a folder swapped in during that gap has its id replaced and starts a fresh history.
  • On file systems without a creation time, identity falls back to the inode alone. Where Node cannot read the creation time and reports the change time instead (WSL1, Linux before 4.11, containers that block statx), an open history closes at the first change to the project's top folder, such as a save, and starts fresh, so undo does not work there. On macOS, setting the folder's modified time earlier (for example restoring its contents in place with rsync -a or tar) moves its creation time back and closes an open history the same way.
  • Ids are stored once in the folder but assigned per history root. If a copy is opened under a second root before the original, the original gets a new id there and, back under its own root, no longer finds its history (which stays on disk).

Tests

  • projectHistory.test.ts: every call refuses after close, through a window begun before it, and readBlob rejects rather than throwing; two concurrent closes both settle before the lock goes; a new project at the path is refused, the old turn still commits what it saw before the swap, and neither project's files change; a folder moved away refuses a restore and the old path is not recreated; a folder whose history id is gone is refused, with its files left alone; a copy put where the original was gets an empty history and the moved original keeps its own, in either opening order; an open that waited for the lock while the folder was moved away or replaced refuses, and so does an open of a folder that is not there; a changed device number keeps the history; a record without an inode gives the folder a history of its own; opening under a second, empty history root leaves the id alone and the open history keeps recording; a history whose folder record was deleted while open writes it back.
  • projectHistory.swap.test.ts: the folder is swapped between two file writes of a restore, between a write's check and its rename, inside a restore's delete, between two copies of a sweep, and between two copies of the first baseline. Each is refused and neither project's files change; the moved original's baseline has only its own bytes. A file deleted during the first baseline is skipped.
  • projectHistory.test.ts also: a record whose creation time differs gives the folder a history of its own; a copy carrying the id that took the path during the lock wait is refused.
  • studioServer.test.ts and Studio's vite.adapter.projects.test.ts: after the folder is swapped, the next history request answers 200 from the new project's own history.
  • commands/history.test.ts: a command during which the folder is swapped still releases the history.

Results: studio-server src/history 102 (2 skipped on Linux), src/routes 348; Studio vite.adapter 24; CLI studioServer.test.ts 28, commands/history.test.ts 32, src/utils 859; tsc clean in studio-server, studio and cli. Single mutations each fail their test: the closed check in the queue, the inode compare, accepting a record without an inode, rewriting the id for an empty history root, an inode-only compare, no identity check after the lock wait, no check in the first baseline, no check right before the rename, an unchecked asynchronous delete, failing on a file deleted during the first baseline, the missing-id refusal, each of the two write checks, the per-file write check, the sweep's check after each copy, the record write-back, the shared close, readBlob being async, the read-side refusal, the open-time id check, the missing-folder refusal at open, a copy keeping the id at the recorded path, the moved original losing its history, a device-number compare, the cache's reopen (both host tests answer 409), each server's retry after a refused open, and the command's close in finally.

Before

Studio's dev server on main: project demo has one edit, its folder is renamed away and a new project is created at the same path. After a reload, Undo in the new project offers the old project's edit; clicking it writes the old project's file into the new one ("Old project, edited").

before.webm

After

The same steps on this branch (recorded at fe90b5e5; later commits add the identity and write checks described above, which do not change these steps): the new project opens with Undo disabled and its own empty history, and its file stays "New project".

after.webm

@terencecho terencecho 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.

Reviewed bd03bd4c14ff5794ad5a3418ac709be060d877bc. Changes requested. The explicit-close guard and ordinary different-inode replacement path work in focused tests, but the project-identity boundary is still unsafe in reproducible replacement cases, and the new cache can permanently lose history after a transient move.

  1. A delete/recreate can restore the old project's files into the new project. projectHistory.ts:313–318 accepts the folder as the original when device/inode match and its history ID is missing. On this ext4 volume, deleting a project and immediately creating a new folder at the same path reused the inode; in an end-to-end openProjectHistory reproduction, replacedAtPath() returned false and restore("start") overwrote the new project's index.html with old-project content. The PR body acknowledges inode reuse as a known limit, but this is the exact replacement boundary this change promises to protect, and it occurred under an ordinary delete/recreate. The old path-only implementation also had this exposure; this review is calling out an incomplete safety fix, not attributing the original exposure to this diff. Please require a durable identity that cannot accept a new, ID-less folder merely because its inode was recycled, while retaining the intended handling of a history-ID file removed within the same folder.

  2. A copied history ID still transfers the original project's history to its replacement. The new historyCache.ts:12–16 correctly notices a different inode and reopens, but the existing historyId.ts:33–40 accepts a copied .hyperframes/history-id if its recorded directory equals the replacement's pathname. I reproduced: copy the original project including its ID, move the original elsewhere, put the copy at the original path, then request history. The replacement receives the original log (Old change); opening the moved original assigns it a new ID and loses access to its old log. The ID helper predates this PR, but the new cache's promise that a replacement gets its own history is not true in this reachable case. Please make the reopen distinguish a copied ID from the original folder before taking ownership of that log.

  3. A move during history open can leave a permanent null cache entry. The new constructor statSync(this.dir) (projectHistory.ts:285) throws ENOENT if the folder moves after the ownership-lock await and before the replacement appears. The preview and dev-server open wrappers convert only the named closed/busy errors into a retry; historyCache.ts:9–12 then retains the resolved null promise. I reproduced a move during that await: the first get returns null, and even after creating the replacement at the same path, subsequent get calls keep returning null for the server's lifetime. Treat this missing-path transition as retryable or evict its cache entry.

Focused tests passed in the isolated worktree: history engine 87 passed/2 skipped, history routes 17, Studio adapter 15, CLI server/history commands 60. These do not cover the replacement witnesses above. The only failing required check at this head was the PR-body capture gate (86 changed Studio/player lines exceed its no-visible-change exemption); it was not a product-test failure. No product files were edited.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen force-pushed the fix/history-engine-closed-or-moved branch 2 times, most recently from 69a7047 to 19e86ab Compare September 26, 2026 13:51

@terencecho terencecho 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.

Re-reviewed 19e86abb8492da2825daed4acfbd0cf089e7d720. Changes requested. The ordinary missing/corrupt marker, fresh copied-ID, replacement, and moved-then-restored cases now behave as intended; focused history tests passed. Three data-integrity paths remain:

  1. Existing history records without ino still transfer the old log to a replacement. packages/studio-server/src/history/historyId.ts:28–30 treats was.dir === dir and was.ino === undefined as not a copy. Pre-change project.json records have that shape. Move the original, place a copy carrying its .hyperframes/history-id at the original path, and open history: the copy inherits the old log and the moved original gets a new ID. In a controlled Studio API reproduction, GET history offered the old Undo and POST restore replaced the copy's new content with the original's baseline. The fresh-record test does not cover this migration state. Distinguish the copied folder even for legacy records before assigning their log.

  2. An in-progress Undo/restore can cross a folder replacement. projectHistory.ts:802–818 checks whereFolder() once, then awaits successive path-based writes in writeProjectFile (:825–837). In a controlled 320-file restore, I moved the original after its first file was restored and put a different project at the old path. A later file in the replacement changed from NEW PROJECT LAST to the old project's contents, while the moved original retained its edited file. Check/bind ownership across the entire write, not only before its first await; add a deterministic mid-restore swap test.

  3. A sweep can ingest the replacement project's bytes into the old log. projectHistory.ts:390–447 checks ownership before listing, then asynchronously observes and hashes files by the mutable path. In a controlled 320-file flush, replacing the folder during the sweep caused the old log to record the SHA-256 of the replacement's file. Keep the sweep tied to its original directory/identity across asynchronous observation and blob writes; test the mid-sweep swap as well.

These are controlled filesystem/API reproductions, not a full UI walk. The build passed, focused history tests passed 161 with 2 skipped; a separate broader test invocation had one build-sensitive, non-history fixture failure. Required CI, including the captures gate, is passing at this head. I downloaded and sampled the two linked videos: their embedded BEFORE/AFTER overlays appear opposite the PR body's link labels, and the recording labeled After in the body is from an earlier head; please correct that evidence, though it is not the reason for this verdict.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen force-pushed the fix/history-engine-closed-or-moved branch 4 times, most recently from 97fd766 to eb8725b Compare September 26, 2026 15:32

@terencecho terencecho 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.

Re-reviewed #4537 at eb8725b3fa7f04556a67cf6167bcf6297a9bebfe. Changes requested. The earlier legacy no-inode reset, between-files restore check, after-copy sweep check, second-history-root behavior, deleted-record recovery and atomic record write are addressed in the inspected paths. The central rule—never attach another folder's history or write through a folder that has been replaced—still has four distinct failures:

  1. In-flight restore writes through a replaced folder. packages/studio-server/src/history/projectHistory.ts:838–850 calls assertWritable() only before asynchronous blobs.writeTo. That writer makes a temp copy and renames it to the target path. A controlled witness swapped the original folder for an unrelated folder after the check but before writeTo; restore(START) changed the unrelated project's NEW PROJECT file to OLD BASELINE while the moved original remained OLD EDIT. The new between-files test swaps after one write completes, so it misses this interval. A check after the rename would detect damage too late; the destructive write must remain bound to the validated folder across the write boundary.

  2. Copied history marker plus recycled inode reuses old history. historyId.ts:33–40 accepts the saved ID when the recorded inode matches, but an inode is reusable after removal. In an ext4 backup-restore witness, a backup carried the old .hyperframes/history-id; after deleting the original, the restored directory reused its inode on attempt 61. Opening the restored backup kept the old UUID and exposed Old change; restoring rewrote NEW BACKUP CONTENT to OLD. Please fail closed when a copied/restored marker cannot prove the same folder, rather than treating inode equality alone as enduring identity.

  3. The ownership-lock wait makes a pre-checked identity stale. projectHistory.ts:1056–1064 calculates projectHistoryId before awaiting takeHistoryOwnership; the constructor at :286–288 checks the marker ID, not that the selected folder still has the same inode after the wait. A controlled witness replaced the folder with a copy carrying the same marker (different inode) during the wait. The waiting open adopted the copy under the original UUID, exposed the old Undo history and rewrote NEW CONTENT to OLD BASELINE on restore. Revalidate/recompute identity after the lock is acquired.

  4. First-open baseline reads a mutable path. projectHistory.ts:369–379 lists the original folder, then awaits blobs.put for each path without checking ownership before persisting that baseline. Swapping the folder at the first actual put recorded the replacement's NEW PROJECT bytes under the original UUID; reopening the moved original then offered that baseline and restore rewrote its OLD PROJECT bytes to NEW PROJECT. Discard an incomplete baseline if the source folder changed during its reads.

These are deterministic Bun fixtures exercising the actual history/blob code with controlled filesystem swaps, not observed user incidents or an interactive Studio walk. The isolated worktree lacks dependencies for focused Vitest; required GitHub checks at this head passed, but checks do not cover these interleavings.

— Review by tai (pr-review)

@miguel-heygen
miguel-heygen force-pushed the fix/history-engine-closed-or-moved branch 2 times, most recently from 50e7491 to 16c949c Compare September 26, 2026 16:34

@terencecho terencecho 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.

Re-reviewed #4537 at 16c949c096135e05b2a5879759a827a04b22c494. Changes requested: the new creation-time identity is checked on open, but not by an already-open history.

historyId.ts:19–25,34–46 now records and compares ino plus birthtimeMs, so reopening a copied folder on a recycled inode can receive a fresh history. But the live Engine.whereFolder() in projectHistory.ts:323–329 compares only dev, ino and the copied history marker; it omits this.folder.birthtimeMs even though the constructor captured it at line 298. list() and restore() trust that check. Delete the original project folder while a history handle remains open, copy back a backup carrying its .hyperframes/history-id, and wait for the original inode to be recycled: the replacement is treated as the old project. A controlled ext4 fixture reproduced the collision on attempt 61; list() exposed the old Old change entry, and restore('start') overwrote the replacement's NEW BACKUP CONTENT with OLD BASELINE. This is a controlled local filesystem witness, not a production incident; inode recycling time varies by filesystem.

Please use the same birthtime identity in the live path check, with a regression that keeps the old handle open across a copied-marker/inode-reuse replacement and verifies both listing and restore refuse it. The reopen-only test changes the recorded creation time after closing the handle, so it does not exercise this path.

— Review by tai (pr-review)

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review at 16c949c0.

Verdict: REQUEST_CHANGES. I reproduced tai's newest finding on my own: an open history still accepts a copy that carries the project's .hyperframes/history-id when the inode is recycled, and a restore then writes the old project's bytes into it. In my fixture the inode was reused on the first try in 9 of 9 runs, so on ext4 this can happen with an ordinary rm -rf and cp -r.

What would flip this to an approve: compare birthtimeMs in Engine.whereFolder() the same way isRecordedFolder() does on reopen, and add a regression test that keeps the handle open across the replacement and checks that both list() and restore() refuse. I made that one-line change locally. My repro then refused, and all 102 history tests still passed. The Should-fix items below are worth doing in the same push, but I would not hold the approve on them.

tai's findings

Evidence is a mutation (the named test goes red when that guard is removed) or a replay I ran. Mutations ran in a separate worktree at this head; suites were studio-server src/history (102 + 2 skipped), and for host changes the CLI server/command tests (60) and Studio vite.adapter (24), with studio-server dist rebuilt first because both host suites import it through its node export.

Round 1 (bd03bd4c)

  1. Delete and recreate reuses the inode, ID missing. Resolved. Removing the ID check in whereFolder() (projectHistory.ts:328) fails "counts a folder whose history id is gone as another project". Replay with real inode reuse (no marker in the new folder): replacedAtPath() true, list() and restore() throw HistoryClosedError, file untouched.
  2. Copied history ID takes over the original's history on reopen. Resolved. Making isRecordedFolder() accept any record (historyId.ts:24) fails 6 tests, including both "gives a copy put where the original was its own history" cases.
  3. Move during open leaves a permanent null cache entry. Resolved. Putting back a throwing statSync(this.dir) in the constructor (projectHistory.ts:294) fails "refuses to open, so a server can retry, once the folder moved away while it waited". Removing the missing-folder refusal (:1066) fails "refuses, so a server can retry, to open a folder that is not there". Dropping the forget on HistoryClosedError fails "tries a history whose folder changed while it opened again" in the CLI (studioServer.ts:440) and "opens it again on the next request" in Studio (vite.adapter.ts:117).

Round 2 (19e86abb)

  1. Records without ino hand the old log to a copy. Resolved. Accepting a record with no ino/born fails "gives a folder whose history record cannot prove it is that folder a history of its own".
  2. Restore crosses a replacement between files. Resolved. The top-of-write check (:849) and the check before rename (:861) cover each other: removing either alone leaves the suite green, removing both fails "stops a restore at the next file".
  3. Sweep files the replacement's bytes into the old log. Resolved for the log. Removing the per-file check (:468) fails "stops a sweep at the next file". See the Nit below about the blob store.

Round 3 (eb8725b3)

  1. Restore write between check and rename. Resolved. Dropping the beforeReplace check (:861) fails "refuses a restore write whose folder was swapped after its check".
  2. Copied marker plus recycled inode, on reopen. Resolved on reopen only. Dropping the birthtime compare (historyId.ts:24) fails "gives a folder on a reused inode, created at another time". My replay's reopen step confirms it with a real recycled inode: fresh id, empty list.
  3. Identity stale after the ownership wait. Resolved. Dropping the record check in the constructor (:296) fails "refuses to open ... once a copy carrying its id took the folder's path while it waited".
  4. First baseline reads a swapped folder. Resolved. Dropping the check (:383) fails "drops a first baseline read partly from a swapped folder".
  • Restore-delete gap (Miguel's addition). Resolved. Dropping the delete check (:857) fails "refuses a restore delete whose folder was swapped after its check".

Round 4 (16c949c0, this head)

  1. Live whereFolder() omits birthtime. Confirmed. projectHistory.ts:327 compares dev and ino only, while the constructor captured birthtimeMs at :298. Repro (tsx script against this head, ext4 scratch dir, Node 22 reports a real birthtime via statx): open history, record "Old change", copy the project with its marker to a backup and edit the backup, then rm -rf project && cp -r backup project with the handle still open. The inode was reused on the first attempt in 9 of 9 runs (birthtime differed by 8 to 12 ms). tai needed 61 attempts, and a separate 500-attempt run of this fixture earlier in this review got no reuse at all (other suites were running on the same disk then), so how often it happens varies. Every run that reused the inode: replacedAtPath() false, list() returned ["Old change"], restore(START) ran and changed the file from NEW BACKUP CONTENT to OLD BASELINE. Closing and reopening the same folder gave a new id and an empty list, so the two identity checks disagree about the same folder. The same run with the marker removed from the backup refused correctly.
  • Round 2 note on the videos. The body now says the After video was "recorded at fe90b5e5; the head differs by one line in the sweep". I fetched fe90b5e5 (same parent, 162de1a6). It differs from this head by 6 files, +168/-40, including the birthtime identity in historyId.ts, the beforeReplace check in blobStore.ts, and the delete check in projectHistory.ts. The sweep change is the one line that was removed (see Nit 1). The user-visible behavior in the video is probably unchanged, but the sentence is not accurate.

New findings

Nobody raised these in the four earlier rounds. Each one comes with a probe or mutation I ran at this head. Probes live in scratch dirs and import the real modules. Where the swap has to happen at an exact point, the probe uses the same hook points as the PR's swap tests.

Should-fix

  1. No test pins the live inode compare, so tai's bug class could come back and the suite would stay green. projectHistory.ts:327. I removed now.ino === this.folder.ino and rebuilt studio-server dist. The full studio-server suite (797 tests), the CLI tests and the Studio tests all still passed. With that mutation in place, the common copy case gets adopted: running cp -a p p2 && mv p p-old && mv p2 p with the history open gave replacedAtPath() false, and restore() wrote OLD BASELINE into the copy. At this head the same replay refuses. The reason: every live-swap test builds its replacement without the marker, so the ID check always fires first. Please add a live-handle test whose replacement carries the marker on a new inode. The recycled-inode test tai asked for belongs right next to it.

  2. No test pins the sweep's entry check either. projectHistory.ts:416. I put back the old if (!existsSync(this.dir)) return; and all 797 studio-server tests passed. With that mutation, take a project with a.html and b.html and replace it with a new project that has only a.html. On its next flush, the old history logs "Changed outside the app: b.html deleted". Reopening the moved original then logs b.html again as a second outside change. The removal branch of the loop (:446-449) has no per-event check, so the entry check is the only thing that stops this. A test whose replacement lacks one of the old files would pin it.

  3. In Studio's dev server, swapping two project folders' names leaves one of them with no history until restart. vite.adapter.ts:116-117 only calls forget on HistoryClosedError, so a HistoryBusyError stays cached as null. Here is the sequence after mv a tmp; mv b a; mv tmp b. The a entry sees a replaced folder, so it closes A's history and reopens at a. That folder is now project B, and B's history is still held by this same process's b entry. The open waits the full 5 s and fails with HistoryBusyError. Measured through createStudioApi + createViteAdapter:

    after swap: GET a -> 404 5032ms "This project has no history here."
    after swap: GET b -> 200 (entries: "edit a")
    again:      GET a -> 404 0ms
    

    A plain rename (demo to demo-renamed) hits the same wall. The first request takes 5028 ms and returns 404. Every later request also returns 404, even after a new project takes demo and the old history closes. The rename case already failed on main, but this PR's stated goal is that a renamed or replaced folder no longer leaves the server's history stuck. The CLI server already retries on HistoryBusyError (studioServer.ts:439). I tried the same forget in the Studio adapter: after the swap, the next GET a returned 200 with "edit b". The rename then recovered too, but only after the old path's history closed. Until then, every request waited the full 5 s and got a 404. So the "gone" entry also needs to let go of its lock when the same history is wanted under a new path.

  4. hyperframes history does not treat the new HistoryClosedError as a refusal. packages/cli/src/commands/history.ts:277-280. guarded() handles Refusal, AmbiguousPreviewServerError and HistoryBusyError with exit 2, and prints JSON when --json is set. HistoryClosedError falls through. I ran bun run packages/cli/src/cli.ts history --json and swapped the folder while the command waited for the lock. Result: exit 1, empty stdout, and one stderr line ending "is now another project.". The control run, where the history is held past the wait, gave exit 2 and {"ok": false, "error": "...open in another process..."}. An agent that reads --json gets nothing back for the refusal this PR adds. The PR's own mid-command test calls withOwner directly, so it never reaches guarded().

Nit

  1. Refused reads still leave the other project's bytes in this history's blob store. fe90b5e5 had if (this.whereFolder() !== "here") break; at the top of the sweep loop (projectHistory.ts:441-450), and this head removed it. observe() now drops each file only after copying it (:467-468). I forced a swap at the second blob copy with 50 edited files. This head made 50 copies and left 49 blobs of the new project's bytes in the old store. With the break put back, it made 2 copies and left 1, and all 102 history tests still passed. A refused first baseline does the same (:382-383): the other project's b.html was in the store after the refusal. Only a budget fold removes these blobs.
  2. checkout checks less than every other call, and three of the listed refusals have no test. projectHistory.ts:996 checks only existsSync. On a replaced handle, list() threw HistoryClosedError, but checkout(entry, "after", emptyDir) ran and wrote OLD EDIT out. Separately, removing assertOpen() from peek (:989), next (:1012) or pin (:1020) each leaves all 797 tests green. Only list and readBlob are asserted.
  3. A move in the middle of a write recreates the old path. The body says "writes refuse instead of recreating the old path". I moved the folder away, with nothing put at the path, at the PR's own beforeWrite hook point during a restore of scenes/intro.html. cloneOrCopy then ran mkdir(dirname(to), { recursive: true }) (blobStore.ts:28), which recreated project/scenes. The refusal message said "is now another project". The next cache get() treated the recreated folder as a new project and wrote .hyperframes/history-id into it.
  4. The body says a history closed at shutdown is not reopened. It is reopened when the folder was replaced. historyCache.ts:12 does not check whether the handle is closed, and replacedAtPath() (projectHistory.ts:1031) looks only at the folder. My probe ran get(), then closeAll() (what shutdown() does at studioServer.ts:1104), then swapped the folder, then called get() again. open ran a second time, the late call got a handle for the new project, and that history's owner.pid held this process's pid after shutdown. The same late call also wrote a fresh .hyperframes/history-id into the folder. preview.ts:1683-1687 closes the HTTP server only after shutdown settles, so a late call like this can actually arrive.
  5. When two processes open a never-opened project at once, one is refused as "another project". The id is minted before the lock (historyId.ts:38-45) and checked after it (projectHistory.ts:296). Two child processes started at the same instant: 20 of 20 trials gave one HistoryClosedError: ... is now another project. and one success, and every trial left a second, orphan history directory. That beats main, where both processes silently forked. But upgrade day triggers it too, because every legacy record gets re-minted. Through the CLI, the refused side hits Should-fix 4.
  6. Where birthtime falls back to ctime, the first open of an existing project is refused. This is a simulation: I patched statSync so that birthtimeMs equals ctimeMs, which is what the body says Node reports without statx, and aged the project folder by 100 ms before opening. Creating .hyperframes moves the root's ctime between the recorded stat and the check after the lock. Result in 3 of 3 runs: the first open threw "is now another project", the second open got a new id, and a reopen after adding a root file got another new id. That left three history dirs with the edit lost. The body mentions "fresh on most reopens" but not the refused first open or the orphan per reopen. This matters for the blocker fix too: once whereFolder compares birthtime, a live handle in these setups turns "replaced" on every root entry change.
  7. Some of the body's "single mutations each fail their test" do not hold as I ran them. "The inode compare": both inode compares survive (projectHistory.ts:327, and historyId.ts:24 with only born kept). "The open-time id check": dropping readId(...) !== projectId from the constructor (:296) survives, because the record check covers it. "Each of the two write checks": each one survives on its own, as noted under round 2. None of these is a bug at this head, but readers will rely on those claims about the suite.
  8. Already on main, but this PR rewrote the line: vite.adapter.ts:122 runs void histories.closeAll(). If a final commit fails while the dev server stops, the result is an unhandled rejection. To trigger it I opened a window, made the history dir read-only, and emitted close. The result was EACCES ... unlink, which crashes a Node process under default settings. Adding .catch as studioServer.ts:1104 does would fix it.

Checked

  • I read the full changed files, not only the diff: projectHistory.ts, historyId.ts, historyCache.ts, blobStore.ts, ownerLock.ts, atomicFile.ts, routes/history.ts, studioServer.ts, historyOwner.ts, vite.adapter.ts, commands/history.ts (guarded), and every changed test.
  • Every site that decides project identity: projectHistoryId and isRecordedFolder (inode + birthtime), the constructor after the lock wait (id + record), whereFolder (dev + inode + id, no birthtime), sweep entry and per-file checks, the first baseline, writeAs, the top of writeProjectFile, the delete check, beforeReplace, replacedAtPath, the cache, and checkout (existsSync only). whereFolder is the only site whose identity check differs from the reopen path.
  • Error mapping: withHistory (routes/history.ts:65-77) turns any refusal into a 409, and the blob route turns a rejected readBlob into a 404. On those routes, a closed or replaced handle never produces a 500 or an unhandled rejection.
  • Cleanup: close clears the noted timer, and settleAll clears the quiet, max, idle and claim timers. A failed open releases the owner lock (projectHistory.ts:1082-1085). The cache closes a replaced handle before it reopens, which releases that handle's lock. release can safely run twice (ownerLock.ts:83-87).
  • Tests at this head: studio-server src/history 102 passed and 2 skipped; src/routes 348; the full studio-server suite 797 passed and 2 skipped; CLI studioServer.test.ts plus commands/history.test.ts 60 (these need bun on PATH); Studio vite.adapter 24. These match the counts in the body. tsc --noEmit exits 0 in studio-server, studio and cli.
  • Other body claims I checked by mutation: the shared close (removing ??= fails 25 tests), the record write-back (fails "rewrites a history folder removed while open"), the device-number case (comparing dev fails "keeps the history when the disk's device number changed"), and the cache reopen (fails both host tests, but only after dist is rebuilt, because the host suites import studio-server through its node export).
  • git merge-tree --write-tree origin/main 16c949c0 is clean (origin/main at 8798e40d).
  • Birthtime on this box: Node 22 and Bun 1.3.14 report the same real birthtimeMs on ext4, and it stays put when the folder's ctime moves.
  • I also had a separate adversarial pass run in a fresh session. I included only the items above that I re-ran myself.

Not verified

  • Real no-statx environments (WSL1, some containers). Nit 6 is a simulation.
  • macOS APFS and Windows NTFS identity: inode and birthtime semantics, rsync -a moving birthtime back, and Windows ino values above 2^53. I reasoned about these from the body and did not run them.
  • A remount while the history is open. whereFolder compares dev, but the record deliberately does not. So the open handle would turn "replaced", and a direct CLI command would fail mid-run while the server reopens the same history. I only read this path.
  • vfat/exfat and overlayfs inode stability, which needs mounts I did not have.
  • The two videos. tai sampled them in round 2; I checked only the fe90b5e5 sentence.

— Rames

@miguel-heygen
miguel-heygen force-pushed the fix/history-engine-closed-or-moved branch from 16c949c to c905172 Compare September 26, 2026 18:01

@terencecho terencecho 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.

Re-reviewed #4537 at c905172c989144257198e44bc88882eefe03665d: APPROVE on code merit. The remaining live-handle recycled-inode hold from my 16c949c0 review is addressed. Engine.whereFolder() now applies the same sameFolder(ino, birthtimeMs) identity rule as the reopen check, in addition to device and project ID. An already-open history whose path is replaced by a copied-marker folder on a recycled inode therefore refuses list() through assertOpen() and restore() through the writable check, rather than reading old entries or restoring old bytes into the replacement. The earlier four filesystem interleavings were replayed on the prior head and remain guarded; this narrow delta does not change their mechanics. Merge-tree against current main is clean.

Test-evidence precision: the added test holds the handle open and changes its folder stat's birthtimeMs at the same path, then verifies list/restore refusal and unchanged bytes. Removing sameFolder makes it red, so it pins the missing comparison. Despite the test's copy/recycled-inode title, it does not physically copy a folder or cause inode reuse; my earlier controlled ext4 witness, plus the now-shared comparison, establish why that case is covered. The prior should-fixes (Studio busy-cache and CLI JSON handling for HistoryClosedError) are not resolved by this delta, but were not the blocking finding.

CI at this exact SHA has a completed successful earlier run (including Studio/support/runtime, build and typecheck); a newer rerun was still settling at review time, with no reported failure. Focused Vitest could not run in this dependency-free isolated worktree (vitest: command not found); I did not rerun the filesystem witness on this head. Rames's independent changes-requested review remains live and only they can clear it; this approval does not claim the PR is merge-ready.

— Review by tai (pr-review)

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review at c905172c, following my CHANGES_REQUESTED at 16c949c0.

Verdict: APPROVE. An open history now checks the folder with the same inode and creation time rule as reopen, and my ext4 repro of the blocker refuses at this head in 9 of 9 runs.

tai approved this head (5326902146), closing its own recycled-inode hold. This head does what both of our reviews asked for: the birthtime in the live check, plus a test that keeps the handle open and checks both list() and restore(). Credit to tai for finding it first. With this approval, neither of us has a change request left on this head.

Prior blocker

Resolved. whereFolder() now calls sameFolder() (projectHistory.ts:328). That is the same helper isRecordedFolder() uses on reopen (historyId.ts:21-22, :27), so the two checks can no longer drift apart.

Every path that checks identity goes through whereFolder():

  • list, peek, next, readBlob and pin through assertOpen() (:349-352).
  • restore, undo and step through writeAs() and writeProjectFile() (:827, :850, :858, :862).
  • The sweep's entry and per-file checks (:417, :469) and the first baseline (:384).
  • replacedAtPath() (:1032), which the cache uses to decide whether to reopen (historyCache.ts:12-15).

The user-facing path: POST .../history/restore (routes/history.ts:102-104) calls the adapter's history() (vite.adapter.ts:223-224, studioServer.ts:444), which calls historyCache.get(). There, replacedAtPath() is now true for a recycled inode, so the cache closes the old history and reopens with a fresh id.

Repro at this head, through the real ProjectHistory API on ext4 (same script as last round). The steps: open, record "Old change", copy the project with its marker to a backup and edit it, then rm -rf project && cp -r backup project with the handle still open.

  • Head: 9 of 9 runs reused the inode on the first attempt. Every run gave replacedAtPath() true, and list() and restore(START) both threw HistoryClosedError. The file kept NEW BACKUP CONTENT. A reopen got a new id and an empty list, so the live and reopen checks now agree.
  • Control, with the 16c949c0 line put back: 6 of 6 runs reused the inode, replacedAtPath() was false, list() returned ["Old change"], and restore wrote OLD BASELINE into the copy. One more control run got no reuse in 500 attempts, so how often the inode gets reused still varies.

Miguel's claim holds. The new test (projectHistory.swap.test.ts:183-193) goes red when the birthtime is removed from the live check, and also when the whole identity compare is removed. The test fakes the recycle: a mocked statSync (:17-28) shifts birthtimeMs for the same folder instead of copying it. That is enough to pin the compare, and the ext4 run above covers the real sequence.

Prior should-fixes and nits

Should-fix 1, live inode compare not pinned by a test. Partly resolved. The birthtime half is now pinned. The inode half is not. With the inode removed from whereFolder() (:328) or from sameFolder() (historyId.ts:22), all 103 history tests still pass. Because birthtime is now compared, a plain cp -a copy on ext4 is refused even without the inode check. On a file system with no creation time, though, the inode is the only thing left. On this box, tmpfs (kernel 5.15) reports birthtimeMs 0. I ran cp -a project copy, edited the copy, then mv project project-old && mv copy project with the handle open, on tmpfs:

  • Head: replacedAtPath() true, list() and restore() refuse, and the copy keeps its content.
  • Inode compare removed: replacedAtPath() false, list() returns ["Old change"], and restore writes OLD BASELINE into the copy.

A second case in the new test would pin it: same birthtime, different ino, from the same mock. I would not hold the approve on this.

Should-fix 2, sweep entry check not pinned. Unchanged. projectHistory.ts:417. Putting back if (!existsSync(this.dir)) return; leaves 103 of 103 green.

Should-fix 3, Studio's dev server caches a HistoryBusyError as null. Unchanged. vite.adapter.ts:115-118. I re-ran the name-swap probe (mv a tmp; mv b a; mv tmp b). GET a answered 404 after 5036 ms, then 404 again after 1 ms. GET b answered 200 with "edit a".

Should-fix 4, hyperframes history --json exits 1 on HistoryClosedError. Unchanged. commands/history.ts:272-280. I swapped the folder while the command waited: exit 1, empty stdout, one stderr line. In the control, where the history is held past the wait, the command exited 2 and printed the JSON refusal.

Nits

  1. Blob store keeps the other project's bytes after a refused read. Unchanged, projectHistory.ts:442-451 and :468-469. Not re-run, because the code is the same.
  2. checkout checks only existsSync (:997). Unchanged. Removing assertOpen() from peek (:990), next (:1013) or pin (:1021) each still leaves 103 of 103 green.
  3. A move in the middle of a write recreates the old path. Unchanged, blobStore.ts:28. The body still says writes refuse "instead of recreating the old path".
  4. The body's "A history closed at shutdown is not replaced" is still inaccurate. historyCache.ts:12 is unchanged.
  5. Two processes opening a never-opened project at once. Unchanged (historyId.ts:36-50, projectHistory.ts:297). Not re-run.
  6. Where birthtime falls back to ctime. Intentionally left, and now disclosed under Known limits. I checked the new text with the same simulation (birthtimeMs patched to ctimeMs) against an open handle. An in-place write kept the history. An atomic save of index.html through replaceFileAtomically (the helper routes/files.ts uses) turned replacedAtPath() true, and list() threw. That matches the body.
  7. Body mutation claims. "The inode compare" still survives, in both places (see Should-fix 1). The others I listed last round sit in code and tests this push did not touch. I did not re-run them.
  8. vite.adapter.ts:122 still calls void histories.closeAll() with no .catch. Unchanged.
  • Round 2 video sentence. Resolved. The body now says later commits add checks that do not change the recorded steps.

New findings

Blocker: none.

Should-fix: none.

Nit

  1. Parts of the body are out of date after this push. "The history records the folder's identity (device and inode)" now also covers the creation time, and the open handle checks it too. The Tests section does not list the new live test. The counts say studio-server src/history 102, but this head has 103, and the full studio-server suite has 798.

Checked

  • Delta: git diff 16c949c0 c905172c is 3 files, +33/-3 (historyId.ts, projectHistory.ts, projectHistory.swap.test.ts). Both heads have the same parent (162de1a6), so the rewrite is an amend and this diff is the whole change.
  • I read all of whereFolder(), sameFolder(), isRecordedFolder(), the constructor, and every caller listed above, plus historyCache.ts, the vite adapter's open and close, and guarded().
  • Tests at this head: studio-server src/history 103 passed, 2 skipped. The full studio-server suite: 798 passed, 2 skipped. Studio vite.adapter*: 24. CLI studioServer.test.ts plus commands/history.test.ts: 60. tsc --noEmit exits 0 in studio-server, studio and cli.
  • CI at this head: 48 pass, 12 skipped, none failing or pending.
  • git merge-tree --write-tree 8798e40da8d56b2f0f3be0b74198d0cbbec1dc06 c905172c989144257198e44bc88882eefe03665d is clean (tree 69b7a79c). main is 7 commits ahead of the base, and none of them touch the PR's files.
  • Reviews and comments: tai's four CHANGES_REQUESTED up to 16c949c0, then tai's APPROVE at this head, and my CHANGES_REQUESTED at 16c949c0. No inline or issue comments.

Mutations (studio-server src/history, 103 tests; source restored after each):

Mutation Where Red
Live check back to the 16c949c0 line (dev + inode) projectHistory.ts:328 1: the new live test
Live check with no identity compare (id only) projectHistory.ts:328 1: the new live test
sameFolder without birthtime historyId.ts:22 2: the new live test, and "gives a folder on a reused inode, created at another time, a history of its own"
Live check without inode (birthtime kept) projectHistory.ts:328 0
sameFolder without inode historyId.ts:22 0
Sweep entry check back to existsSync projectHistory.ts:417 0
No assertOpen() in peek, next or pin :990, :1013, :1021 0 each

Probes (real modules, scratch dirs):

Probe Head Mutated
ext4 recycled inode, marker copied, handle open refused, 9 of 9 16c949c0 line: adopted, 6 of 6
cp -a copy on a new inode, ext4 refused inode compare removed: refused (birthtime)
cp -a copy on a new inode, tmpfs (birthtimeMs 0) refused inode compare removed: adopted, restore wrote OLD BASELINE
Studio name swap (vite.adapter) a 404 after 5036 ms, then 404 n/a
history --json, folder swapped during the wait exit 1, empty stdout n/a
ctime used as birthtime, atomic root save, handle open handle closes (disclosed) n/a

Not verified

  • One history run failed one test, and I did not capture which one. It was my first run right after building the packages. The next 30 runs all passed: 16 one at a time, 2 while tsc ran, and 12 with four suites in parallel.
  • A real no-statx system (WSL1, old kernels, containers that block statx). Nit 6 is a simulation.
  • macOS APFS and Windows NTFS identity, including rsync -a moving birthtime back and Windows ino values above 2^53.
  • Nits 1, 3 and 5 were not re-run. The code under them is unchanged.
  • The two videos.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Sep 26, 2026
Merged via the queue into main with commit 7eb6430 Sep 26, 2026
105 checks passed
@miguel-heygen
miguel-heygen deleted the fix/history-engine-closed-or-moved branch September 26, 2026 19:10
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.

3 participants