@@ -1854,6 +1854,12 @@ describe("AccountManager", () => {
18541854 } ) ;
18551855
18561856 describe ( "flushPendingSave" , ( ) => {
1857+ const drainMicrotasks = async ( turns = 10 ) : Promise < void > => {
1858+ for ( let turn = 0 ; turn < turns ; turn ++ ) {
1859+ await Promise . resolve ( ) ;
1860+ }
1861+ } ;
1862+
18571863 it ( "flushes pending debounced save" , async ( ) => {
18581864 const { saveAccounts } = await import ( "../lib/storage.js" ) ;
18591865 const mockSaveAccounts = vi . mocked ( saveAccounts ) ;
@@ -1998,6 +2004,183 @@ describe("AccountManager", () => {
19982004 vi . useRealTimers ( ) ;
19992005 }
20002006 } ) ;
2007+
2008+ it ( "drains saves queued during flush without overlapping writes" , async ( ) => {
2009+ vi . useFakeTimers ( ) ;
2010+ try {
2011+ const { saveAccounts } = await import ( "../lib/storage.js" ) ;
2012+ const mockSaveAccounts = vi . mocked ( saveAccounts ) ;
2013+ mockSaveAccounts . mockClear ( ) ;
2014+
2015+ let activeWrites = 0 ;
2016+ let maxConcurrentWrites = 0 ;
2017+ const settleWrites : Array < ( ) => void > = [ ] ;
2018+ const writePromises : Promise < void > [ ] = [ ] ;
2019+
2020+ mockSaveAccounts . mockImplementation ( ( ) => {
2021+ activeWrites ++ ;
2022+ maxConcurrentWrites = Math . max ( maxConcurrentWrites , activeWrites ) ;
2023+ const writePromise = new Promise < void > ( ( resolve ) => {
2024+ settleWrites . push ( ( ) => {
2025+ activeWrites -- ;
2026+ resolve ( ) ;
2027+ } ) ;
2028+ } ) ;
2029+ writePromises . push ( writePromise ) ;
2030+ return writePromise ;
2031+ } ) ;
2032+
2033+ const now = Date . now ( ) ;
2034+ const stored = {
2035+ version : 3 as const ,
2036+ activeIndex : 0 ,
2037+ accounts : [
2038+ { refreshToken : "token-1" , addedAt : now , lastUsed : now } ,
2039+ ] ,
2040+ } ;
2041+
2042+ const manager = new AccountManager ( undefined , stored ) ;
2043+
2044+ manager . saveToDiskDebounced ( 50 ) ;
2045+ await vi . advanceTimersByTimeAsync ( 60 ) ;
2046+ expect ( mockSaveAccounts ) . toHaveBeenCalledTimes ( 1 ) ;
2047+
2048+ const armGapSave = writePromises [ 0 ] ! . then ( ( ) => {
2049+ manager . saveToDiskDebounced ( 0 ) ;
2050+ } ) ;
2051+
2052+ manager . saveToDiskDebounced ( 50 ) ;
2053+ const flushPromise = manager . flushPendingSave ( ) ;
2054+
2055+ settleWrites [ 0 ] ! ( ) ;
2056+ await armGapSave ;
2057+ await drainMicrotasks ( ) ;
2058+
2059+ expect ( mockSaveAccounts ) . toHaveBeenCalledTimes ( 2 ) ;
2060+
2061+ manager . saveToDiskDebounced ( 0 ) ;
2062+ await vi . advanceTimersByTimeAsync ( 0 ) ;
2063+ expect ( mockSaveAccounts ) . toHaveBeenCalledTimes ( 2 ) ;
2064+
2065+ settleWrites [ 1 ] ! ( ) ;
2066+ await drainMicrotasks ( ) ;
2067+
2068+ expect ( mockSaveAccounts ) . toHaveBeenCalledTimes ( 3 ) ;
2069+
2070+ settleWrites [ 2 ] ! ( ) ;
2071+ await flushPromise ;
2072+
2073+ expect ( maxConcurrentWrites ) . toBe ( 1 ) ;
2074+ } finally {
2075+ vi . useRealTimers ( ) ;
2076+ }
2077+ } ) ;
2078+
2079+ it ( "persists disabled auth-failure state after flushing over an in-flight save" , async ( ) => {
2080+ vi . useFakeTimers ( ) ;
2081+ try {
2082+ const { saveAccounts } = await import ( "../lib/storage.js" ) ;
2083+ const mockSaveAccounts = vi . mocked ( saveAccounts ) ;
2084+ mockSaveAccounts . mockClear ( ) ;
2085+
2086+ let activeWrites = 0 ;
2087+ let maxConcurrentWrites = 0 ;
2088+ const settleWrites : Array < ( ) => void > = [ ] ;
2089+ const savedSnapshots : Array <
2090+ Array < {
2091+ refreshToken ?: string ;
2092+ enabled ?: false ;
2093+ disabledReason ?: string ;
2094+ coolingDownUntil ?: number ;
2095+ cooldownReason ?: string ;
2096+ } >
2097+ > = [ ] ;
2098+
2099+ mockSaveAccounts . mockImplementation ( async ( storage ) => {
2100+ activeWrites ++ ;
2101+ maxConcurrentWrites = Math . max ( maxConcurrentWrites , activeWrites ) ;
2102+ savedSnapshots . push (
2103+ storage . accounts . map ( ( account ) => ( {
2104+ refreshToken : account . refreshToken ,
2105+ enabled : account . enabled ,
2106+ disabledReason : account . disabledReason ,
2107+ coolingDownUntil : account . coolingDownUntil ,
2108+ cooldownReason : account . cooldownReason ,
2109+ } ) ) ,
2110+ ) ;
2111+ await new Promise < void > ( ( resolve ) => {
2112+ settleWrites . push ( ( ) => {
2113+ activeWrites -- ;
2114+ resolve ( ) ;
2115+ } ) ;
2116+ } ) ;
2117+ } ) ;
2118+
2119+ const now = Date . now ( ) ;
2120+ const stored = {
2121+ version : 3 as const ,
2122+ activeIndex : 0 ,
2123+ accounts : [
2124+ {
2125+ refreshToken : "shared-refresh" ,
2126+ addedAt : now ,
2127+ lastUsed : now ,
2128+ coolingDownUntil : now + 10_000 ,
2129+ cooldownReason : "auth-failure" as const ,
2130+ } ,
2131+ {
2132+ refreshToken : "shared-refresh" ,
2133+ addedAt : now + 1 ,
2134+ lastUsed : now + 1 ,
2135+ coolingDownUntil : now + 20_000 ,
2136+ cooldownReason : "auth-failure" as const ,
2137+ } ,
2138+ ] ,
2139+ } ;
2140+
2141+ const manager = new AccountManager ( undefined , stored ) ;
2142+
2143+ manager . saveToDiskDebounced ( 50 ) ;
2144+ await vi . advanceTimersByTimeAsync ( 60 ) ;
2145+ expect ( mockSaveAccounts ) . toHaveBeenCalledTimes ( 1 ) ;
2146+
2147+ const account = manager . getAccountsSnapshot ( ) [ 0 ] ! ;
2148+ manager . disableAccountsWithSameRefreshToken ( account ) ;
2149+ manager . saveToDiskDebounced ( 50 ) ;
2150+
2151+ const flushPromise = manager . flushPendingSave ( ) ;
2152+
2153+ settleWrites [ 0 ] ! ( ) ;
2154+ await drainMicrotasks ( ) ;
2155+
2156+ expect ( mockSaveAccounts ) . toHaveBeenCalledTimes ( 2 ) ;
2157+
2158+ const flushedSharedAccounts = savedSnapshots [ 1 ] ! . filter (
2159+ ( savedAccount ) => savedAccount . refreshToken === "shared-refresh" ,
2160+ ) ;
2161+ expect ( flushedSharedAccounts ) . toEqual ( [
2162+ expect . objectContaining ( {
2163+ enabled : false ,
2164+ disabledReason : "auth-failure" ,
2165+ coolingDownUntil : undefined ,
2166+ cooldownReason : undefined ,
2167+ } ) ,
2168+ expect . objectContaining ( {
2169+ enabled : false ,
2170+ disabledReason : "auth-failure" ,
2171+ coolingDownUntil : undefined ,
2172+ cooldownReason : undefined ,
2173+ } ) ,
2174+ ] ) ;
2175+
2176+ settleWrites [ 1 ] ! ( ) ;
2177+ await flushPromise ;
2178+
2179+ expect ( maxConcurrentWrites ) . toBe ( 1 ) ;
2180+ } finally {
2181+ vi . useRealTimers ( ) ;
2182+ }
2183+ } ) ;
20012184 } ) ;
20022185
20032186 describe ( "health and token tracking methods" , ( ) => {
0 commit comments