@@ -785,4 +785,186 @@ describe("flagged storage extracted helpers", () => {
785785 await removeWithRetry ( fixtureRoot , { recursive : true , force : true } ) ;
786786 }
787787 } ) ;
788+
789+ it ( "retries transient backup read locks before recovering flagged storage" , async ( ) => {
790+ const { loadFlaggedAccountsState } = await import (
791+ "../lib/storage/flagged-storage-io.js"
792+ ) ;
793+ const fixtureRoot = join (
794+ tmpdir ( ) ,
795+ `codex-flagged-io-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ,
796+ ) ;
797+ const flaggedPath = join ( fixtureRoot , "flagged.json" ) ;
798+ const backupPath = `${ flaggedPath } .bak` ;
799+ const resetMarkerPath = `${ flaggedPath } .reset` ;
800+ const originalReadFile = fs . readFile . bind ( fs ) ;
801+ const logError = vi . fn ( ) ;
802+ const logInfo = vi . fn ( ) ;
803+ const persistRecoveredBackup = vi . fn ( async ( ) => true ) ;
804+ let backupReadAttempts = 0 ;
805+
806+ try {
807+ await fs . mkdir ( fixtureRoot , { recursive : true } ) ;
808+ await fs . writeFile (
809+ backupPath ,
810+ JSON . stringify ( {
811+ version : 1 ,
812+ accounts : [
813+ {
814+ refreshToken : "backup-token" ,
815+ flaggedAt : 1 ,
816+ addedAt : 1 ,
817+ lastUsed : 1 ,
818+ } ,
819+ ] ,
820+ } ) ,
821+ "utf8" ,
822+ ) ;
823+
824+ const readSpy = vi
825+ . spyOn ( fs , "readFile" )
826+ . mockImplementation ( async ( ...args ) => {
827+ const [ targetPath ] = args ;
828+ if ( targetPath === backupPath ) {
829+ backupReadAttempts += 1 ;
830+ if ( backupReadAttempts === 1 ) {
831+ const error = new Error ( "EBUSY backup read" ) as NodeJS . ErrnoException ;
832+ error . code = "EBUSY" ;
833+ throw error ;
834+ }
835+ }
836+ return originalReadFile ( ...args ) ;
837+ } ) ;
838+
839+ await expect (
840+ loadFlaggedAccountsState ( {
841+ path : flaggedPath ,
842+ legacyPath : `${ flaggedPath } .legacy` ,
843+ resetMarkerPath,
844+ normalizeFlaggedStorage : ( data ) => data as never ,
845+ persistRecoveredBackup,
846+ saveFlaggedAccounts : vi . fn ( async ( ) => { } ) ,
847+ logError,
848+ logInfo,
849+ } ) ,
850+ ) . resolves . toEqual ( {
851+ version : 1 ,
852+ accounts : [
853+ {
854+ refreshToken : "backup-token" ,
855+ flaggedAt : 1 ,
856+ addedAt : 1 ,
857+ lastUsed : 1 ,
858+ } ,
859+ ] ,
860+ } ) ;
861+ expect ( backupReadAttempts ) . toBe ( 2 ) ;
862+ expect ( persistRecoveredBackup ) . toHaveBeenCalledTimes ( 1 ) ;
863+ expect ( logInfo ) . toHaveBeenCalledWith (
864+ "Recovered flagged account storage from backup" ,
865+ expect . objectContaining ( { from : backupPath , to : flaggedPath , accounts : 1 } ) ,
866+ ) ;
867+ expect ( logError ) . not . toHaveBeenCalled ( ) ;
868+
869+ readSpy . mockRestore ( ) ;
870+ } finally {
871+ await removeWithRetry ( fixtureRoot , { recursive : true , force : true } ) ;
872+ }
873+ } ) ;
874+
875+ it ( "retries transient legacy read locks before migrating flagged storage" , async ( ) => {
876+ const { loadFlaggedAccountsState } = await import (
877+ "../lib/storage/flagged-storage-io.js"
878+ ) ;
879+ const fixtureRoot = join (
880+ tmpdir ( ) ,
881+ `codex-flagged-io-${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ,
882+ ) ;
883+ const flaggedPath = join ( fixtureRoot , "flagged.json" ) ;
884+ const legacyPath = `${ flaggedPath } .legacy` ;
885+ const resetMarkerPath = `${ flaggedPath } .reset` ;
886+ const originalReadFile = fs . readFile . bind ( fs ) ;
887+ const logError = vi . fn ( ) ;
888+ const logInfo = vi . fn ( ) ;
889+ const saveFlaggedAccounts = vi . fn ( async ( ) => { } ) ;
890+ let legacyReadAttempts = 0 ;
891+
892+ try {
893+ await fs . mkdir ( fixtureRoot , { recursive : true } ) ;
894+ await fs . writeFile (
895+ legacyPath ,
896+ JSON . stringify ( {
897+ version : 1 ,
898+ accounts : [
899+ {
900+ refreshToken : "legacy-token" ,
901+ flaggedAt : 2 ,
902+ addedAt : 2 ,
903+ lastUsed : 2 ,
904+ } ,
905+ ] ,
906+ } ) ,
907+ "utf8" ,
908+ ) ;
909+
910+ const readSpy = vi
911+ . spyOn ( fs , "readFile" )
912+ . mockImplementation ( async ( ...args ) => {
913+ const [ targetPath ] = args ;
914+ if ( targetPath === legacyPath ) {
915+ legacyReadAttempts += 1 ;
916+ if ( legacyReadAttempts === 1 ) {
917+ const error = new Error ( "EBUSY legacy read" ) as NodeJS . ErrnoException ;
918+ error . code = "EBUSY" ;
919+ throw error ;
920+ }
921+ }
922+ return originalReadFile ( ...args ) ;
923+ } ) ;
924+
925+ await expect (
926+ loadFlaggedAccountsState ( {
927+ path : flaggedPath ,
928+ legacyPath,
929+ resetMarkerPath,
930+ normalizeFlaggedStorage : ( data ) => data as never ,
931+ persistRecoveredBackup : vi . fn ( async ( ) => true ) ,
932+ saveFlaggedAccounts,
933+ logError,
934+ logInfo,
935+ } ) ,
936+ ) . resolves . toEqual ( {
937+ version : 1 ,
938+ accounts : [
939+ {
940+ refreshToken : "legacy-token" ,
941+ flaggedAt : 2 ,
942+ addedAt : 2 ,
943+ lastUsed : 2 ,
944+ } ,
945+ ] ,
946+ } ) ;
947+ expect ( legacyReadAttempts ) . toBe ( 2 ) ;
948+ expect ( saveFlaggedAccounts ) . toHaveBeenCalledWith ( {
949+ version : 1 ,
950+ accounts : [
951+ {
952+ refreshToken : "legacy-token" ,
953+ flaggedAt : 2 ,
954+ addedAt : 2 ,
955+ lastUsed : 2 ,
956+ } ,
957+ ] ,
958+ } ) ;
959+ expect ( logInfo ) . toHaveBeenCalledWith (
960+ "Migrated legacy flagged account storage" ,
961+ expect . objectContaining ( { from : legacyPath , to : flaggedPath , accounts : 1 } ) ,
962+ ) ;
963+ expect ( logError ) . not . toHaveBeenCalled ( ) ;
964+
965+ readSpy . mockRestore ( ) ;
966+ } finally {
967+ await removeWithRetry ( fixtureRoot , { recursive : true , force : true } ) ;
968+ }
969+ } ) ;
788970} ) ;
0 commit comments