mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(desktop): never queue local proxy requests (#3421)
This commit is contained in:
1 parent
68cb2a4bbb
commit
ab19c0d1ac
8 files changed
+86
-505
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import {Buffer} from 'node:buffer';
|
||||
import {Readable} from 'node:stream';
|
||||
import {PassThrough, Readable} from 'node:stream';
|
||||
import {describe, mock, test} from 'node:test';
|
||||
import {installElectronStub} from './LocalAppTestSupport.test.mjs';
|
||||
|
||||
@@ -10,17 +10,9 @@ installElectronStub();
|
||||
|
||||
const {DesktopLocalAppProxyClient, LocalAppProxyTraffic} = await import('./LocalAppProxyClient.ts');
|
||||
|
||||
const PROXY_MAX_CONCURRENT_REQUESTS = 32;
|
||||
const PROXY_MAX_QUEUED_REQUESTS = 4096;
|
||||
const OUTBOUND_MAX_IN_FLIGHT_PER_SERVICE = 64;
|
||||
const RENDERED_AVATAR_COUNT = 120;
|
||||
|
||||
class DesktopOutboundHTTPCapacityError extends Error {
|
||||
constructor(limit) {
|
||||
super(`Desktop outbound HTTP exceeded its ${limit} limit`);
|
||||
this.name = 'DesktopOutboundHTTPCapacityError';
|
||||
}
|
||||
}
|
||||
const PARKED_MEDIA_BODY_COUNT = 200;
|
||||
const RESOURCE_HEADERS_DEADLINE_MS = 30_000;
|
||||
|
||||
function tick() {
|
||||
return new Promise((resolve) => {
|
||||
@@ -28,33 +20,6 @@ function tick() {
|
||||
});
|
||||
}
|
||||
|
||||
function createOutboundHTTP() {
|
||||
const state = {inFlight: 0, peakInFlight: 0, started: 0, pending: []};
|
||||
const outboundHTTP = {
|
||||
request: (request) => {
|
||||
if (state.inFlight >= OUTBOUND_MAX_IN_FLIGHT_PER_SERVICE) {
|
||||
throw new DesktopOutboundHTTPCapacityError(`in-flight request for ${request.serviceName}`);
|
||||
}
|
||||
state.inFlight += 1;
|
||||
state.started += 1;
|
||||
state.peakInFlight = Math.max(state.peakInFlight, state.inFlight);
|
||||
return new Promise((resolve) => {
|
||||
state.pending.push(() => {
|
||||
state.inFlight -= 1;
|
||||
resolve({
|
||||
headers: {'content-type': 'image/webp'},
|
||||
message: Readable.from([Buffer.from('avatar-bytes')]),
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
url: new URL(request.url),
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
return {outboundHTTP, state};
|
||||
}
|
||||
|
||||
function avatarRequest(index, signal) {
|
||||
return {
|
||||
targetURL: `https://media.fluxer.app/avatars/${index}.webp`,
|
||||
@@ -68,131 +33,17 @@ function avatarRequest(index, signal) {
|
||||
};
|
||||
}
|
||||
|
||||
async function pump(state, work) {
|
||||
let finished = false;
|
||||
const result = work.finally(() => {
|
||||
finished = true;
|
||||
});
|
||||
result.catch(() => {});
|
||||
while (!finished) {
|
||||
for (const settle of state.pending.splice(0)) {
|
||||
settle();
|
||||
}
|
||||
await tick();
|
||||
}
|
||||
return await result;
|
||||
}
|
||||
|
||||
function readAvatar(client, index, signal) {
|
||||
return client.fetch(avatarRequest(index, signal)).then(async (response) => ({
|
||||
status: response.status,
|
||||
text: await response.text(),
|
||||
}));
|
||||
}
|
||||
|
||||
describe('the local app proxy bounds its own concurrency instead of overrunning outbound HTTP', () => {
|
||||
test('every avatar of a fully rendered chat resolves rather than tripping the outbound capacity cap', async () => {
|
||||
const {outboundHTTP, state} = createOutboundHTTP();
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetches = [];
|
||||
for (let index = 0; index < RENDERED_AVATAR_COUNT; index += 1) {
|
||||
fetches.push(readAvatar(client, index, controller.signal));
|
||||
}
|
||||
const responses = await pump(state, Promise.all(fetches));
|
||||
|
||||
assert.equal(responses.length, RENDERED_AVATAR_COUNT);
|
||||
assert.deepEqual(new Set(responses.map((response) => response.status)), new Set([200]));
|
||||
assert.equal(state.started, RENDERED_AVATAR_COUNT);
|
||||
assert.deepEqual(
|
||||
responses.map((response) => response.text),
|
||||
new Array(RENDERED_AVATAR_COUNT).fill('avatar-bytes'),
|
||||
);
|
||||
});
|
||||
|
||||
test('no more requests are in flight at any instant than the proxy concurrency limit', async () => {
|
||||
const {outboundHTTP, state} = createOutboundHTTP();
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const controller = new AbortController();
|
||||
|
||||
const fetches = [];
|
||||
for (let index = 0; index < RENDERED_AVATAR_COUNT; index += 1) {
|
||||
fetches.push(readAvatar(client, index, controller.signal));
|
||||
}
|
||||
await tick();
|
||||
const peakBeforeAnyCompletion = state.peakInFlight;
|
||||
await pump(state, Promise.all(fetches));
|
||||
|
||||
assert.equal(peakBeforeAnyCompletion, PROXY_MAX_CONCURRENT_REQUESTS);
|
||||
assert.equal(state.peakInFlight, PROXY_MAX_CONCURRENT_REQUESTS);
|
||||
});
|
||||
|
||||
test('an aborted waiter rejects with the abort reason and leaves its queue slot to the next waiter', async () => {
|
||||
const {outboundHTTP, state} = createOutboundHTTP();
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const held = new AbortController();
|
||||
|
||||
const holding = [];
|
||||
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS; index += 1) {
|
||||
holding.push(readAvatar(client, index, held.signal));
|
||||
}
|
||||
await tick();
|
||||
assert.equal(state.started, PROXY_MAX_CONCURRENT_REQUESTS);
|
||||
|
||||
const abandoned = new AbortController();
|
||||
const abandonedFetch = client.fetch(avatarRequest(998, abandoned.signal));
|
||||
const survivor = readAvatar(client, 999, held.signal);
|
||||
const reason = new Error('the renderer navigated away');
|
||||
abandoned.abort(reason);
|
||||
|
||||
await assert.rejects(abandonedFetch, (error) => error === reason);
|
||||
assert.equal(state.started, PROXY_MAX_CONCURRENT_REQUESTS);
|
||||
|
||||
state.pending.shift()();
|
||||
await tick();
|
||||
await tick();
|
||||
assert.equal(state.started, PROXY_MAX_CONCURRENT_REQUESTS + 1);
|
||||
|
||||
const settled = await pump(state, Promise.all([survivor, ...holding]));
|
||||
assert.equal(settled[0].status, 200);
|
||||
assert.equal(settled.length, PROXY_MAX_CONCURRENT_REQUESTS + 1);
|
||||
});
|
||||
|
||||
test('the queue refuses a request past its bound rather than growing without limit', async () => {
|
||||
const {outboundHTTP, state} = createOutboundHTTP();
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const controller = new AbortController();
|
||||
|
||||
const accepted = [];
|
||||
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS + PROXY_MAX_QUEUED_REQUESTS; index += 1) {
|
||||
accepted.push(readAvatar(client, index, controller.signal));
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
client.fetch(avatarRequest(Number.MAX_SAFE_INTEGER, controller.signal)),
|
||||
(error) => error.name === 'LocalAppProxyQueueOverflowError',
|
||||
);
|
||||
|
||||
const settled = await pump(state, Promise.all(accepted));
|
||||
assert.equal(settled.length, PROXY_MAX_CONCURRENT_REQUESTS + PROXY_MAX_QUEUED_REQUESTS);
|
||||
});
|
||||
});
|
||||
|
||||
const RESOURCE_HEADERS_DEADLINE_MS = 30_000;
|
||||
|
||||
function createSilentUpstream() {
|
||||
function createUpstream({streamBodies}) {
|
||||
const state = {started: [], aborted: 0};
|
||||
const outboundHTTP = {
|
||||
request: (request) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const entry = {
|
||||
url: request.url,
|
||||
serviceName: request.serviceName,
|
||||
answer: () =>
|
||||
resolve({
|
||||
headers: {'content-type': 'image/webp'},
|
||||
message: Readable.from([Buffer.from('avatar-bytes')]),
|
||||
message: streamBodies ? new PassThrough() : Readable.from([Buffer.from('avatar-bytes')]),
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
url: new URL(request.url),
|
||||
@@ -212,80 +63,64 @@ function createSilentUpstream() {
|
||||
return {outboundHTTP, state};
|
||||
}
|
||||
|
||||
function apiRequest(signal) {
|
||||
return {...avatarRequest(0, signal), targetURL: 'https://api.fluxer.app/v1/users/@me'};
|
||||
}
|
||||
describe('the local app proxy never queues a request behind another', () => {
|
||||
test('every avatar of a fully rendered chat starts at once', async () => {
|
||||
const {outboundHTTP, state} = createUpstream({streamBodies: false});
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const renderer = new AbortController();
|
||||
|
||||
describe('media that a slow or silent self-host never answers cannot wedge the local app proxy', () => {
|
||||
test('avatars dropped while their upstream sends no headers free their permits at the headers deadline', async () => {
|
||||
const fetches = [];
|
||||
for (let index = 0; index < RENDERED_AVATAR_COUNT; index += 1) {
|
||||
fetches.push(client.fetch(avatarRequest(index, renderer.signal)));
|
||||
}
|
||||
await tick();
|
||||
assert.equal(state.started.length, RENDERED_AVATAR_COUNT);
|
||||
|
||||
for (const entry of state.started) {
|
||||
entry.answer();
|
||||
}
|
||||
const texts = await Promise.all(fetches.map(async (fetch) => await (await fetch).text()));
|
||||
assert.deepEqual(texts, new Array(RENDERED_AVATAR_COUNT).fill('avatar-bytes'));
|
||||
});
|
||||
|
||||
test('a media request starts at once while earlier response bodies sit unread', async () => {
|
||||
const {outboundHTTP, state} = createUpstream({streamBodies: true});
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const renderer = new AbortController();
|
||||
|
||||
for (let index = 0; index < PARKED_MEDIA_BODY_COUNT; index += 1) {
|
||||
const fetch = client.fetch(avatarRequest(index, renderer.signal));
|
||||
await tick();
|
||||
state.started.at(-1).answer();
|
||||
assert.equal((await fetch).status, 200);
|
||||
}
|
||||
|
||||
const next = client.fetch(avatarRequest(PARKED_MEDIA_BODY_COUNT, renderer.signal));
|
||||
await tick();
|
||||
assert.equal(state.started.length, PARKED_MEDIA_BODY_COUNT + 1);
|
||||
state.started.at(-1).answer();
|
||||
assert.equal((await next).status, 200);
|
||||
renderer.abort();
|
||||
});
|
||||
});
|
||||
|
||||
describe('media that a slow or silent self-host never answers is cut off', () => {
|
||||
test('a silent upstream is aborted at the headers deadline', async () => {
|
||||
mock.timers.enable({apis: ['setTimeout']});
|
||||
try {
|
||||
const {outboundHTTP, state} = createSilentUpstream();
|
||||
const {outboundHTTP, state} = createUpstream({streamBodies: false});
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const renderer = new AbortController();
|
||||
const dropped = [];
|
||||
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS; index += 1) {
|
||||
const fetch = client.fetch(avatarRequest(index, renderer.signal));
|
||||
fetch.catch(() => {});
|
||||
dropped.push(fetch);
|
||||
}
|
||||
const silent = client.fetch(avatarRequest(0, renderer.signal));
|
||||
silent.catch(() => {});
|
||||
await tick();
|
||||
const fresh = client.fetch(avatarRequest(1000, renderer.signal));
|
||||
await tick();
|
||||
assert.equal(state.started.length, PROXY_MAX_CONCURRENT_REQUESTS);
|
||||
|
||||
mock.timers.tick(RESOURCE_HEADERS_DEADLINE_MS);
|
||||
await Promise.allSettled(dropped);
|
||||
await tick();
|
||||
|
||||
assert.equal(state.aborted, PROXY_MAX_CONCURRENT_REQUESTS);
|
||||
assert.equal(state.started.length, PROXY_MAX_CONCURRENT_REQUESTS + 1);
|
||||
state.started.at(-1).answer();
|
||||
const response = await fresh;
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(await response.text(), 'avatar-bytes');
|
||||
await assert.rejects(silent, (error) => error.name === 'LocalAppProxyResponseHeadersDeadlineError');
|
||||
assert.equal(state.aborted, 1);
|
||||
} finally {
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test('API requests keep flowing while every media permit is held by a silent upstream', async () => {
|
||||
const {outboundHTTP, state} = createSilentUpstream();
|
||||
const media = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const api = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.API});
|
||||
const renderer = new AbortController();
|
||||
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS * 2; index += 1) {
|
||||
media.fetch(avatarRequest(index, renderer.signal)).catch(() => {});
|
||||
}
|
||||
await tick();
|
||||
const apiCall = api.fetch(apiRequest(renderer.signal));
|
||||
await tick();
|
||||
const started = state.started.at(-1);
|
||||
assert.equal(started.url, 'https://api.fluxer.app/v1/users/@me');
|
||||
assert.equal(started.serviceName, LocalAppProxyTraffic.API);
|
||||
started.answer();
|
||||
assert.equal((await apiCall).status, 200);
|
||||
renderer.abort();
|
||||
});
|
||||
|
||||
test('the newest queued media request is admitted first so what is on screen loads before what scrolled away', async () => {
|
||||
const {outboundHTTP, state} = createSilentUpstream();
|
||||
const client = new DesktopLocalAppProxyClient({outboundHTTP, traffic: LocalAppProxyTraffic.REMOTE_RESOURCE});
|
||||
const renderer = new AbortController();
|
||||
const fetches = [];
|
||||
for (let index = 0; index < PROXY_MAX_CONCURRENT_REQUESTS + 3; index += 1) {
|
||||
const fetch = client.fetch(avatarRequest(index, renderer.signal));
|
||||
fetch.catch(() => {});
|
||||
fetches.push(fetch);
|
||||
}
|
||||
await tick();
|
||||
state.started[0].answer();
|
||||
await (await fetches[0]).text();
|
||||
await tick();
|
||||
assert.equal(
|
||||
state.started.at(-1).url,
|
||||
`https://media.fluxer.app/avatars/${PROXY_MAX_CONCURRENT_REQUESTS + 2}.webp`,
|
||||
);
|
||||
renderer.abort();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user