From 4ddcda122ab4c92b429a3f72d7c169c510779ef5 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 19:35:06 -0400 Subject: [PATCH] fix(calls): one delayed-event restart in flight per id, with a timeout (#318) During a homeserver stall Element Call retries update_delayed_event every 2 s, and the host opened a new HTTP request each time: 12 pending in a 60 s stall, all landing together on recovery (404 x5, 429 x7). Share the in-flight restart per delay id and abort it after 5 s. Refs #318 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- src/app/plugins/call/CallWidgetDriver.ts | 15 ++++++++++- src/app/utils/inFlight.test.ts | 32 ++++++++++++++++++++++++ src/app/utils/inFlight.ts | 14 +++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/app/utils/inFlight.test.ts create mode 100644 src/app/utils/inFlight.ts diff --git a/src/app/plugins/call/CallWidgetDriver.ts b/src/app/plugins/call/CallWidgetDriver.ts index f0d6e5a2f..3600d6dc8 100644 --- a/src/app/plugins/call/CallWidgetDriver.ts +++ b/src/app/plugins/call/CallWidgetDriver.ts @@ -28,7 +28,10 @@ import { } from 'matrix-js-sdk'; import { getCallCapabilities } from './utils'; import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix'; +import { dedupeInFlight } from '../../utils/inFlight'; +/** A little longer than Element Call's own 2 s timeout for a restart. */ +const RESTART_TIMEOUT_MS = 5000; export class CallWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; @@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver { await this.mx._unstable_cancelScheduledDelayedEvent(delayId); } + // [Gitea #318] While the homeserver is stalled, Element Call times out each + // restart locally after 2 s and asks again, so requests for the same delay + // id piled up and hit the server together on recovery (404s, then 429s). + // Share one in-flight restart per id, and give it a timeout of its own. + private readonly restartOnce = dedupeInFlight(); + public async restartScheduledDelayedEvent(delayId: string): Promise { - await this.mx._unstable_restartScheduledDelayedEvent(delayId); + await this.restartOnce(delayId, () => + this.mx._unstable_restartScheduledDelayedEvent(delayId, { + localTimeoutMs: RESTART_TIMEOUT_MS, + }), + ); } public async sendScheduledDelayedEvent(delayId: string): Promise { diff --git a/src/app/utils/inFlight.test.ts b/src/app/utils/inFlight.test.ts new file mode 100644 index 000000000..832366613 --- /dev/null +++ b/src/app/utils/inFlight.test.ts @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { dedupeInFlight } from './inFlight'; + +test('dedupeInFlight: one request per key while pending, a new one after (#318)', async () => { + const once = dedupeInFlight(); + let calls = 0; + let release!: () => void; + const gate = new Promise((r) => { + release = r; + }); + const run = async () => { + calls += 1; + await gate; + return calls; + }; + const a = once('d1', run); + const b = once('d1', run); + const c = once('d2', run); + assert.equal(a, b); + release(); + assert.deepEqual(await Promise.all([a, b, c]), [2, 2, 2]); + assert.equal(calls, 2); + await once('d1', run); + assert.equal(calls, 3, 'settled requests are not reused'); +}); + +test('dedupeInFlight: a rejection clears the key', async () => { + const once = dedupeInFlight(); + await assert.rejects(once('k', () => Promise.reject(new Error('boom')))); + await once('k', () => Promise.resolve()); +}); diff --git a/src/app/utils/inFlight.ts b/src/app/utils/inFlight.ts new file mode 100644 index 000000000..a1fd10080 --- /dev/null +++ b/src/app/utils/inFlight.ts @@ -0,0 +1,14 @@ +/** + * Share one in-flight promise per key: callers that ask again while a request + * for the same key is still pending get that request instead of a new one. + */ +export function dedupeInFlight(): (key: string, run: () => Promise) => Promise { + const pending = new Map>(); + return (key, run) => { + const existing = pending.get(key); + if (existing) return existing; + const p = run().finally(() => pending.delete(key)); + pending.set(key, p); + return p; + }; +}