fix(mobile): discard refused picker cache copies (#34772)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-27 23:18:40 -07:00
committed by GitHub
co-authored by OrcaWin
parent 4270c886d8
commit 72ff417691
2 changed files with 98 additions and 16 deletions
+93 -16
View File
@@ -81,10 +81,11 @@ function harness(
const files = new Map<string, Uint8Array>()
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 () => {
+5
View File
@@ -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}`