@@ -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+ } ) ;
0 commit comments