aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-18 23:24:17 -0700
committerAdam Soltys <[email protected]>2026-10-18 23:24:17 -0700
commit7367343cb45d08bcc0f5e3246c8c5462ea204281 (patch)
tree87ae8563c50a91eeec0662928b79067ca863c25e
parentd007dbec5d44963b4650baf9668389ec7da944f3 (diff)
downloadcoinosv3-7367343cb45d08bcc0f5e3246c8c5462ea204281.tar.gz
coinosv3-7367343cb45d08bcc0f5e3246c8c5462ea204281.zip
Calls: the routes found before the offer or answer ride inside it (until there's a direct and a relayed one, 1.5 s at most) and only later ones go one by one — each route was its own signed message, dozens per call; a household's two devices calling each other got its IP banned by damus and rate-limited by relay.coinos.io, and the answer was refused with them: the callee stuck on Connecting…, the caller ringing on. An offer, answer, hang-up or decline no relay took is tried again (1.5, 3, 6 s) before the call gives up.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_013ZDf1cyCQLkfjftYhymZAg
-rw-r--r--src/calls.js14
-rw-r--r--src/features/messages.js14
-rw-r--r--tools/call-test.js4
3 files changed, 26 insertions, 6 deletions
diff --git a/src/calls.js b/src/calls.js
index e95b975..366b00d 100644
--- a/src/calls.js
+++ b/src/calls.js
@@ -115,7 +115,12 @@ export function createCalls(env) {
c.trickle = env.trickle ? env.trickle() !== false : true;
c.sent = [];
pc.onicecandidate = (e) => {
- if (!c.trickle || !e.candidate || call !== c || c.state === 'ended') return;
+ // the routes found before the offer or answer went ride inside it;
+ // only the late ones go one by one (one signed message each — dozens
+ // of them got the household's IP rate-limited on the relays, and the
+ // answer refused with them)
+ if (!c.trickle || !c.described || !e.candidate || call !== c || c.state === 'ended') return;
+ if (c.described.includes(e.candidate.candidate)) return;
c.sent.push(e.candidate);
sendFor(c, CALL.ICE, c.peer, candidateJson(e.candidate)).catch(() => {});
};
@@ -160,7 +165,7 @@ export function createCalls(env) {
const pc = c.pc;
const enough = () => pc.iceGatheringState === 'complete'
|| (/ typ srflx/.test(pc.localDescription.sdp) && / typ relay/.test(pc.localDescription.sdp));
- if (!c.trickle && !enough()) {
+ if (!enough()) {
await new Promise((resolve) => {
const check = () => { if (enough()) resolve(); };
pc.addEventListener('icegatheringstatechange', check);
@@ -168,7 +173,10 @@ export function createCalls(env) {
setTimeout(resolve, 1500);
});
}
- return pc.localDescription.sdp;
+ const sdp = pc.localDescription.sdp;
+ // what it carries isn't sent again; what comes later is (trickle)
+ c.described = sdp;
+ return sdp;
}
async function addCandidates(c) {
const list = [...(early.get(c.peer) || []), ...c.ice];
diff --git a/src/features/messages.js b/src/features/messages.js
index 887f327..bfdabd1 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -53,7 +53,7 @@ import { animateZap, warmZapSound } from '../zap-animation.js';
import { SIGNER_SILENT } from '../dm.js';
import { cacheStorage, onCacheReady, cacheIsReady } from '../cache-store.js';
import { dlog } from '../debug.js';
-import { createCalls, openCall, wrapCall, CALL_WRAP, RING_S } from '../calls.js';
+import { createCalls, openCall, wrapCall, CALL, CALL_WRAP, RING_S } from '../calls.js';
import { LIVE_KIND, LIVE_CHAT_KIND, LIVE_RELAYS, liveAddr, liveHost, liveTitle, liveSummary, liveImage, liveViewers, liveStarts, liveStream, liveRecording, liveStatus as announcedStatus, isLiveNow as isLiveAnnounced, liveRelays, newestByAddr, zapLine, setZapAddr } from '../live.js';
import { qrSvg } from '../qr.js';
@@ -9305,9 +9305,17 @@ export function messagesFeature(ctx) {
calls = createCalls({
me: () => dmSubKeys()[0],
sign: signAsMe,
+ // An offer, answer, hang-up or decline nobody took is tried again for a
+ // while (a relay's per-minute limit, a socket reconnecting): a lost
+ // answer left the caller ringing on and the callee "Connecting…".
+ // A late route isn't worth the wait.
send: async (pk, signed) => {
- const ok = await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth });
- if (!ok) throw new Error('not delivered');
+ const tries = signed.kind === CALL.ICE ? 1 : 4;
+ for (let i = 0; i < tries; i++) {
+ if (i) await new Promise((r) => setTimeout(r, 1500 * 2 ** (i - 1)));
+ if (await publishOn(await inboxFor(pk), wrapCall(signed, pk), { onauth: relayAuth })) return;
+ }
+ throw new Error('not delivered');
},
iceServers: callIce,
media: callMedia,
diff --git a/tools/call-test.js b/tools/call-test.js
index 5a0c3a0..15b3d98 100644
--- a/tools/call-test.js
+++ b/tools/call-test.js
@@ -106,6 +106,7 @@ try {
console.log('[a video call]');
await openDm(A, B.pk);
check('the conversation has voice and video call buttons', await A.page.evaluate(() => document.querySelectorAll('.chat-head .call-start').length === 2));
+ const w0 = wrapIds.size;
await A.page.evaluate(() => document.querySelector('.call-start[data-call="video"]').click());
check('the caller sees it calling, their own camera on', await waitFor(A.page, () => /calling/i.test(document.querySelector('.call-overlay .call-status')?.textContent || '') && document.querySelector('video.call-local')?.videoWidth > 0, 10000));
check('the callee, on their wallet page, is rung: who, and a video call', await waitFor(B.page, () => !!document.querySelector('.call-overlay .call-accept') && /Alice Caller/.test(document.querySelector('.call-overlay').innerText) && /video call/i.test(document.querySelector('.call-overlay').innerText), 15000));
@@ -119,6 +120,9 @@ try {
await sleep(1500);
const ma = await media(A.page), mb = await media(B.page);
check('each sees the other\'s picture and gets their sound', ma.video && ma.audio && mb.video && mb.audio, JSON.stringify({ ma, mb }));
+ // the routes ride in the offer and answer: a household's two devices
+ // calling each other mustn't flood the relays (a per-IP limit ate the answer)
+ check('...in a handful of messages, not one per route', wrapIds.size - w0 <= 6, (wrapIds.size - w0) + ' wraps');
await B.page.evaluate(() => document.querySelector('.call-bar .call-btn[aria-label="Mute"]').click());
check('mute turns the microphone off', await waitFor(B.page, () => !!document.querySelector('.call-btn.on[aria-label="Unmute"]'), 3000));
await A.page.evaluate(() => document.querySelector('.call-btn[aria-label="Minimize"]').click());