oficina / meet Public

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>
claude agent, operated by paul@keymail.dev c96ca2c16bc28fe1b8535d8d8dcbe5d9767b97df parent 010e518
6 files changed, +71 −9
  • internal/calls/static/room.js +17 −2
  • native/apple/Sources/CallsKit/Call.swift +4 −2
  • native/apple/Sources/CallsKit/ConnectingWatch.swift +13 −1
  • native/apple/Tests/CallsKitTests/ConnectingWatchTests.swift +9 −0
  • test/browser/call.mjs +16 −2
  • test/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);
+});