diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 55b0fdecb6..dcbbe0e3e9 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -10,11 +10,12 @@ import {t} from '@lingui/core/macro' import {type QueryClient} from '@tanstack/react-query' import {type LinkResolvers} from '#/lib/api/resolve' +import {mapWithSerialRetry} from '#/lib/async/map-with-serial-retry' import {IMAGE_SIZE_CONFIG_POSTS} from '#/lib/constants' import {isNetworkError} from '#/lib/strings/errors' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {logger} from '#/logger' -import {compressImage} from '#/state/gallery' +import {type ComposerImage, compressImage} from '#/state/gallery' import { fetchResolveGifQuery, fetchResolveLinkQuery, @@ -334,18 +335,15 @@ async function resolveMedia( count: imagesDraft.length, }) onStateChange?.(t`Uploading images...`) + const compressedImages = await compressImages(imagesDraft) const images: app.bsky.embed.images.Image[] = await Promise.all( - imagesDraft.map(async (image, i) => { - logger.debug(`Compressing image #${i}`) - const {path, width, height, mime} = await compressImage( - image, - IMAGE_SIZE_CONFIG_POSTS, - ) + compressedImages.map(async (compressedImage, i) => { + const {path, width, height, mime} = compressedImage logger.debug(`Uploading image #${i}`) const res = await uploadBlob(pdsClient, path, mime) return { image: res.blob, - alt: image.alt, + alt: imagesDraft[i].alt, aspectRatio: {width, height}, } }), @@ -361,19 +359,16 @@ async function resolveMedia( count: imagesDraft.length, }) onStateChange?.(t`Uploading images...`) + const compressedImages = await compressImages(imagesDraft) const items: $Typed[] = await Promise.all( - imagesDraft.map(async (image, i) => { - logger.debug(`Compressing image #${i}`) - const {path, width, height, mime} = await compressImage( - image, - IMAGE_SIZE_CONFIG_POSTS, - ) + compressedImages.map(async (compressedImage, i) => { + const {path, width, height, mime} = compressedImage logger.debug(`Uploading image #${i}`) const res = await uploadBlob(pdsClient, path, mime) return { $type: 'app.bsky.embed.gallery#image' as const, image: res.blob, - alt: image.alt, + alt: imagesDraft[i].alt, aspectRatio: {width, height}, } }), @@ -490,6 +485,28 @@ async function resolveMedia( return undefined } +type CompressedImage = Awaited> + +async function compressImages( + images: ComposerImage[], +): Promise { + return mapWithSerialRetry( + images, + (image, i) => { + logger.debug(`Compressing image #${i}`) + return compressImage(image, IMAGE_SIZE_CONFIG_POSTS) + }, + isBitmapLoadingError, + (_error, i) => { + logger.info(`Retrying image compression serially`, {index: i}) + }, + ) +} + +function isBitmapLoadingError(error: unknown): boolean { + return String(error).includes('Loading bitmap failed') +} + async function resolveRecord( clients: LinkResolvers, queryClient: QueryClient, diff --git a/src/lib/async/map-with-serial-retry.test.ts b/src/lib/async/map-with-serial-retry.test.ts new file mode 100644 index 0000000000..bc50febc31 --- /dev/null +++ b/src/lib/async/map-with-serial-retry.test.ts @@ -0,0 +1,52 @@ +import {mapWithSerialRetry} from './map-with-serial-retry' + +describe('mapWithSerialRetry', () => { + it('reuses successes and retries failures serially', async () => { + const attempts = new Map() + let retriesInFlight = 0 + let maxRetriesInFlight = 0 + + const result = await mapWithSerialRetry( + [1, 2, 3], + async value => { + const attempt = (attempts.get(value) ?? 0) + 1 + attempts.set(value, attempt) + + if (value !== 1 && attempt === 1) { + throw new Error('retryable') + } + + if (attempt > 1) { + retriesInFlight++ + maxRetriesInFlight = Math.max(maxRetriesInFlight, retriesInFlight) + await Promise.resolve() + retriesInFlight-- + } + + return value * 2 + }, + error => String(error).includes('retryable'), + ) + + expect(result).toEqual([2, 4, 6]) + expect(attempts).toEqual( + new Map([ + [1, 1], + [2, 2], + [3, 2], + ]), + ) + expect(maxRetriesInFlight).toBe(1) + }) + + it('does not retry when any concurrent attempt fails permanently', async () => { + const mapper = jest.fn((value: string) => Promise.reject(new Error(value))) + + await expect( + mapWithSerialRetry(['retryable', 'permanent'], mapper, error => + String(error).includes('retryable'), + ), + ).rejects.toThrow('permanent') + expect(mapper).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/lib/async/map-with-serial-retry.ts b/src/lib/async/map-with-serial-retry.ts new file mode 100644 index 0000000000..7dbfd2e3ae --- /dev/null +++ b/src/lib/async/map-with-serial-retry.ts @@ -0,0 +1,32 @@ +/** + * Maps all values concurrently, then retries eligible failures one at a time. + * Successful results from the concurrent attempt are reused. + */ +export async function mapWithSerialRetry( + values: T[], + mapper: (value: T, index: number) => Promise, + shouldRetry: (error: unknown) => boolean, + onRetry?: (error: unknown, index: number) => void, +): Promise { + const settled = await Promise.allSettled(values.map(mapper)) + + const nonRetryableFailure = settled.find( + result => result.status === 'rejected' && !shouldRetry(result.reason), + ) + if (nonRetryableFailure?.status === 'rejected') { + throw nonRetryableFailure.reason + } + + const results: U[] = [] + for (let i = 0; i < settled.length; i++) { + const result = settled[i] + if (result.status === 'fulfilled') { + results.push(result.value) + } else { + onRetry?.(result.reason, i) + results.push(await mapper(values[i], i)) + } + } + + return results +} diff --git a/src/state/gallery.ts b/src/state/gallery.ts index 15c3a9e5b6..3a0a2d9544 100644 --- a/src/state/gallery.ts +++ b/src/state/gallery.ts @@ -12,7 +12,7 @@ import {renderImage} from '#/lib/media/image-manipulator' import {getImageDim} from '#/lib/media/manip' import {openCropper} from '#/lib/media/picker' import {type PickerImage} from '#/lib/media/picker.shared' -import {getDataUriSize} from '#/lib/media/util' +import {getUriSize} from '#/lib/media/uriSize' import {isCancelledError} from '#/lib/strings/errors' import {logger} from '#/logger' import {IS_NATIVE, IS_WEB} from '#/env' @@ -241,15 +241,16 @@ export async function compressImage( source.path, context => context.resize({width: w, height: h}), { + // Requesting base64 makes Android encode the bitmap a second time and + // retain the encoded bytes and string in memory. Measure the URI instead. + base64: false, compress: qualityPercentage / 100, format: SaveFormat.JPEG, - base64: true, }, ) - const base64 = res.base64 - const size = base64 ? getDataUriSize(base64) : 0 - if (base64 && size <= maxBytes) { + const size = await getUriSize(res.uri) + if (size <= maxBytes) { minQualityPercentage = qualityPercentage newDataUri = { path: await moveIfNecessary(res.uri),