Report hearing on a gain or a loss, not a departure; steady the gates
A departure dropping someone from hearing cost a report from everyone left, and the probehang scenario caught it as a second post. hearingMoved (and HearingRule.moved) report only someone newly heard, or someone still here no longer heard. probehang now lets A's first hearing report go before it holds everything A posts, and the connecting scenario allows the 409s its own held signals earn.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+71
−9
internal/calls/static/room.js+17 −2native/apple/Sources/CallsKit/Call.swift+4 −2native/apple/Sources/CallsKit/ConnectingWatch.swift+13 −1native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift+9 −0test/browser/call.mjs+16 −2test/browser/room-connecting.test.mjs+12 −2
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js| index c99871d..ee8aefe 100644 |
| --- a/internal/calls/static/room.js |
| +++ b/internal/calls/static/room.js |
| @@ -4877,6 +4877,20 @@ |
| return out.sort().slice(0, 15); |
| } |
| + // hearingMoved is whether `hearing` has changed in a way worth a report |
| + // (both lists joined with commas): someone heard who was not, or someone |
| + // still here no longer heard. Someone who has left is not news — every |
| + // departure would otherwise cost a report from everyone left — and their |
| + // id rides along, ignored, until the next report for another reason. |
| + // Pure, and lifted by name in the node test. |
| + function hearingMoved(before, now, present) { |
| + const was = new Set(before ? before.split(',') : []); |
| + const is = new Set(now ? now.split(',') : []); |
| + for (const id of is) if (!was.has(id)) return true; |
| + for (const id of was) if (!is.has(id) && present.has(id)) return true; |
| + return false; |
| + } |
| + |
| // watchConnecting feeds connectingStep what this tab knows, and says |
| // "<name> can hear you now" for anyone whose words have just gone. |
| function watchConnecting(now) { |
| @@ -5692,9 +5706,10 @@ |
| } |
| const key = noSoundList().join(','); |
| const heardKey = hearingList().join(','); |
| - if (key !== noSoundKey || heardKey !== hearingKey) { |
| + const heardMoved = hearingMoved(hearingKey, heardKey, new Set(pcs.keys())); |
| + hearingKey = heardKey; |
| + if (key !== noSoundKey || heardMoved) { |
| noSoundKey = key; |
| - hearingKey = heardKey; |
| reportMedia(); |
| } |
| for (const k of tiles.keys()) drawHearing(k); |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index 801044d..46b4418 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -679,14 +679,16 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| audioArriving.removeValue(forKey: id) |
| } |
| reportNoSound(levels.noSoundFrom) |
| - reportHearing(heardFrom.filter { $0.value.heard }.keys.sorted()) |
| + reportHearing(heardFrom.filter { $0.value.heard }.keys.sorted(), present: present) |
| } |
| /// §15.24: whose audio this app is receiving and playing. Sent only on a |
| /// change, in order, off the main thread, as `noSoundFrom` is. |
| - private func reportHearing(_ peers: [String]) { |
| + private func reportHearing(_ peers: [String], present: Set<String>) { |
| guard peers != reportedHearing, let client = bridge else { return } |
| + let moved = HearingRule.moved(from: reportedHearing, to: peers, present: present) |
| reportedHearing = peers |
| + guard moved else { return } |
| let body = json(peers) ?? "[]" |
| noSoundQueue.async { try? client.setHearing(body) } |
| } |
diff --git a/native/apple/Sources/CallsKit/ConnectingWatch.swift b/native/apple/Sources/CallsKit/ConnectingWatch.swift| index f8827b4..798cafc 100644 |
| --- a/native/apple/Sources/CallsKit/ConnectingWatch.swift |
| +++ b/native/apple/Sources/CallsKit/ConnectingWatch.swift |
| @@ -143,6 +143,18 @@ enum HearingRule { |
| return f.receiverLive && f.packets > 0 && f.decoded > 0 |
| } |
| + /// Whether `hearing` has changed in a way worth a report: someone heard |
| + /// who was not, or someone still here no longer heard. Someone who has |
| + /// left is not news — every departure would otherwise cost a report from |
| + /// everyone left — and the core carries their id, ignored, until the next |
| + /// report for another reason. The browser's `hearingMoved` is its twin. |
| + static func moved(from before: [String], to now: [String], present: Set<String>) -> Bool { |
| + let was = Set(before) |
| + let is_ = Set(now) |
| + if !is_.subtracting(was).isEmpty { return true } |
| + return !was.subtracting(is_).intersection(present).isEmpty |
| + } |
| + |
| /// A person's report on you: true when their `hearing` lists you, false |
| /// when it has a list and you are not on it, nil when it has no list — no |
| /// report yet, or a client from before §15.24. |
| @@ -153,7 +165,7 @@ enum HearingRule { |
| } |
| /// The words (spec §15.24). The ellipsis is drawn apart from them |
| -/// (`ConnectingEllipsis`), so VoiceOver hears the sentence and not the dots. |
| +/// (`ConnectingText`), so VoiceOver hears the sentence and not the dots. |
| public enum ConnectingWords { |
| public static func connecting(_ name: String) -> String { "\(name) is connecting and can’t hear you yet" } |
| public static func canHear(_ name: String) -> String { "\(name) can hear you now" } |
diff --git a/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift| index 53e29ae..aa12d4d 100644 |
| --- a/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift |
| +++ b/native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift |
| @@ -228,4 +228,13 @@ final class ConnectingWatchTests: XCTestCase { |
| XCTAssertEqual(ConnectingText.opacity(at: 0.76, dot: 1), 1, accuracy: 0.001) |
| XCTAssertEqual(ConnectingText.opacity(at: 0.96, dot: 2), 1, accuracy: 0.001) |
| } |
| + |
| + func testAHearingReportGoesForAGainOrALossOfSomeoneStillHereAndNotForADeparture() { |
| + let here: Set<String> = ["a", "b"] |
| + XCTAssertTrue(HearingRule.moved(from: [], to: ["a"], present: here)) |
| + XCTAssertTrue(HearingRule.moved(from: ["a"], to: ["a", "b"], present: here)) |
| + XCTAssertTrue(HearingRule.moved(from: ["a", "b"], to: ["a"], present: here)) |
| + XCTAssertFalse(HearingRule.moved(from: ["a", "c"], to: ["a"], present: here), "c has left") |
| + XCTAssertFalse(HearingRule.moved(from: ["a"], to: ["a"], present: here)) |
| + } |
| } |
diff --git a/test/browser/call.mjs b/test/browser/call.mjs| index 15a63f9..f39030c 100644 |
| --- a/test/browser/call.mjs |
| +++ b/test/browser/call.mjs |
| @@ -3874,8 +3874,16 @@ const run = { |
| return; |
| } |
| + // §15.24: A reports who it hears once the pair is up; let that report |
| + // go before everything A posts is held, or it is counted below as a |
| + // second message the recovery sent. |
| + const settled = await until(async () => ((await state(pageA)).hearingList || []).includes(pair.b.peer), 10000); |
| + check(settled, 'A reports that it hears B before the cut'); |
| + await sleep(500); |
| + |
| let firstAt = 0; |
| let signals = 0; |
| + const posted = []; |
| let hungProbes = 0; |
| // Everything A posts from here is answered 404, twenty seconds |
| // after it was sent: "that peer is not here", arriving with a third |
| @@ -3883,6 +3891,8 @@ const run = { |
| await ctxA.route('**/signal', async (route) => { |
| if (!firstAt) firstAt = Date.now(); |
| signals += 1; |
| + const m = (route.request().postData() || '').match(/"type":"([a-z-]+)"/); |
| + posted.push(m ? m[1] : '?'); |
| await sleep(20000); |
| await route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"not-found"}' }); |
| }); |
| @@ -3917,7 +3927,7 @@ const run = { |
| // scenario sent is the only thing A ever posted, and it is still the |
| // only thing ten seconds later. |
| const atDrop = signals; |
| - check(atDrop === 1, 'the offer was the only thing A posted (' + atDrop + ')'); |
| + check(atDrop === 1, 'the offer was the only thing A posted (' + atDrop + ': ' + posted.join(', ') + ')'); |
| check(dropped && dropped.stale && dropped.stale.includes(pair.b.peer), |
| 'and A holds the target marked stale, waiting for a roster rather than asking again (' + |
| JSON.stringify(dropped && dropped.stale) + ')'); |
| @@ -4744,7 +4754,11 @@ const run = { |
| await ctxB.unroute('**/signal', oldClient); |
| check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')'); |
| - check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')'); |
| + // A signal held back for seconds can land after the pair has moved to |
| + // a new generation, and the hub refuses it 409 (§15.9) — which is the |
| + // hold's doing, and what the client recovers from. Nothing else. |
| + const errorsB = (consoleErrors.get('member B') || []).filter((e) => !/status of 409/.test(e)).join('; '); |
| + check(errorsB === '', 'no console errors in B but the held signals’ 409s (' + errorsB + ')'); |
| await leaveRoom(pageA); |
| await leaveRoom(pageB); |
| await ctxA.close(); |
diff --git a/test/browser/room-connecting.test.mjs b/test/browser/room-connecting.test.mjs| index 41fc497..74a57de 100644 |
| --- a/test/browser/room-connecting.test.mjs |
| +++ b/test/browser/room-connecting.test.mjs |
| @@ -39,11 +39,11 @@ function liftConst(name) { |
| } |
| const CONSTS = ['CONNECTING_GRACE', 'CONNECTING_CAP'].map(liftConst).join('\n'); |
| const names = ['connectingRecord', 'connectingStep', 'hearsYou', 'peerNotice', 'remoteNotice', 'connectingWords', |
| - 'canHearWords', 'judgeHeard']; |
| + 'canHearWords', 'judgeHeard', 'hearingMoved']; |
| // eslint-disable-next-line no-new-func |
| const fns = new Function(CONSTS + '\n' + names.map(lift).join('\n') + '; return { ' + names.join(', ') + |
| ', GRACE: CONNECTING_GRACE, CAP: CONNECTING_CAP };')(); |
| -const { connectingRecord, connectingStep, hearsYou, peerNotice, judgeHeard, GRACE, CAP } = fns; |
| +const { connectingRecord, connectingStep, hearsYou, peerNotice, judgeHeard, hearingMoved, GRACE, CAP } = fns; |
| const ME = 'm'.repeat(32); |
| const SAM = 's'.repeat(32); |
| @@ -184,3 +184,13 @@ test('judgeHeard: arriving and decoded, or a muted sender on a live connection', |
| assert.equal(judgeHeard(true, { ...base, connected: false }), true, 'a blip is not losing them'); |
| assert.equal(judgeHeard(true, { ...base, bad: true }), false, 'a failed connection is'); |
| }); |
| + |
| +test('hearingMoved: a gain, or a loss of someone still here, and never a departure alone', () => { |
| + const here = new Set(['a', 'b']); |
| + assert.equal(hearingMoved('', 'a', here), true); |
| + assert.equal(hearingMoved('a', 'a,b', here), true); |
| + assert.equal(hearingMoved('a,b', 'a', here), true, 'b is still here and no longer heard'); |
| + assert.equal(hearingMoved('a,c', 'a', here), false, 'c has left: not worth a report from everyone'); |
| + assert.equal(hearingMoved('a', 'a', here), false); |
| + assert.equal(hearingMoved('', '', here), false); |
| +}); |