diff --git a/packages/studio-server/src/history/blobStore.test.ts b/packages/studio-server/src/history/blobStore.test.ts index 07ced19953d..4a900ebaebc 100644 --- a/packages/studio-server/src/history/blobStore.test.ts +++ b/packages/studio-server/src/history/blobStore.test.ts @@ -1,5 +1,14 @@ // @vitest-environment node -import { createReadStream, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { + createReadStream, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,7 +19,12 @@ vi.mock("node:fs", async (importOriginal) => { }); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, rename: vi.fn(actual.rename), copyFile: vi.fn(actual.copyFile) }; + return { + ...actual, + rename: vi.fn(actual.rename), + copyFile: vi.fn(actual.copyFile), + open: vi.fn(actual.open), + }; }); import * as fs from "node:fs"; @@ -19,8 +33,11 @@ import { onRestoredInPlace, openBlobStore } from "./blobStore.js"; import { serveWhileOpen } from "../helpers/servedFiles.js"; const { renameSync: realRenameSync } = await vi.importActual("node:fs"); -const { rename: realRename, copyFile: realCopyFile } = - await vi.importActual("node:fs/promises"); +const { + rename: realRename, + copyFile: realCopyFile, + open: realOpen, +} = await vi.importActual("node:fs/promises"); const busy = () => Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); const platform = process.platform; @@ -190,3 +207,76 @@ describe("the history store's count of what it holds", () => { } }); }); + +describe("a file written to while the history reads it", () => { + let root: string; + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "hf-blob-moving-")); + }); + afterEach(() => { + vi.mocked(fsp.open).mockImplementation(realOpen); + vi.mocked(fsp.copyFile).mockImplementation(realCopyFile); + rmSync(root, { recursive: true, force: true }); + }); + + const sha = (text: string) => createHash("sha256").update(text).digest("hex"); + const stored = () => readdirSync(join(root, "blobs"), { recursive: true }).map(String); + + /** A saved file, dated a minute back, and the half and whole of the save about to replace it. */ + function savedFile(name: string, size: number) { + const path = join(root, name); + writeFileSync(path, "a".repeat(size)); + const minuteAgo = new Date(Date.now() - 60_000); + utimesSync(path, minuteAgo, minuteAgo); + return { path, half: "b".repeat(size / 2), whole: "b".repeat(size) }; + } + + it("reads a small file again when a save lands during the read, and files only the whole save", async () => { + const { path, half, whole } = savedFile("index.html", 1024); + vi.mocked(fsp.open).mockImplementationOnce(async (file, flags) => { + const handle = await realOpen(file, flags); + const read = handle.readFile.bind(handle); + handle.readFile = (async () => { + writeFileSync(path, half); + const bytes = await read(); + writeFileSync(path, whole); + return bytes; + }) as typeof handle.readFile; + return handle; + }); + const store = await openBlobStore(join(root, "blobs")); + + expect(await store.put(path)).toBe(sha(whole)); + expect(store.has(sha(half))).toBe(false); + }); + + it("copies a large file again when a save lands during the copy, and files only the whole save", async () => { + const { path, half, whole } = savedFile("clip.bin", 256 * 1024); + vi.mocked(fsp.copyFile).mockImplementationOnce(async (from, to) => { + writeFileSync(path, half); + await realCopyFile(from, to); + writeFileSync(path, whole); + }); + const store = await openBlobStore(join(root, "blobs")); + + expect(await store.put(path)).toBe(sha(whole)); + expect(store.has(sha(half))).toBe(false); + expect(stored().filter((name) => name.includes("incoming-"))).toEqual([]); + }); + + it("files nothing, and leaves it for the next sweep, while every read is cut into", async () => { + const { path } = savedFile("clip.bin", 256 * 1024); + let saves = 0; + vi.mocked(fsp.copyFile).mockImplementation(async (from, to) => { + await realCopyFile(from, to); + saves += 1; + // A new size each time: a same-size rewrite within one clock tick of the disk keeps its stat. + writeFileSync(path, "b".repeat(256 * 1024 + saves)); + }); + const store = await openBlobStore(join(root, "blobs")); + + expect(await store.put(path)).toBeNull(); + expect(saves).toBe(3); + expect(stored()).toEqual([]); + }); +}); diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 5aa1c16f19c..eeb445e0f4f 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import { constants, createReadStream, createWriteStream } from "node:fs"; +import { constants, createReadStream, createWriteStream, type Stats } from "node:fs"; import { chmod, copyFile, @@ -23,8 +23,8 @@ import { stopServingAndClose } from "../helpers/servedFiles.js"; /** File contents stored once by sha256, text and binary alike. */ export interface BlobStore { - /** Copies the file in (a clone where the file system can, for all but small files) and returns the copy's hash. */ - put(absPath: string): Promise; + /** Copies the file in (cloned where it can) and returns its hash; null while it keeps changing. */ + put(absPath: string): Promise; reference(absPath: string): Promise; has(hash: string): boolean; read(hash: string): Promise; @@ -40,6 +40,18 @@ export interface BlobStore { const BLOB_HASH = /^[0-9a-f]{64}$/; const READ_ONCE_AT_MOST_BYTES = 64 * 1024; +const READS_BEFORE_LEAVING_IT = 3; + +// Longer than the 20 ms a watcher gathers a burst for, so the sweep a save's first event starts sees it under way. +export const SETTLE_MS = 100; + +export const isSettling = (file: { mtimeMs: number; ctimeMs: number }, now: number) => + Math.abs(now - Math.max(file.mtimeMs, file.ctimeMs)) < SETTLE_MS; + +const unchanged = (before: Stats, after: Stats) => + before.size === after.size && + before.mtimeMs === after.mtimeMs && + before.ctimeMs === after.ctimeMs; export function blobPath(dir: string, hash: string): string { // Checked where the path is joined, so no caller can read or write outside the store. @@ -55,11 +67,15 @@ async function hashFile(path: string): Promise { return hash.digest("hex"); } -async function readIfSmall(path: string): Promise<{ bytes: Buffer; mode: number } | null> { +async function readIfSmall( + path: string, +): Promise<{ bytes: Buffer; mode: number; moved: boolean } | null> { const handle = await open(path, "r"); try { - const { size, mode } = await handle.stat(); - return size > READ_ONCE_AT_MOST_BYTES ? null : { bytes: await handle.readFile(), mode }; + const before = await handle.stat(); + if (before.size > READ_ONCE_AT_MOST_BYTES) return null; + const bytes = await handle.readFile(); + return { bytes, mode: before.mode, moved: !unchanged(before, await handle.stat()) }; } finally { await handle.close(); } @@ -194,28 +210,43 @@ export async function openBlobStore(dir: string): Promise { return hash; }; - return { - async put(absPath) { - const small = await readIfSmall(absPath); - if (small) return putBytes(small.bytes, small.mode); - // Hash the copy, not the source, so a write racing the copy can never file bytes under the wrong hash. - const temp = join(dir, `incoming-${randomUUID()}`); - // Recreated if removed while open, so a missing folder is never mistaken for a deleted project file. - await mkdir(dir, { recursive: true }); - await cloneOrStream(absPath, temp).catch(async (error) => { + const putIfUnchanged = async (absPath: string): Promise => { + const small = await readIfSmall(absPath); + if (small) return small.moved ? null : putBytes(small.bytes, small.mode); + // Hash the copy, not the source, so a write racing the copy can never file bytes under the wrong hash. + const temp = join(dir, `incoming-${randomUUID()}`); + // Recreated if removed while open, so a missing folder is never mistaken for a deleted project file. + await mkdir(dir, { recursive: true }); + const before = await stat(absPath); + const moved = await cloneOrStream(absPath, temp) + .then(async () => !unchanged(before, await stat(absPath))) + .catch(async (error) => { await rm(temp, { force: true }); throw error; }); - const hash = await hashFile(temp); - if (sizes.has(hash)) { - await rm(temp, { force: true }); - return hash; - } - await mkdir(dirname(blobPath(dir, hash)), { recursive: true }); - const size = (await stat(temp)).size; - await renameWithRetryAsync(temp, blobPath(dir, hash)); - file(hash, size); + if (moved) { + await rm(temp, { force: true }); + return null; + } + const hash = await hashFile(temp); + if (sizes.has(hash)) { + await rm(temp, { force: true }); return hash; + } + await mkdir(dirname(blobPath(dir, hash)), { recursive: true }); + const size = (await stat(temp)).size; + await renameWithRetryAsync(temp, blobPath(dir, hash)); + file(hash, size); + return hash; + }; + + return { + async put(absPath) { + for (let read = 0; read < READS_BEFORE_LEAVING_IT; read += 1) { + const hash = await putIfUnchanged(absPath); + if (hash !== null) return hash; + } + return null; }, reference: (absPath) => hashFile(absPath), has: (hash) => sizes.has(hash) || asideSizes.has(hash), diff --git a/packages/studio-server/src/history/projectHistory.saving.test.ts b/packages/studio-server/src/history/projectHistory.saving.test.ts new file mode 100644 index 00000000000..9953e5e53bc --- /dev/null +++ b/packages/studio-server/src/history/projectHistory.saving.test.ts @@ -0,0 +1,175 @@ +// @vitest-environment node +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { open as openFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fileContentVersion, hashOfVersion } from "../helpers/fileVersion"; +import { SETTLE_MS } from "./blobStore"; +import { START, type HistoryWho } from "./historyLog"; +import { openProjectHistory, type ProjectHistory } from "./projectHistory"; + +// Per file name, how many more copies find it changing during every read. +const changing = vi.hoisted(() => new Map()); +vi.mock("./blobStore", async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + openBlobStore: async (dir: string) => { + const store = await real.openBlobStore(dir); + const put = async (path: string) => { + const left = changing.get(basename(path)) ?? 0; + if (left === 0) return store.put(path); + changing.set(basename(path), left - 1); + return null; + }; + return { ...store, put }; + }, + }; +}); + +const you: HistoryWho = { kind: "person", name: "You" }; +const agent: HistoryWho = { kind: "agent", name: "Agent" }; +const cleanup: Array<() => unknown> = []; + +afterEach(async () => { + for (const step of cleanup.splice(0).reverse()) await step(); + changing.clear(); +}); + +function tempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + cleanup.push(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +async function project(options = {}) { + const projectDir = tempDir("hf-history-project-"); + const historyRoot = tempDir("hf-history-root-"); + writeFileSync(join(projectDir, "index.html"), "

