Delete unreturned clipboard cache files after a failed write (#24599)

Reuse existing provider-copy best-effort deletion for a newly created clipboard cache file whose write fails before its URI reaches the caller.
This commit is contained in:
Neil
2026-10-03 15:47:14 -07:00
committed by GitHub
parent 831710c380
commit 06194626ba
2 changed files with 219 additions and 6 deletions
+15 -6
View File
@@ -33,6 +33,14 @@ function stagedMediaFile(extension: string): FsFile {
return new FsFile(Paths.cache, `orca-media-${Date.now()}-${Math.random()}.${extension}`)
}
function discardUnreturnedMediaFile(file: FsFile): void {
try {
file.delete()
} catch {
// Best effort; the cache is the OS's to reclaim.
}
}
/**
* Copies what a uri names into this shell's cache and answers the copy's uri.
*
@@ -57,11 +65,7 @@ export function copyPickedMediaIntoCache(uri: string): string {
destination.write(new FsFile(uri).bytesSync())
} catch (error) {
// The empty file this just created is nobody's otherwise: the caller never learns its name.
try {
destination.delete()
} catch {
// Best effort; the cache is the OS's to reclaim.
}
discardUnreturnedMediaFile(destination)
throw error
}
return destination.uri
@@ -94,7 +98,12 @@ export function nativeMediaDeviceDeps(registry: MediaHandleRegistry): NativeMedi
stageBase64: (base64) => {
const file = stagedMediaFile('png')
file.create({ overwrite: true })
file.write(base64, { encoding: 'base64' })
try {
file.write(base64, { encoding: 'base64' })
} catch (error) {
discardUnreturnedMediaFile(file)
throw error
}
return file.uri
},
openFile: (uri) => new FsFile(uri),
@@ -0,0 +1,204 @@
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
type FileEvent = {
operation: 'create' | 'write' | 'delete'
uri: string
content?: string | Uint8Array
options?: { overwrite?: boolean; encoding?: string }
}
type StorageState = {
files: Map<string, Uint8Array>
events: FileEvent[]
writeFailure: unknown
deleteFailure: unknown
createFailure: unknown
readFailure: unknown
partialBytes: number
}
const storage = vi.hoisted<StorageState>(() => ({
files: new Map(),
events: [],
writeFailure: null,
deleteFailure: null,
createFailure: null,
readFailure: null,
partialBytes: 0
}))
vi.mock('expo-clipboard', () => ({
getImageAsync: () => Promise.resolve({ data: 'data:image/png;base64,AQID' })
}))
vi.mock('expo-document-picker', () => ({}))
vi.mock('expo-image-picker', () => ({}))
vi.mock('expo-file-system', () => ({
File: class {
readonly uri: string
constructor(...parts: string[]) {
this.uri = parts.join('/')
}
get size(): number {
return storage.files.get(this.uri)?.byteLength ?? 0
}
create(options: { overwrite?: boolean }): void {
storage.events.push({ operation: 'create', uri: this.uri, options })
storage.files.set(this.uri, new Uint8Array())
if (storage.createFailure !== null) {
throw storage.createFailure
}
}
write(content: string | Uint8Array, options?: { encoding?: string }): void {
storage.events.push({ operation: 'write', uri: this.uri, content, options })
if (storage.writeFailure !== null) {
storage.files.set(this.uri, new Uint8Array(storage.partialBytes))
throw storage.writeFailure
}
storage.files.set(
this.uri,
typeof content === 'string' ? Uint8Array.from(Buffer.from(content, 'base64')) : content
)
}
bytesSync(): Uint8Array {
if (storage.readFailure !== null) {
throw storage.readFailure
}
return Uint8Array.from([9, 8])
}
delete(): void {
storage.events.push({ operation: 'delete', uri: this.uri })
if (storage.deleteFailure !== null) {
throw storage.deleteFailure
}
storage.files.delete(this.uri)
}
},
Paths: { cache: 'file:///cache' }
}))
import { MediaHandleRegistry } from '../mobile-web-shell/media-handle-registry'
import { createNativeMediaVerbServer } from './native-media'
import { copyPickedMediaIntoCache, nativeMediaDeviceDeps } from './native-media-device'
beforeEach(() => {
storage.files.clear()
storage.events.length = 0
storage.writeFailure = null
storage.deleteFailure = null
storage.createFailure = null
storage.readFailure = null
storage.partialBytes = 0
vi.spyOn(Date, 'now').mockReturnValue(1234)
vi.spyOn(Math, 'random').mockReturnValue(0.25)
})
afterEach(() => vi.restoreAllMocks())
function device() {
return nativeMediaDeviceDeps(new MediaHandleRegistry({ now: () => 0, discard: vi.fn() }))
}
function thrownBy(operation: () => unknown): unknown {
try {
operation()
} catch (error) {
return error
}
throw new Error('Expected staging to throw')
}
function heldBytes(): number {
return [...storage.files.values()].reduce((total, bytes) => total + bytes.byteLength, 0)
}
it('preserves the successful staged URI, base64 options and bytes without deleting it', () => {
const uri = device().stageBase64('AQID')
expect(uri).toBe('file:///cache/orca-media-1234-0.25.png')
expect(storage.events).toEqual([
{ operation: 'create', uri, options: { overwrite: true } },
{ operation: 'write', uri, content: 'AQID', options: { encoding: 'base64' } }
])
expect(storage.files.get(uri)).toEqual(Uint8Array.from([1, 2, 3]))
})
it.each([0, 4096])('releases an unreturned file after a write fails with %i bytes', (bytes) => {
const original = new Error('partial native write')
storage.writeFailure = original
storage.partialBytes = bytes
expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
expect(heldBytes()).toBe(0)
expect(storage.files.size).toBe(0)
expect(storage.events.at(-1)).toEqual({
operation: 'delete',
uri: 'file:///cache/orca-media-1234-0.25.png'
})
})
it('preserves the write failure when best-effort deletion also fails', () => {
const original = new Error('native write failed')
storage.writeFailure = original
storage.deleteFailure = new Error('cache cannot be deleted')
storage.partialBytes = 4096
expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
expect(storage.events.map((event) => event.operation)).toEqual(['create', 'write', 'delete'])
expect(heldBytes()).toBe(4096)
})
it('preserves creation failure and its original operation order', () => {
const original = new Error('native create failed')
storage.createFailure = original
expect(thrownBy(() => device().stageBase64('AQID'))).toBe(original)
expect(storage.events.map((event) => event.operation)).toEqual(['create'])
})
it('deletes only the failed file while a prior successful file remains readable', () => {
vi.spyOn(Math, 'random').mockReturnValueOnce(0.1).mockReturnValueOnce(0.2)
const successfulUri = device().stageBase64('AQID')
const original = new Error('next write failed')
storage.writeFailure = original
storage.partialBytes = 4096
expect(thrownBy(() => device().stageBase64('BAUG'))).toBe(original)
expect(storage.events.filter((event) => event.operation === 'delete')).toEqual([
{ operation: 'delete', uri: 'file:///cache/orca-media-1234-0.2.png' }
])
expect([...storage.files.keys()]).toEqual([successfulUri])
expect(storage.files.get(successfulUri)).toEqual(Uint8Array.from([1, 2, 3]))
expect(heldBytes()).toBe(3)
})
it('keeps the clipboard verb rejection while leaving no unregistered cache bytes', async () => {
const original = new Error('clipboard write failed')
storage.writeFailure = original
storage.partialBytes = 4096
const registryDiscard = vi.fn()
const registry = new MediaHandleRegistry({ now: () => 0, discard: registryDiscard })
const serve = createNativeMediaVerbServer(nativeMediaDeviceDeps(registry))
await expect(serve('native.media.pick', { source: 'clipboard', multiple: false })).rejects.toBe(
original
)
expect(registry.liveCount()).toBe(0)
expect(registryDiscard).not.toHaveBeenCalled()
expect(heldBytes()).toBe(0)
expect(storage.files.size).toBe(0)
})
it('retains the provider-copy cleanup and original read error when deletion fails', () => {
const original = new Error('provider read failed')
storage.readFailure = original
storage.deleteFailure = new Error('cache delete failed')
expect(thrownBy(() => copyPickedMediaIntoCache('content://media/1'))).toBe(original)
expect(storage.events).toEqual([
{
operation: 'create',
uri: 'file:///cache/orca-media-1234-0.25.bin',
options: { overwrite: true }
},
{ operation: 'delete', uri: 'file:///cache/orca-media-1234-0.25.bin' }
])
})