diff --git a/mobile/src/platform/native-media.test.ts b/mobile/src/platform/native-media.test.ts index 1da0c721241..160a69144f2 100644 --- a/mobile/src/platform/native-media.test.ts +++ b/mobile/src/platform/native-media.test.ts @@ -81,10 +81,11 @@ function harness( const files = new Map() const written: { uri: string; base64: string }[] = [] const copied: { from: string; to: string }[] = [] - const registry = new MediaHandleRegistry({ - now: () => 1_000, - discard: (uri) => discarded.push(uri) - }) + const discard = (uri: string): void => { + discarded.push(uri) + files.delete(uri) + } + const registry = new MediaHandleRegistry({ now: () => 1_000, discard }) const serve = createNativeMediaVerbServer({ registry, launchLibrary: () => @@ -114,7 +115,7 @@ function harness( return destination }, openFile: (uri) => fakeFile(files.get(uri) ?? new Uint8Array()), - discard: (uri) => discarded.push(uri), + discard, ownsStagedUri: (uri: string) => uri.startsWith('file:'), ...overrides }) @@ -352,28 +353,104 @@ describe('picking', () => { warn.mockRestore() }) - it('refuses a Files result over the room before it stages any of it', async () => { - // DocumentPicker can exceed advisory room; reject before staging rather than copy assets the registry will refuse. - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + it.each([0, 2])( + 'deletes an over-cap Files result while preserving %i prior handles', + async (held) => { + const liveUris = Array.from({ length: held }, (_, index) => `${CACHE}/held-${index}.png`) + const returnedUris = Array.from( + { length: BRIDGE_MEDIA_MAX_LIVE_HANDLES - held + 1 }, + (_, index) => `${CACHE}/doc-${index}.pdf` + ) + const probe = harness({ + launchFiles: () => + Promise.resolve({ + canceled: false, + assets: returnedUris.map((uri) => ({ uri, mimeType: 'application/pdf' })) + }) + }) + for (const uri of liveUris) { + probe.files.set(uri, new Uint8Array(4096)) + } + probe.registry.mint(liveUris.map((uri) => ({ uri, mime: 'image/png', byteLength: 4096 }))) + for (const uri of returnedUris) { + probe.files.set(uri, new Uint8Array(1024 * 1024)) + } + const storedBytes = (): number => + [...probe.files.values()].reduce((bytes, file) => bytes + file.byteLength, 0) + expect(storedBytes()).toBe(returnedUris.length * 1024 * 1024 + held * 4096) + await expect( + probe.serve('native.media.pick', { source: 'files', multiple: true }) + ).rejects.toSatisfy((error) => refusalOf(error) === 'native_media_handle_cap') + expect(probe.registry.liveCount()).toBe(held) + expect(probe.discarded).toEqual(returnedUris) + expect([...probe.files.keys()]).toEqual(liveUris) + expect(storedBytes()).toBe(held * 4096) + expect(probe.copied).toEqual([]) + expect(probe.written).toEqual([]) + probe.registry.releaseAll() + expect(probe.files.size).toBe(0) + } + ) + + it('leaves provider originals intact when refusing an over-cap mixed result', async () => { + const providerUri = 'content://media/external/images/media/42' + const ownedUris = Array.from( + { length: BRIDGE_MEDIA_MAX_LIVE_HANDLES }, + (_, index) => `${CACHE}/picked-${index}.png` + ) const probe = harness({ launchFiles: () => Promise.resolve({ canceled: false, - assets: Array.from({ length: BRIDGE_MEDIA_MAX_LIVE_HANDLES + 1 }, (_, index) => ({ - uri: `${CACHE}/doc-${index}.pdf`, - mimeType: 'application/pdf' - })) + assets: [providerUri, ...ownedUris].map((uri) => ({ uri, mimeType: 'image/png' })) }) }) - for (let index = 0; index <= BRIDGE_MEDIA_MAX_LIVE_HANDLES; index += 1) { - probe.files.set(`${CACHE}/doc-${index}.pdf`, bytesOf(4)) + for (const uri of [providerUri, ...ownedUris]) { + probe.files.set(uri, new Uint8Array(1024 * 1024)) } await expect( probe.serve('native.media.pick', { source: 'files', multiple: true }) ).rejects.toSatisfy((error) => refusalOf(error) === 'native_media_handle_cap') + expect(probe.discarded).toEqual(ownedUris) + expect([...probe.files.keys()]).toEqual([providerUri]) + expect(probe.files.get(providerUri)?.byteLength).toBe(1024 * 1024) expect(probe.registry.liveCount()).toBe(0) - expect(probe.discarded).toEqual([]) - warn.mockRestore() + expect(probe.copied).toEqual([]) + }) + + it('continues sweeping refused copies when one file cannot be deleted', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const returnedUris = Array.from( + { length: BRIDGE_MEDIA_MAX_LIVE_HANDLES + 1 }, + (_, index) => `${CACHE}/doc-${index}.pdf` + ) + const probe = harness({ + launchFiles: () => + Promise.resolve({ + canceled: false, + assets: returnedUris.map((uri) => ({ uri, mimeType: 'application/pdf' })) + }), + discard: (uri) => { + probe.discarded.push(uri) + if (uri === returnedUris[0]) { + throw new Error('cache file is busy') + } + probe.files.delete(uri) + } + }) + try { + for (const uri of returnedUris) { + probe.files.set(uri, new Uint8Array(1024 * 1024)) + } + await expect( + probe.serve('native.media.pick', { source: 'files', multiple: true }) + ).rejects.toSatisfy((error) => refusalOf(error) === 'native_media_handle_cap') + expect(probe.discarded).toEqual(returnedUris) + expect([...probe.files.keys()]).toEqual([returnedUris[0]]) + expect(probe.registry.liveCount()).toBe(0) + } finally { + warn.mockRestore() + } }) it('refuses an item bigger than the staging ceiling, and deletes what it picked', async () => { diff --git a/mobile/src/platform/native-media.ts b/mobile/src/platform/native-media.ts index 0f6239e10c9..36f072a350b 100644 --- a/mobile/src/platform/native-media.ts +++ b/mobile/src/platform/native-media.ts @@ -167,6 +167,11 @@ export function createNativeMediaVerbServer( // DocumentPicker ignores room; enforce count before staging any bytes. function withinRoom(assets: readonly PickedAsset[]): readonly PickedAsset[] { if (assets.length > room) { + for (const asset of assets) { + if (deps.ownsStagedUri(asset.uri)) { + discardQuietly(asset.uri) + } + } throw new BridgeNativeVerbRefusedError( 'native_media_handle_cap', `that pick answered ${assets.length} items and this page has room for ${room}`