Hello

"); + const history = await openProjectHistory({ projectDir, historyRoot, ...options }); + cleanup.push(() => history.close()); + const write = (content: string) => writeFileSync(join(projectDir, "index.html"), content); + const read = () => readFileSync(join(projectDir, "index.html"), "utf-8"); + return { projectDir, history, write, read, index: join(projectDir, "index.html") }; +} + +describe("a file an outside save has emptied but not yet written", () => { + const sha = (text: string) => hashOfVersion(fileContentVersion(text)); + const kept = (history: ProjectHistory) => + history.list().map((entry) => [entry.label, entry.files.map((file) => file.after)]); + const outside = "Changed outside the app"; + + /** A project whose clock stops at index.html's last change, as a sweep right after that change sees it. */ + async function saving() { + let clock: number | undefined; + const opened = await project({ now: () => clock ?? Date.now() }); + const changedNow = () => { + const { mtimeMs, ctimeMs } = statSync(opened.index); + clock = Math.max(mtimeMs, ctimeMs); + }; + const later = (ms: number) => (clock = (clock ?? Date.now()) + ms); + return { ...opened, changedNow, later }; + } + + it("files no empty version for a save paused between emptying the file and writing it", async () => { + const { history, index, read, changedNow } = await saving(); + const save = await openFile(index, "w"); + changedNow(); + await history.flush(); + await history.claim(you, "Another file", []); + await save.write("

