@@ -14,6 +14,7 @@ type RunCache = {
1414 ready : Map < string , Uploaded >
1515 busy : boolean
1616 cleanupQueued : boolean
17+ cleaning ?: Promise < boolean >
1718 refreshed : Set < string >
1819 retired : Uploaded [ ]
1920 waiting : Array < ( ) => void >
@@ -102,56 +103,91 @@ export function withImageFiles(model: LanguageModelV3, options: Options) {
102103 return { bytes, key : `${ mediaType } :${ new Bun . CryptoHasher ( "sha256" ) . update ( bytes ) . digest ( "hex" ) } ` }
103104 }
104105
105- async function cleanup ( signal ?: AbortSignal , preserveRegistry = false ) {
106- const pinned = new Set ( [ ...state . leases ] . flatMap ( ( lease ) => [ ...lease . files ] ) )
107- if ( state . closed ) {
108- for ( const [ key , pending ] of state . cache ) {
109- if ( [ ...state . leases ] . some ( ( lease ) => lease . keys . has ( key ) ) ) continue
110- if ( state . retired . length >= 300 ) break
111- const file = await pending . catch ( ( ) => undefined )
112- state . cache . delete ( key )
113- state . ready . delete ( key )
114- state . refreshed . delete ( key )
115- if ( file ) state . retired . push ( file )
116- }
106+ // One shared drain prevents concurrent wrappers from multiplying DELETE work.
107+ // Its per-file timeout is independent of a caller's bounded foreground wait.
108+ async function cleanup ( signal ?: AbortSignal , preserveRegistry = false , budget = { remaining : 1000 } ) {
109+ const forget = ( ) => {
110+ if (
111+ state . closed &&
112+ ! state . cache . size &&
113+ ! state . retired . length &&
114+ ! state . leases . size &&
115+ cacheID &&
116+ runCaches . get ( cacheID ) === state
117+ )
118+ runCaches . delete ( cacheID )
117119 }
118- // Cleanup is best effort with one total deadline, including retries. A
119- // deletion outage must not hold the request lock for one timeout per file.
120- const deadline = AbortSignal . any ( [ AbortSignal . timeout ( 1000 ) , ...( signal ? [ signal ] : [ ] ) ] )
121- const removed = new Set < Uploaded > ( )
122- let attempted = 0
123- for ( const file of state . retired ) {
124- if ( pinned . has ( file . file_id ) || ( state . chain && ! state . closed ) ) continue
125- if ( file . expires_at <= now ( ) / 1000 ) {
126- removed . add ( file )
127- continue
128- }
129- if ( deadline . aborted || attempted ++ >= ( state . closed ? 300 : 8 ) ) break
130- const response = await request (
131- `${ endpoint } /${ encodeURIComponent ( file . file_id ) } ?model=${ encodeURIComponent ( model . modelId ) } ` ,
132- {
133- method : "DELETE" ,
134- headers : { ...routeHeaders , "x-bu-image-scope" : scope ! } ,
135- signal : deadline ,
136- } ,
137- ) . catch ( ( ) => undefined )
138- if ( ! response ?. ok && response ?. status !== 404 ) break
139- removed . add ( file )
120+ const joined = Boolean ( state . cleaning )
121+ if ( ! state . cleaning ) {
122+ state . cleaning = ( async ( ) => {
123+ for ( ; ; ) {
124+ if ( state . closed ) {
125+ for ( const [ key , file ] of state . ready ) {
126+ if ( [ ...state . leases ] . some ( ( lease ) => lease . keys . has ( key ) ) ) continue
127+ if ( state . retired . length >= 300 ) break
128+ state . cache . delete ( key )
129+ state . ready . delete ( key )
130+ state . refreshed . delete ( key )
131+ state . retired . push ( file )
132+ }
133+ }
134+ const pinned = new Set ( [ ...state . leases ] . flatMap ( ( lease ) => [ ...lease . files ] ) )
135+ const batch = state . retired
136+ . filter ( ( file ) => ! pinned . has ( file . file_id ) && ( ! state . chain || state . closed ) )
137+ . slice ( 0 , 16 )
138+ if ( ! batch . length ) return true
139+ const removed = await Promise . all (
140+ batch . map ( async ( file ) => {
141+ if ( file . expires_at <= now ( ) / 1000 ) return file
142+ const response = await request (
143+ `${ endpoint } /${ encodeURIComponent ( file . file_id ) } ?model=${ encodeURIComponent ( model . modelId ) } ` ,
144+ {
145+ method : "DELETE" ,
146+ headers : { ...routeHeaders , "x-bu-image-scope" : scope ! } ,
147+ signal : AbortSignal . timeout ( 1000 ) ,
148+ } ,
149+ ) . catch ( ( ) => undefined )
150+ await response ?. body ?. cancel ( ) . catch ( ( ) => { } )
151+ return response ?. ok || response ?. status === 404 ? file : undefined
152+ } ) ,
153+ )
154+ // Filter the current queue: other requests can retire files while a
155+ // batch is in flight. Failed tombstones survive for an explicit retry.
156+ const done = new Set ( removed . filter ( ( file ) => file !== undefined ) )
157+ state . retired = state . retired . filter ( ( file ) => ! done . has ( file ) )
158+ if ( done . size !== batch . length ) return false
159+ }
160+ } ) ( ) . finally ( ( ) => {
161+ state . cleaning = undefined
162+ // A caller reopening this cache holds the lock and keeps its registry
163+ // entry stable until it has either reopened it or reported backpressure.
164+ if ( ! state . busy ) forget ( )
165+ } )
140166 }
141- state . retired = state . retired . filter ( ( file ) => ! removed . has ( file ) )
142- if (
143- ! preserveRegistry &&
144- state . closed &&
145- ! state . cache . size &&
146- ! state . retired . length &&
147- cacheID &&
148- runCaches . get ( cacheID ) === state
149- )
150- runCaches . delete ( cacheID )
167+ const started = performance . now ( )
168+ const result = await new Promise < boolean | undefined > ( ( resolve ) => {
169+ const finish = ( result ?: boolean ) => {
170+ clearTimeout ( timer )
171+ signal ?. removeEventListener ( "abort" , abort )
172+ resolve ( result )
173+ }
174+ const abort = ( ) => finish ( )
175+ const timer = setTimeout ( abort , Math . max ( 0 , budget . remaining ) )
176+ signal ?. addEventListener ( "abort" , abort , { once : true } )
177+ void state . cleaning ! . then ( finish , abort )
178+ if ( signal ?. aborted ) abort ( )
179+ } )
180+ budget . remaining = Math . max ( 0 , budget . remaining - ( performance . now ( ) - started ) )
181+ // An explicit retry may have joined the tail of a failed drain. Use only
182+ // its remaining budget to start one fresh attempt after that drain ends.
183+ if ( joined && result === false && budget . remaining > 0 && ! signal ?. aborted )
184+ await cleanup ( signal , preserveRegistry , budget )
185+ if ( ! preserveRegistry ) forget ( )
151186 }
152187
153188 async function prepare ( params : LanguageModelV3CallOptions , lease : Lease ) : Promise < LanguageModelV3CallOptions > {
154189 if ( ! enabled ) return params
190+ const cleanupBudget = { remaining : 1000 }
155191 const stateful =
156192 ! anthropic &&
157193 ( typeof params . providerOptions ?. openai ?. previousResponseId === "string" ||
@@ -223,7 +259,7 @@ export function withImageFiles(model: LanguageModelV3, options: Options) {
223259 if ( state . closed )
224260 await locked ( async ( ) => {
225261 if ( ! state . closed ) return
226- await cleanup ( signal , true )
262+ await cleanup ( signal , true , cleanupBudget )
227263 if ( state . cache . size || state . retired . length || state . leases . size )
228264 throw new Error ( "Screenshot file cache is closing; retry cleanup first" )
229265 state . closed = false
@@ -273,7 +309,7 @@ export function withImageFiles(model: LanguageModelV3, options: Options) {
273309 )
274310 lease . keys = keys
275311 state . leases . add ( lease )
276- if ( ! cached ) await cleanup ( signal )
312+ if ( ! cached ) await cleanup ( signal , false , cleanupBudget )
277313 const missing = [ ...keys ] . filter ( ( key ) => ! state . cache . has ( key ) ) . length
278314 for ( const key of state . cache . keys ( ) ) {
279315 if ( state . cache . size + missing <= 300 ) break
@@ -291,7 +327,7 @@ export function withImageFiles(model: LanguageModelV3, options: Options) {
291327 // files. Keep explicit stateful chains until close; never assume a copy.
292328 if ( state . chain ) for ( const key of keys ) state . chainKeys . add ( key )
293329 // Reclaim files newly retired by eviction, after the pre-eviction backlog pass.
294- if ( ! cached ) await cleanup ( signal )
330+ if ( ! cached ) await cleanup ( signal , false , cleanupBudget )
295331 const headers = { ...currentHeaders , "x-bu-image-scope" : scope ! }
296332 const proofs : Record < string , { expires_at : number ; signature : string } > = { }
297333 async function upload ( data : string | Uint8Array | URL , mediaType : string ) {
0 commit comments