Skip to content

Commit 825a6eb

Browse files
Add garbage collection to SyncOPFSFileSystem to prevent unbounded disk growth
The arena file previously only grew and never shrank. When files were overwritten or deleted, old pages were added to the free list but the underlying OPFS file retained its full size, causing massive disk usage over time. - Auto-reclaim trailing free pages after every free (shrinkArenaTrailingFreeSpace) - Add gc() for full compaction/defragmentation - Add getStats() for monitoring arena usage - Add tests proving sequential edits don't grow storage disproportionately
1 parent 68d5537 commit 825a6eb

3 files changed

Lines changed: 282 additions & 7 deletions

File tree

spec/MultiTabWorkerBroker.spec.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -235,29 +235,42 @@ describe("MultiTabWorkerBroker", () => {
235235

236236
// Wait for follower to become leader and initialize its worker
237237
await broker2LeaderPromise;
238-
// Wait for broker2 to be fully ready as leader
239238
await waitFor(
240239
() => broker2.isLeader,
241240
(isLeader) => isLeader === true,
242-
{ timeout: 500, message: "Broker2 did not become leader" }
241+
{ timeout: 3000, message: "Broker2 did not become leader" }
243242
);
244-
// Give worker additional time to be fully ready
245-
await new Promise((resolve) => setTimeout(resolve, 100));
246243
expect(broker2.isLeader).toBe(true);
247244

248-
// Verify new leader can communicate
245+
// Verify worker is responsive before proceeding — avoids flaky fixed delays
246+
const probeMessages: any[] = [];
247+
const probeConn = broker2.createConnection();
248+
probeConn.reader.listen((msg) => probeMessages.push(msg));
249+
await probeConn.writer.write({
250+
jsonrpc: "2.0",
251+
id: 9999,
252+
method: "echo",
253+
params: { probe: true },
254+
} as any);
255+
await waitFor(
256+
() => probeMessages,
257+
(msgs) => msgs.some((m) => m.id === 9999),
258+
{ timeout: 3000, message: "Worker not responsive after leader promotion" }
259+
);
260+
probeConn.dispose();
261+
262+
// Now verify new leader can communicate on the original connection
249263
await conn2.writer.write({
250264
jsonrpc: "2.0",
251265
id: 1,
252266
method: "echo",
253267
params: { promoted: true },
254268
} as any);
255269

256-
// Wait for the response with retry logic
257270
await waitFor(
258271
() => followerMessages,
259272
(msgs) => msgs.some((m) => m.id === 1 && m.result?.promoted === true),
260-
{ timeout: 2000, message: "Expected response from promoted leader not received" }
273+
{ timeout: 3000, message: "Expected response from promoted leader not received" }
261274
);
262275

263276
expect(followerMessages).toContainEqual(expect.objectContaining({ id: 1, result: { promoted: true } }));

spec/vfs.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,3 +430,177 @@ describe.each([
430430
expect(vfs.readFileSync(link, { encoding: "utf8" })).toBe("data");
431431
});
432432
});
433+
434+
describe("sync opfs gc", () => {
435+
let vfs: SyncOPFSFileSystem;
436+
437+
beforeEach(async () => {
438+
vfs = new SyncOPFSFileSystem("gadget-gc");
439+
await vfs.init();
440+
});
441+
442+
afterEach(async () => {
443+
await vfs.close();
444+
});
445+
446+
test("getStats reports arena usage", async () => {
447+
await vfs.writeFileEnsuringDirectories("/stats/file.ts", "hello world");
448+
const stats = vfs.getStats();
449+
expect(stats.arenaBytes).toBeGreaterThan(0);
450+
expect(stats.allocatedBytes).toBeGreaterThan(0);
451+
});
452+
453+
test("arena shrinks when trailing pages are freed", async () => {
454+
// Write a file to grow the arena, then delete it
455+
const bigData = "x".repeat(200_000); // ~200KB, multiple pages
456+
await vfs.writeFileEnsuringDirectories("/shrink/big.ts", bigData);
457+
const afterWrite = vfs.getStats();
458+
459+
await vfs.deleteFile("/shrink/big.ts");
460+
const afterDelete = vfs.getStats();
461+
462+
// Arena should have shrunk after freeing trailing pages
463+
expect(afterDelete.arenaBytes).toBeLessThanOrEqual(afterWrite.arenaBytes);
464+
expect(afterDelete.allocatedBytes).toBe(0);
465+
});
466+
467+
test("overwriting a file does not leak arena space indefinitely", async () => {
468+
const path = "/overwrite/file.ts";
469+
const data = "x".repeat(100_000);
470+
471+
await vfs.writeFileEnsuringDirectories(path, data);
472+
const baseline = vfs.getStats();
473+
474+
// Overwrite many times — arena should not grow unboundedly
475+
for (let i = 0; i < 20; i++) {
476+
vfs.writeFileSync(path, data + i);
477+
}
478+
const afterOverwrites = vfs.getStats();
479+
480+
// Arena should not be significantly larger than baseline
481+
// (some growth is expected due to copy-on-write, but not 20x)
482+
expect(afterOverwrites.arenaBytes).toBeLessThan(baseline.arenaBytes * 3);
483+
});
484+
485+
test("many sequential small edits to a single file do not grow arena disproportionately", async () => {
486+
const path = "/edits/document.ts";
487+
// Start with a ~10KB file, simulating a source file
488+
const baseContent = "// line\n".repeat(1250);
489+
await vfs.writeFileEnsuringDirectories(path, baseContent);
490+
const baseline = vfs.getStats();
491+
492+
// Simulate 500 small incremental edits (typo fixes, adding a line, etc.)
493+
// Each edit changes only a few characters but rewrites the whole file
494+
for (let i = 0; i < 500; i++) {
495+
const edited = baseContent.slice(0, 100) + `// edit ${i}\n` + baseContent.slice(100);
496+
vfs.writeFileSync(path, edited);
497+
}
498+
499+
const afterEdits = vfs.getStats();
500+
501+
// The file is ~10KB. After 500 overwrites, a naive system would accumulate
502+
// ~5MB of dead data. With GC, the arena should stay close to baseline.
503+
// We allow up to 2x the baseline to account for copy-on-write transients
504+
// and page alignment overhead, but certainly not 500x.
505+
expect(afterEdits.arenaBytes).toBeLessThan(baseline.arenaBytes * 2);
506+
507+
// Allocated bytes should reflect only the single live file (~10KB, page-aligned)
508+
expect(afterEdits.allocatedBytes).toBeLessThanOrEqual(baseline.allocatedBytes * 2);
509+
510+
// The file content should be the last edit, proving correctness
511+
const finalContent = baseContent.slice(0, 100) + `// edit 499\n` + baseContent.slice(100);
512+
expect(vfs.readFileSync(path, { encoding: "utf8" })).toBe(finalContent);
513+
});
514+
515+
test("many sequential small edits across multiple files do not grow arena disproportionately", async () => {
516+
const fileCount = 10;
517+
const editsPerFile = 100;
518+
const baseContent = "x".repeat(5_000); // 5KB per file
519+
520+
// Create all files
521+
for (let f = 0; f < fileCount; f++) {
522+
await vfs.writeFileEnsuringDirectories(`/multi/file${f}.ts`, baseContent);
523+
}
524+
const baseline = vfs.getStats();
525+
526+
// Round-robin edits across files — simulates a dev editing multiple open files
527+
for (let round = 0; round < editsPerFile; round++) {
528+
for (let f = 0; f < fileCount; f++) {
529+
vfs.writeFileSync(`/multi/file${f}.ts`, baseContent + `// r${round}`);
530+
}
531+
}
532+
533+
const afterEdits = vfs.getStats();
534+
535+
// 10 files × 5KB = 50KB live data. 1000 total writes would be 5MB without GC.
536+
// Arena should stay reasonable — well under 4x the baseline.
537+
expect(afterEdits.arenaBytes).toBeLessThan(baseline.arenaBytes * 4);
538+
539+
// Verify every file has the correct final content
540+
for (let f = 0; f < fileCount; f++) {
541+
expect(vfs.readFileSync(`/multi/file${f}.ts`, { encoding: "utf8" })).toBe(baseContent + `// r${editsPerFile - 1}`);
542+
}
543+
});
544+
545+
test("gc() compacts fragmented arena", async () => {
546+
// Create several files, delete alternating ones to create fragmentation
547+
for (let i = 0; i < 10; i++) {
548+
await vfs.writeFileEnsuringDirectories(`/frag/file${i}.ts`, "x".repeat(70_000));
549+
}
550+
// Delete even-numbered files to fragment
551+
for (let i = 0; i < 10; i += 2) {
552+
await vfs.deleteFile(`/frag/file${i}.ts`);
553+
}
554+
const beforeGc = vfs.getStats();
555+
556+
vfs.gc();
557+
const afterGc = vfs.getStats();
558+
559+
// After compaction, arena should be smaller or equal
560+
expect(afterGc.arenaBytes).toBeLessThanOrEqual(beforeGc.arenaBytes);
561+
// Fragmentation should be reduced (0 or 1 free fragment)
562+
expect(afterGc.freeFragments).toBeLessThanOrEqual(1);
563+
564+
// All remaining files should still be readable
565+
for (let i = 1; i < 10; i += 2) {
566+
const content = vfs.readFileSync(`/frag/file${i}.ts`, { encoding: "utf8" });
567+
expect(content).toBe("x".repeat(70_000));
568+
}
569+
});
570+
571+
test("gc() on empty filesystem is a no-op", () => {
572+
vfs.gc();
573+
const stats = vfs.getStats();
574+
expect(stats.allocatedBytes).toBe(0);
575+
});
576+
577+
test("exportIndex/importIndex preserves data after gc", async () => {
578+
for (let i = 0; i < 5; i++) {
579+
await vfs.writeFileEnsuringDirectories(`/persist/file${i}.ts`, `content-${i}`);
580+
}
581+
await vfs.deleteFile("/persist/file2.ts");
582+
583+
vfs.gc();
584+
const snapshot = vfs.exportIndex();
585+
586+
// Create a fresh instance, import the snapshot
587+
const vfs2 = new SyncOPFSFileSystem("gadget-gc");
588+
// Re-use the same arena handle by closing and reopening
589+
await vfs.close();
590+
await vfs2.init();
591+
vfs2.importIndex(snapshot);
592+
593+
for (let i = 0; i < 5; i++) {
594+
if (i === 2) {
595+
expect(vfs2.existsSync(`/persist/file${i}.ts`)).toBe(false);
596+
} else {
597+
expect(vfs2.readFileSync(`/persist/file${i}.ts`, { encoding: "utf8" })).toBe(`content-${i}`);
598+
}
599+
}
600+
601+
await vfs2.close();
602+
// Re-open original for teardown
603+
vfs = new SyncOPFSFileSystem("gadget-gc");
604+
await vfs.init();
605+
});
606+
});

