Skip to content

Commit ea72b83

Browse files
authored
Merge pull request #173 from browser-use/screenshot-cleanup-throughput
fix(opencode): keep screenshot cleanup ahead of churn
2 parents 0fa7bce + d8d404f commit ea72b83

2 files changed

Lines changed: 172 additions & 55 deletions

File tree

‎packages/opencode/src/provider/image-files.ts‎

Lines changed: 83 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -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) {

‎packages/opencode/test/provider/image-files-lifecycle.test.ts‎

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ function fixture(options: { legacy?: boolean; anthropic?: boolean } = {}) {
2323
deleteStatus: 0,
2424
deleteGate: undefined as Promise<void> | undefined,
2525
deleteStarted: 0,
26+
deleteDelay: 0,
27+
deleteActive: 0,
28+
deletePeak: 0,
29+
uploadDelay: 0,
2630
hangDeletes: false,
2731
uploadGate: undefined as Promise<void> | undefined,
2832
uploads: 0,
@@ -50,17 +54,25 @@ function fixture(options: { legacy?: boolean; anthropic?: boolean } = {}) {
5054
}
5155
if (req.method === "DELETE") {
5256
state.deleteStarted++
53-
await state.deleteGate
54-
if (state.hangDeletes) return new Promise<Response>(() => {})
55-
if (state.deleteStatus) return new Response("failed", { status: state.deleteStatus })
56-
if (state.deleteFailures-- > 0) return new Response("unavailable", { status: 503 })
57-
const id = new URL(req.url).pathname.split("/").at(-1)!
58-
state.deleted.push(id)
59-
state.live.delete(id)
60-
return Response.json({ deleted: true })
57+
state.deleteActive++
58+
state.deletePeak = Math.max(state.deletePeak, state.deleteActive)
59+
try {
60+
await Bun.sleep(state.deleteDelay)
61+
await state.deleteGate
62+
if (state.hangDeletes) return new Promise<Response>(() => {})
63+
if (state.deleteStatus) return new Response("failed", { status: state.deleteStatus })
64+
if (state.deleteFailures-- > 0) return new Response("unavailable", { status: 503 })
65+
const id = new URL(req.url).pathname.split("/").at(-1)!
66+
state.deleted.push(id)
67+
state.live.delete(id)
68+
return Response.json({ deleted: true })
69+
} finally {
70+
state.deleteActive--
71+
}
6172
}
6273
state.uploads++
6374
const upload = state.uploads
75+
await Bun.sleep(state.uploadDelay)
6476
await state.uploadGate
6577
if (state.uploadFailures-- > 0) return new Response("unavailable", { status: 503 })
6678
const id = `${options.anthropic ? "file_" : "file-"}${upload}`
@@ -464,3 +476,72 @@ test("last stream ending during close cleanup schedules a final cleanup pass", a
464476
await f.close()
465477
}
466478
})
479+
480+
for (const latency of [false, true]) {
481+
test(`healthy cleanup keeps up with 1000 lifetime images and 100 active (latency=${latency})`, async () => {
482+
const f = fixture()
483+
f.state.deleteDelay = latency ? 200 : 0
484+
f.state.uploadDelay = latency ? 20 : 0
485+
try {
486+
for (let turn = 0; turn < 10; turn++) {
487+
await f.model.doGenerate(images(turn * 100, 100))
488+
expect(f.state.live.size).toBeLessThanOrEqual(600)
489+
if (latency) await Bun.sleep(50)
490+
}
491+
expect(f.state.uploads).toBe(1000)
492+
expect(f.state.calls).toHaveLength(10)
493+
await f.model.closeImageFiles()
494+
const deadline = Date.now() + 6000
495+
while (f.state.live.size && Date.now() < deadline) await Bun.sleep(10)
496+
expect(f.state.live.size).toBe(0)
497+
expect(f.state.deleted).toHaveLength(1000)
498+
expect(new Set(f.state.deleted).size).toBe(1000)
499+
expect(f.state.deletePeak).toBeLessThanOrEqual(16)
500+
} finally {
501+
await f.close()
502+
}
503+
}, 30000)
504+
}
505+
506+
test("preparation shares one second of cleanup wait across eviction passes", async () => {
507+
const f = fixture()
508+
try {
509+
await f.model.doGenerate(images(0, 300))
510+
f.state.deleteStatus = 400
511+
await f.model.doGenerate(images(300, 100))
512+
f.state.deleteStatus = 0
513+
f.state.deleteDelay = 200
514+
const started = performance.now()
515+
const result = await f.model.doStream(images(400, 100))
516+
expect(performance.now() - started).toBeLessThan(1900)
517+
expect(f.state.uploads).toBe(500)
518+
expect(f.state.deletePeak).toBeLessThanOrEqual(16)
519+
await result.stream.cancel()
520+
await f.model.closeImageFiles()
521+
const deadline = Date.now() + 7000
522+
while (f.state.live.size && Date.now() < deadline) await Bun.sleep(10)
523+
expect(f.state.live.size).toBe(0)
524+
} finally {
525+
await f.close()
526+
}
527+
}, 15000)
528+
529+
test("background cleanup stops on failure and keeps tombstones for explicit recovery", async () => {
530+
const f = fixture()
531+
try {
532+
await f.model.doGenerate(images(0, 100))
533+
f.state.deleteStatus = 400
534+
await f.model.closeImageFiles()
535+
const attempts = f.state.deleteStarted
536+
expect(attempts).toBeGreaterThan(0)
537+
expect(attempts).toBeLessThanOrEqual(16)
538+
await Bun.sleep(100)
539+
expect(f.state.deleteStarted).toBe(attempts)
540+
expect(f.state.live.size).toBe(100)
541+
f.state.deleteStatus = 0
542+
await f.model.closeImageFiles()
543+
expect(f.state.live.size).toBe(0)
544+
} finally {
545+
await f.close()
546+
}
547+
})

0 commit comments

Comments
 (0)