Saved

"); + await save.close(); + await history.claim(you, "Edited", ["index.html"]); + expect(kept(history)).toEqual([["Edited", [sha("

Saved

")]]]); + await history.step("back", you); + expect(read()).toBe("

Hello

"); + }); + + it("files a save paused after its first bytes as it stands: only an empty file waits", async () => { + const { history, index, changedNow } = await saving(); + const save = await openFile(index, "w"); + await save.write("

Sa"); + changedNow(); + await history.flush(); + await save.write("ved

"); + await save.close(); + await history.claim(you, "Edited", ["index.html"]); + expect(kept(history)).toEqual([ + [outside, [sha("

Sa")]], + ["Edited", [sha("

Saved

")]], + ]); + }); + + it("records a file emptied for good by the next sweep, once it has been quiet a moment", async () => { + const { history, index, changedNow, later } = await saving(); + await (await openFile(index, "w")).close(); + changedNow(); + await history.flush(); + expect(kept(history)).toEqual([]); + later(SETTLE_MS); + await vi.waitFor( + () => expect(history.next("back", you)?.files.map((file) => file.after)).toEqual([sha("")]), + { timeout: 5000, interval: 20 }, + ); + }); + + it("records an empty save the moment a claim names it", async () => { + const { history, write, changedNow } = await saving(); + write(""); + changedNow(); + await history.claim(you, "Cleared", ["index.html"]); + expect(kept(history)).toEqual([["Cleared", [sha("")]]]); + }); + + it("files a window's empty write in that window, ended at once by close or flush", async () => { + const { history, write, changedNow } = await saving(); + const closed = await history.beginWindow(agent, "Closed turn"); + write(""); + changedNow(); + await closed.close(); + write("