src/SyncOPFSFileSystem.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,94 @@ export class SyncOPFSFileSystem implements VirtualFileSystem {
363363
private freeExtents(extents: FileExtent[]) {
364364
for (const e of extents) this.freeList.push({ startPage: e.startPage, pageCount: e.pageCount });
365365
this.coalesceFreeList();
366+
this.shrinkArenaTrailingFreeSpace();
367+
}
368+
369+
/** Shrink the arena file by truncating trailing free pages */
370+
private shrinkArenaTrailingFreeSpace() {
371+
if (this.freeList.length === 0) return;
372+
const arenaBytes = this.arenaHandle.getSize();
373+
const totalPages = Math.floor(arenaBytes / PAGE_SIZE);
374+
if (totalPages === 0) return;
375+
376+
// The free list is sorted after coalesce — check if the last range reaches the arena end
377+
const last = this.freeList[this.freeList.length - 1];
378+
if (last.startPage + last.pageCount !== totalPages) return;
379+
380+
// Keep a minimum arena size to avoid thrashing (e.g. 1 MiB / PAGE_SIZE pages)
381+
const minPages = Math.ceil((1024 * 1024) / PAGE_SIZE);
382+
const reclaimablePages = last.pageCount;
383+
const newTotalPages = Math.max(totalPages - reclaimablePages, minPages);
384+
const pagesReclaimed = totalPages - newTotalPages;
385+
386+
if (pagesReclaimed <= 0) return;
387+
388+
this.arenaHandle.truncate(newTotalPages * PAGE_SIZE);
389+
390+
if (pagesReclaimed === last.pageCount) {
391+
this.freeList.pop();
392+
} else {
393+
last.pageCount -= pagesReclaimed;
394+
}
395+
}
396+
397+
/**
398+
* Full compaction: relocates all live file data to the start of the arena,
399+
* eliminates fragmentation, and truncates the arena file.
400+
* Call this periodically or after large batch deletions for maximum space savings.
401+
*/
402+
gc() {
403+
// Collect all live file extents in the tree
404+
const liveFiles: { node: FileNode; oldExtents: FileExtent[] }[] = [];
405+
const walk = (node: Node) => {
406+
if (node.type === "file" && node.extents.length > 0) {
407+
liveFiles.push({ node, oldExtents: node.extents.map((e) => ({ ...e })) });
408+
} else if (node.type === "dir") {
409+
for (const child of node.children.values()) walk(child);
410+
}
411+
};
412+
walk(this.root);
413+
414+
// Sort live files by their first extent's start page for sequential reads
415+
liveFiles.sort((a, b) => a.oldExtents[0].startPage - b.oldExtents[0].startPage);
416+
417+
// Relocate each file's data contiguously from page 0 onward
418+
let nextPage = 0;
419+
for (const { node, oldExtents } of liveFiles) {
420+
const data = this.readFileBytes(node);
421+
if (!data) continue;
422+
423+
const pagesNeeded = Math.ceil(node.size / PAGE_SIZE);
424+
const newExtent: FileExtent = { startPage: nextPage, pageCount: pagesNeeded };
425+
426+
// Write data to its new location
427+
this.writeBytesToExtents([newExtent], data);
428+
node.extents = [newExtent];
429+
nextPage += pagesNeeded;
430+
}
431+
432+
// Rebuild free list and truncate
433+
const usedPages = nextPage;
434+
const minPages = Math.max(usedPages, Math.ceil((1024 * 1024) / PAGE_SIZE));
435+
this.arenaHandle.truncate(minPages * PAGE_SIZE);
436+
this.allocatedBytes = usedPages * PAGE_SIZE;
437+
438+
this.freeList = [];
439+
if (minPages > usedPages) {
440+
this.freeList.push({ startPage: usedPages, pageCount: minPages - usedPages });
441+
}
442+
}
443+
444+
/** Returns stats about arena usage for monitoring */
445+
getStats(): { arenaBytes: number; allocatedBytes: number; freeBytes: number; freeFragments: number } {
446+
const arenaBytes = this.arenaHandle.getSize();
447+
const freePages = this.freeList.reduce((sum, r) => sum + r.pageCount, 0);
448+
return {
449+
arenaBytes,
450+
allocatedBytes: this.allocatedBytes,
451+
freeBytes: freePages * PAGE_SIZE,
452+
freeFragments: this.freeList.length,
453+
};
366454
}
367455

368456
private coalesceFreeList() {

0 commit comments

Comments
 (0)