Root cause: BaseMediaConnection.sendOpcode is a silent no-op when ws.readyState !== OPEN. In GoLive, playStream() calls setVideoAttributes(true) + setSpeaking(true) the instant createStream() resolves (right after SELECT_PROTOCOL_ACK), but the StreamConnection WebSocket can still be in CONNECTING for a few ms — so op 12 (VIDEO, activating the video SSRC) was silently DROPPED every session. Empirically verified: 0 ops 12/5 ever logged across the entire journal, yet 10k+ video frames were sent and audio played (audio SSRC is activated via the VoiceConnection handshake, independent of GoLive op 12). Discord's media server thus received video RTP on video_ssrc but was never told to forward it → black/broken shared-screen video with working voice. sendOpcodeWhenOpen retries up to ~2s for ws OPEN instead of dropping. Also emits a=fmtp:101 packetization-mode=1;profile-level-id=42e01f in the answer SDP (H264 FU-A fragments require packetization-mode=1 to reassemble). Also removes pre-existing noNonNullAssertion lint (biome 2.5.8 now errors) that was blocking the deploy CI.
116 lines
3.4 KiB
TypeScript
116 lines
3.4 KiB
TypeScript
// Utility functions shared across services
|
|
|
|
export function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export * from "./pagination.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Centralized AbortController with guaranteed cleanup
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Creates an AbortController with a timeout that is ALWAYS cleaned up,
|
|
* even if the caller throws or returns early without calling clear().
|
|
*
|
|
* Returns both the controller and a cleanup handle.
|
|
*
|
|
* Usage:
|
|
* const { controller, clear } = createAbortControllerWithTimeout(8000);
|
|
* try {
|
|
* const res = await fetch(url, { signal: controller.signal });
|
|
* // ... work ...
|
|
* } finally {
|
|
* clear(); // guaranteed to clear the timeout
|
|
* }
|
|
*/
|
|
export function createAbortControllerWithTimeout(timeoutMs: number): {
|
|
controller: AbortController;
|
|
clear: () => void;
|
|
} {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
// Unref so the timeout doesn't keep the process alive
|
|
timeoutId?.unref?.();
|
|
return {
|
|
controller,
|
|
clear: () => {
|
|
clearTimeout(timeoutId);
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Retry with exponential backoff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export async function retryWithBackoff<T>(
|
|
fn: () => Promise<T>,
|
|
options: {
|
|
/** Number of retry attempts (default: 3) */
|
|
retries?: number;
|
|
/** Initial delay in ms (default: 1000) */
|
|
minTimeout?: number;
|
|
/** Maximum delay in ms (default: 30000) */
|
|
maxTimeout?: number;
|
|
/** Multiplication factor for each retry (default: 2) */
|
|
factor?: number;
|
|
/** Optional AbortSignal to cancel retries */
|
|
signal?: AbortSignal;
|
|
} = {},
|
|
): Promise<T> {
|
|
const {
|
|
retries = 3,
|
|
minTimeout = 1_000,
|
|
maxTimeout = 30_000,
|
|
factor = 2,
|
|
signal,
|
|
} = options;
|
|
|
|
let lastError: Error | undefined;
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
if (signal?.aborted) {
|
|
const err = new Error("Aborted");
|
|
err.name = "AbortError";
|
|
throw err;
|
|
}
|
|
|
|
try {
|
|
return await fn();
|
|
} catch (err) {
|
|
lastError = err instanceof Error ? err : new Error(String(err));
|
|
if (lastError.name === "AbortError") {
|
|
throw lastError;
|
|
}
|
|
if (attempt === retries) break;
|
|
const backoff = Math.min(
|
|
minTimeout * factor ** attempt + Math.random() * 100,
|
|
maxTimeout,
|
|
);
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
let timeoutId: NodeJS.Timeout;
|
|
const onAbort = () => {
|
|
clearTimeout(timeoutId);
|
|
const abortErr = new Error("Aborted");
|
|
abortErr.name = "AbortError";
|
|
reject(abortErr);
|
|
};
|
|
if (signal?.aborted) return onAbort();
|
|
|
|
timeoutId = setTimeout(() => {
|
|
if (signal) signal.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
}, backoff);
|
|
|
|
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
}
|
|
// lastError is always set: the for-loop only exits via break when attempt
|
|
// === retries, which only happens in the catch branch that sets lastError.
|
|
if (!lastError) throw new Error("Unknown retry error");
|
|
throw lastError;
|
|
}
|