Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions src/app/utils/mediaConcurrency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest';
import { withMediaFetchSlot } from './mediaConcurrency';

describe('withMediaFetchSlot', () => {
it('never runs more than three tasks at once and runs all of them', async () => {
let active = 0;
let peak = 0;
const release: (() => void)[] = [];

const tasks = Array.from({ length: 10 }, () =>
withMediaFetchSlot(async () => {
active += 1;
peak = Math.max(peak, active);
await new Promise<void>((resolve) => {
release.push(resolve);
});
active -= 1;
})
);

while (release.length > 0) {
release.shift()?.();
// Let the freed slot reach the next waiter before draining again.
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
}

await Promise.all(tasks);
expect(peak).toBe(3);
});

it('releases the slot when a task rejects', async () => {
await expect(withMediaFetchSlot(() => Promise.reject(new Error('boom')))).rejects.toThrow(
'boom'
);

await expect(withMediaFetchSlot(() => Promise.resolve('ok'))).resolves.toBe('ok');
});
});
25 changes: 25 additions & 0 deletions src/app/utils/mediaConcurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Media shares its host with the Matrix API, and browsers cap connections per host at 6.
// Leave slots free so a wave of media loads cannot queue /sync and /send behind it.
const MAX_CONCURRENT_MEDIA_FETCHES = 3;

const waiters: (() => void)[] = [];
let active = 0;

export async function withMediaFetchSlot<T>(task: () => Promise<T>): Promise<T> {
if (active < MAX_CONCURRENT_MEDIA_FETCHES) {
active += 1;
} else {
await new Promise<void>((resolve) => {
waiters.push(resolve);
});
}

try {
return await task();
} finally {
// Hand the slot straight to the next waiter instead of releasing and re-acquiring it.
const next = waiters.shift();
if (next) next();
else active -= 1;
}
}
31 changes: 18 additions & 13 deletions src/app/utils/mediaTransport.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getCachedSWMediaAuthSupport } from './swMediaAuth';
import { fetch } from '$utils/fetch';
import { getFromMediaCache, putInMediaCache } from './mediaCache';
import { withMediaFetchSlot } from './mediaConcurrency';

type StoredSession = {
baseUrl?: string;
Expand Down Expand Up @@ -326,7 +327,9 @@ async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptio
};

if (useServiceWorker) {
return fetchAndCache(await fetchMediaResponse(url, undefined, cacheMode));
return withMediaFetchSlot(async () =>
fetchAndCache(await fetchMediaResponse(url, undefined, cacheMode))
);
}

const fetchWithRetry = async (
Expand All @@ -352,20 +355,22 @@ async function fetchMediaBlobInternal(url: string, options?: MediaTransportOptio
}
};

const initialAccessToken = resolveAccessToken(url, options);
const initialResponse = await fetchWithRetry(url, initialAccessToken, cacheMode);
if (initialResponse.ok) {
return fetchAndCache(initialResponse);
}
return withMediaFetchSlot(async () => {
const initialAccessToken = resolveAccessToken(url, options);
const initialResponse = await fetchWithRetry(url, initialAccessToken, cacheMode);
if (initialResponse.ok) {
return fetchAndCache(initialResponse);
}

if (!isRetryableAuthError(initialResponse)) {
throw new Error(
`Failed to fetch media: ${initialResponse.status} ${initialResponse.statusText}`
);
}
if (!isRetryableAuthError(initialResponse)) {
throw new Error(
`Failed to fetch media: ${initialResponse.status} ${initialResponse.statusText}`
);
}

const retryAccessToken = resolveAccessToken(url, options);
return fetchAndCache(await fetchMediaResponse(url, retryAccessToken, cacheMode));
const retryAccessToken = resolveAccessToken(url, options);
return fetchAndCache(await fetchMediaResponse(url, retryAccessToken, cacheMode));
});
}

export async function fetchMediaBlob(url: string, options?: MediaTransportOptions): Promise<Blob> {
Expand Down
12 changes: 7 additions & 5 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { precacheAndRoute, cleanupOutdatedCaches } from 'workbox-precaching';

import { createPushNotifications } from './sw/pushNotification';
import { withMediaFetchSlot } from './app/utils/mediaConcurrency';

declare const self: ServiceWorkerGlobalScope;

Expand Down Expand Up @@ -790,18 +791,19 @@ function respondWithInflightMedia(
// Fetch by URL instead of reusing the subresource Request. Image requests commonly carry
// mode: "no-cors", which prevents the Authorization header above from reaching the server.
// Preserve Range header for streaming audio and video.
const promise = fetch(request.url, { ...fetchConfig(token, request), redirect })
.then(
// The slot is held until the body has been read, since that is what holds the connection.
const promise = withMediaFetchSlot(() =>
fetch(request.url, { ...fetchConfig(token, request), redirect }).then(
async (res): Promise<BufferedMediaResponse> => ({
status: res.status,
statusText: res.statusText,
headers: new Headers(res.headers),
body: await res.arrayBuffer(),
})
)
.finally(() => {
inflightMediaFetches.delete(key);
});
).finally(() => {
inflightMediaFetches.delete(key);
});
inflightMediaFetches.set(key, promise);
return promise.then(
(data) =>
Expand Down
Loading