Two

"); + await history.claim(you, "Typed", ["index.html"]); + await history.beginWindow(agent, "Flushed turn"); + write(""); + changedNow(); + await history.flush(); + expect(kept(history)).toEqual([ + ["Closed turn", [sha("")]], + ["Typed", [sha("

Two

")]], + ["Flushed turn", [sha("")]], + ]); + }); + + it("still sees a file emptied just before an undo, and refuses to write over it", async () => { + const { history, write, read, changedNow } = await saving(); + const window = await history.beginWindow(you, "Your edit"); + write("v2"); + const mine = await window.close(); + if (!mine) throw new Error("the window recorded nothing"); + write(""); + changedNow(); + expect((await history.undo(mine.id, { who: you })).ok).toBe(false); + expect(read()).toBe(""); + }); +}); + +describe("a file still being written when the history first opens", () => { + it("is not filed as added outside: its first whole copy is where its history starts", async () => { + const projectDir = tempDir("hf-history-project-"); + const historyRoot = tempDir("hf-history-root-"); + const media = join(projectDir, "media.txt"); + writeFileSync(media, "recorded"); + changing.set("media.txt", 3); + const history = await openProjectHistory({ projectDir, historyRoot }); + cleanup.push(() => history.close()); + await vi.waitFor(() => expect(history.peek(START)).toHaveProperty("media.txt"), { + timeout: 5000, + interval: 20, + }); + await history.flush(); + expect(history.list()).toEqual([]); + await history.step("back", you); + expect(existsSync(media)).toBe(true); + }); +}); diff --git a/packages/studio-server/src/history/projectHistory.ts b/packages/studio-server/src/history/projectHistory.ts index c1c6ed33ffd..4c97dea53ad 100644 --- a/packages/studio-server/src/history/projectHistory.ts +++ b/packages/studio-server/src/history/projectHistory.ts @@ -28,7 +28,7 @@ import { affectsProjectSignature, listProjectFiles, } from "../helpers/projectSignature.js"; -import { openBlobStore, readBlob, type BlobStore } from "./blobStore.js"; +import { isSettling, openBlobStore, readBlob, SETTLE_MS, type BlobStore } from "./blobStore.js"; import { isUncopied, uncopiedOnlyHashes } from "./uncopied.js"; import { pruneGoneProjectHistoriesDaily } from "./pruneHistories.js"; import { @@ -212,6 +212,9 @@ const statKey = (file: { size: number; mtimeMs: number; ctimeMs: number }, swept ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`; +type Copied = { hash: string } | "gone" | "changing"; +const ADOPT_RETRY_AT_MOST_MS = 30_000; + const FIRST_COPY_SLOTS_IN_PROCESS = 4; let firstCopiesRunning = 0; const firstCopiesWaiting: (() => void)[] = []; @@ -551,12 +554,12 @@ class Engine { async takeInUnnamedLedger(): Promise { const named = (path: string) => this.log.entries.some((entry) => entry.files.some((file) => file.path === path)); - const listed = () => historyFiles(this.dir).some((file) => file.path === MEDIA_LEDGER); - const hash = - this.log.baseline.has(MEDIA_LEDGER) || named(MEDIA_LEDGER) || !listed() - ? null - : await this.storeIfPresent(MEDIA_LEDGER); - if (hash !== null) this.log.baseline.set(MEDIA_LEDGER, hash); + const ledger = historyFiles(this.dir).find((file) => file.path === MEDIA_LEDGER); + if (ledger && !this.log.baseline.has(MEDIA_LEDGER) && !named(MEDIA_LEDGER)) { + const copied = await this.storeIfPresent(MEDIA_LEDGER); + if (typeof copied === "object") this.log.baseline.set(MEDIA_LEDGER, copied.hash); + else if (copied === "changing") this.adoptMediaInBackground([ledger], this.now()); + } this.log.keepsLedger = true; this.persistLog(); } @@ -570,18 +573,20 @@ class Engine { ); // Nothing names these copies until the log below is written, so one look at the end covers the whole copy. if (this.whereFolder() !== "here") throw this.replaced(); + const owed: ListedFile[] = []; copied.forEach((file, i) => { const result = hashes[i]!; if (result.status === "rejected") throw result.reason; - if (result.value !== null) - this.tracked.set(file.path, { hash: result.value, stat: statKey(file, sweptAt) }); + if (result.value === "changing") owed.push(file); + else if (result.value !== "gone") + this.tracked.set(file.path, { hash: result.value.hash, stat: statKey(file, sweptAt) }); }); this.log.baseline = this.manifest(); this.log.keepsLedger = true; this.persistLog(); this.saveStatCache(); this.adoptMediaInBackground( - files.filter((file) => file.size > COPY_LATER_ABOVE_BYTES), + [...owed, ...files.filter((file) => file.size > COPY_LATER_ABOVE_BYTES)], sweptAt, ); } @@ -602,13 +607,14 @@ class Engine { replaceFileAtomically(file, JSON.stringify([...this.adopting.keys()]), 0o644); } - async adopt(file: ListedFile, sweptAt: number) { + async adopt(file: ListedFile, sweptAt: number, tries = 0) { try { if (this.whereFolder() !== "here") return; - const stored = await this.storeIfPresent(file.path); + const copied = await this.storeIfPresent(file.path); await this.queue(async () => { // Still named for the next open until recorded, so a failed record is never logged as added. - await this.recordAdopted(file, sweptAt, stored); + if ((await this.recordAdopted(file, sweptAt, copied)) === "changing") + return this.adoptLater(file, sweptAt, tries + 1); this.adopting.delete(file.path); this.saveAdopting(); }); @@ -617,12 +623,23 @@ class Engine { } } - async recordAdopted(file: ListedFile, sweptAt: number, stored: string | null) { - if (stored === null || this.whereFolder() !== "here") return; + /** A file still changing stays named until a whole copy of it lands; the next open retries it. */ + adoptLater(file: ListedFile, sweptAt: number, tries: number): void { + if (this.closing) return; + const timer = setTimeout( + () => void this.adopt(file, sweptAt, tries), + Math.min(SETTLE_MS * 2 ** tries, ADOPT_RETRY_AT_MOST_MS), + ); + timer.unref?.(); + } + + async recordAdopted(file: ListedFile, sweptAt: number, first: Copied): Promise { + if (typeof first === "string" || this.whereFolder() !== "here") return first; // A prune that ran while this copy waited for the queue may have taken its bytes. - const kept = this.blobs.has(stored) || isUncopied(file.path, file.size); - const hash = kept ? stored : await this.storeIfPresent(file.path); - if (hash === null) return; + const kept = this.blobs.has(first.hash) || isUncopied(file.path, file.size); + const copied = kept ? first : await this.storeIfPresent(file.path); + if (typeof copied === "string") return copied; + const { hash } = copied; const change = { path: file.path, before: null, after: hash }; const found = this.overwrittenBy(change, undefined, new Set()); const start = found && this.blobs.has(found) ? found : hash; @@ -631,6 +648,7 @@ class Engine { if (start !== hash) addChange(this.outsideGroup(), file.path, start, hash); this.persistLog(baselineRecord(this.log)); this.saveStatCache(); + return copied; } manifest(): Manifest { @@ -666,7 +684,7 @@ class Engine { * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole * project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands. */ - async sweep(): Promise { + async sweep(asItStands: "every" | ReadonlySet = new Set()): Promise { // A folder moved or removed was not emptied, and another project at its path is none of this history's. if (this.whereFolder() !== "here") return; const sweptAt = this.now(); @@ -693,8 +711,20 @@ class Engine { ...removed, ...seen.map((file) => ({ at: appearedAt(file), path: file.path, file })), ].sort((a, b) => a.at - b.at); + // An emptied file may be a save not yet written; a window's write, a claimed path and an undo take it as it is. + const waitsForQuiet = (path: string, file: ListedFile, at: number) => + file.size === 0 && + asItStands !== "every" && + !asItStands.has(path) && + isSettling(file, sweptAt) && + !this.windows.some((open) => takesWrite(open, at)); let changed = false; + let waiting = false; for (const { at, path, file } of events) { + if (file && waitsForQuiet(path, file, at)) { + waiting = true; + continue; + } if (file) { changed = (await this.observe(path, statKey(file, sweptAt), at)) || changed; continue; @@ -706,16 +736,19 @@ class Engine { changed = true; } if (changed) this.saveStatCache(); + if (waiting) this.sweepIn(SETTLE_MS); } - /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */ - async storeIfPresent(path: string): Promise { + /** The file's hash once copied in, or why there is none: gone, or still changing through every read. */ + async storeIfPresent(path: string): Promise { try { const abs = join(this.dir, path); const { size } = await stat(abs); - return await (isUncopied(path, size) ? this.blobs.reference(abs) : this.blobs.put(abs)); + if (isUncopied(path, size)) return { hash: await this.blobs.reference(abs) }; + const hash = await this.blobs.put(abs); + return hash === null ? "changing" : { hash }; } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return "gone"; throw error; } } @@ -724,8 +757,9 @@ class Engine { const known = this.tracked.get(path) ?? { hash: null, stat: null }; if (stat && known.stat === stat) return false; const heard = this.overwritten.get(path); - const hash = await this.storeIfPresent(path); - if (hash === null || this.whereFolder() !== "here") return false; + const copied = await this.storeIfPresent(path); + if (typeof copied === "string" || this.whereFolder() !== "here") return false; + const { hash } = copied; this.tracked.set(path, { hash, stat }); this.forgetWritesBefore(path, hash, heard); if (known.hash !== hash) await this.record(path, known.hash, hash, at); @@ -753,7 +787,7 @@ class Engine { paths: readonly string[], { coalesceKey, idleMs, overwrote = {}, targets = [] }: ClaimOptions, ): Promise<{ id: string } | null> { - await this.sweep(); + await this.sweep(new Set(paths.map((path) => this.logPath(path)))); const taken = await this.takeClaimed(who, paths, overwrote); if (!taken.length) return this.claimNothing(coalesceKey); const held = this.heldFor(coalesceKey, taken); @@ -1000,7 +1034,7 @@ class Engine { /** Before an operation or a window: every write is filed and every pending change committed, in write order. */ async settle(): Promise { - await this.sweep(); + await this.sweep("every"); await this.commitClaim(); await this.commitPending(); } @@ -1010,10 +1044,15 @@ class Engine { const absolute = resolve(this.dir, path); if (this.notedTimer || !affectsProjectSignature(this.dir, absolute)) return; if (!isHistoryPath(relative(this.dir, absolute).split(sep).join("/"))) return; + this.sweepIn(20); + } + + sweepIn(ms: number): void { + if (this.notedTimer || this.closed) return; this.notedTimer = setTimeout(() => { this.notedTimer = null; this.background(() => this.sweep()); - }, 20); + }, ms); this.notedTimer.unref?.(); }