@mentra/acs-meeting 3.2.0-dev.221 → 3.2.0-dev.223

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +4 -2
  2. package/android/build.gradle +23 -0
  3. package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
  4. package/android/src/main/AndroidManifest.xml +2 -0
  5. package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
  6. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
  7. package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
  8. package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
  9. package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
  10. package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
  11. package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
  12. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
  13. package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
  14. package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
  15. package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
  16. package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
  17. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
  18. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
  19. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
  20. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
  21. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
  22. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
  23. package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
  24. package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
  25. package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
  26. package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
  27. package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
  28. package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
  29. package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
  30. package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
  31. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
  32. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
  33. package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
  34. package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
  35. package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
  36. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
  37. package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
  38. package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
  39. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
  40. package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
  41. package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
  42. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
  43. package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
  44. package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
  45. package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
  46. package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
  47. package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
  48. package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
  49. package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
  50. package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
  51. package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
  52. package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
  53. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
  54. package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
  55. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
  56. package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
  57. package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
  58. package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
  59. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
  60. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
  61. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
  62. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
  63. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
  64. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
  65. package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
  66. package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
  67. package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
  68. package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
  69. package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
  70. package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
  71. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
  72. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
  73. package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
  74. package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
  75. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
  76. package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
  77. package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
  78. package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
  79. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
  80. package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
  81. package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
  82. package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
  83. package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
  84. package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
  85. package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
  86. package/build/AcsMeeting.types.d.ts +5 -0
  87. package/build/AcsMeeting.types.d.ts.map +1 -1
  88. package/build/AcsMeeting.types.js.map +1 -1
  89. package/build/AcsMeetingModule.d.ts +23 -0
  90. package/build/AcsMeetingModule.d.ts.map +1 -1
  91. package/build/AcsMeetingModule.js.map +1 -1
  92. package/build/AcsMeetingModule.web.d.ts +15 -0
  93. package/build/AcsMeetingModule.web.d.ts.map +1 -1
  94. package/build/AcsMeetingModule.web.js +13 -0
  95. package/build/AcsMeetingModule.web.js.map +1 -1
  96. package/ios/AcsMeetingModule.swift +213 -2
  97. package/package.json +1 -1
  98. package/src/AcsMeeting.types.ts +5 -0
  99. package/src/AcsMeetingModule.ts +13 -0
  100. package/src/AcsMeetingModule.web.ts +13 -0
@@ -0,0 +1,411 @@
1
+ package com.mentra.acsmeeting.source
2
+
3
+ import java.io.BufferedReader
4
+ import java.net.InetAddress
5
+ import java.net.InetSocketAddress
6
+ import java.net.Socket
7
+ import java.util.concurrent.ConcurrentLinkedQueue
8
+ import java.util.concurrent.CountDownLatch
9
+ import java.util.concurrent.TimeUnit
10
+ import org.assertj.core.api.Assertions.assertThat
11
+ import org.junit.After
12
+ import org.junit.Test
13
+
14
+ /**
15
+ * Drives [WhipIngestServer] over a real loopback socket with a stub negotiator.
16
+ *
17
+ * [WhipIngestProtocolTest] covers the decision table; this covers everything the table cannot: that
18
+ * the listener binds to one address, that a request actually parses off the wire, that a
19
+ * negotiation blocking for the length of an ICE gather does not block a concurrent DELETE, and that
20
+ * the session slot is genuinely freed when a negotiation fails. Loopback stands in for the hotspot
21
+ * address — the binding code is identical, only the interface differs.
22
+ */
23
+ class WhipIngestServerLoopbackTest {
24
+
25
+ private val loopback = InetAddress.getByName("127.0.0.1")
26
+ private var server: WhipIngestServer? = null
27
+
28
+ @After
29
+ fun tearDown() {
30
+ server?.closeNow()
31
+ }
32
+
33
+ /** Records what the server asked of it and answers on command. */
34
+ private class StubNegotiator(
35
+ private val answer: String = ANSWER_SDP,
36
+ private val failWith: String? = null,
37
+ ) : WhipIngestServer.Negotiator {
38
+ val offers = ConcurrentLinkedQueue<Pair<String, String>>()
39
+ val terminated = ConcurrentLinkedQueue<String>()
40
+ val entered = CountDownLatch(1)
41
+
42
+ /** Set to hold [negotiate] open, simulating a slow ICE gather. */
43
+ @Volatile var block: CountDownLatch? = null
44
+
45
+ /** ICE already dead — a second POST should replace, not 409. */
46
+ @Volatile var failed = false
47
+
48
+ override fun publisherFailed(): Boolean = failed
49
+
50
+ override fun negotiate(sessionId: String, offer: String): Result<String> {
51
+ offers.add(sessionId to offer)
52
+ entered.countDown()
53
+ block?.await(5, TimeUnit.SECONDS)
54
+ return failWith?.let { Result.failure(IllegalStateException(it)) } ?: Result.success(answer)
55
+ }
56
+
57
+ override fun terminate(sessionId: String) {
58
+ terminated.add(sessionId)
59
+ }
60
+ }
61
+
62
+ private fun start(negotiator: WhipIngestServer.Negotiator, id: String = "sess-1") =
63
+ WhipIngestServer(negotiator) { id }
64
+ .also { server = it }
65
+ .start(loopback)
66
+
67
+ // -----------------------------------------------------------------
68
+ // Binding
69
+ // -----------------------------------------------------------------
70
+
71
+ @Test
72
+ fun `the listener binds to the given address and reports the port`() {
73
+ val endpoint = start(StubNegotiator())
74
+
75
+ assertThat(endpoint.host).isEqualTo("127.0.0.1")
76
+ assertThat(endpoint.port).isGreaterThan(0)
77
+ assertThat(server!!.boundEndpoint).isEqualTo(endpoint)
78
+ assertThat(endpoint.sessionUrl("abc"))
79
+ .isEqualTo("http://127.0.0.1:${endpoint.port}/whip/abc")
80
+ }
81
+
82
+ @Test
83
+ fun `starting twice is rejected rather than silently rebinding`() {
84
+ val ingest = WhipIngestServer(StubNegotiator())
85
+ server = ingest
86
+ ingest.start(loopback)
87
+
88
+ // A second bind would leave an orphan listener holding a port for the rest of the process.
89
+ runCatching { ingest.start(loopback) }
90
+ .onSuccess { throw AssertionError("expected a rejected second start") }
91
+ .onFailure { assertThat(it).isInstanceOf(IllegalStateException::class.java) }
92
+ }
93
+
94
+ // -----------------------------------------------------------------
95
+ // Publishing
96
+ // -----------------------------------------------------------------
97
+
98
+ @Test
99
+ fun `an offer off the wire is answered with 201 and an absolute location`() {
100
+ val negotiator = StubNegotiator()
101
+ val endpoint = start(negotiator)
102
+
103
+ val response = request(endpoint.port, postOffer())
104
+
105
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 201 Created")
106
+ assertThat(response.header("Location"))
107
+ .isEqualTo("http://127.0.0.1:${endpoint.port}/whip/sess-1")
108
+ assertThat(response.header("Content-Type")).isEqualTo("application/sdp")
109
+ assertThat(response.body).isEqualTo(ANSWER_SDP)
110
+ assertThat(negotiator.offers).hasSize(1)
111
+ assertThat(negotiator.offers.peek().second).contains("m=video")
112
+ }
113
+
114
+ @Test
115
+ fun `the offer body reaches the negotiator intact`() {
116
+ val negotiator = StubNegotiator()
117
+ val endpoint = start(negotiator)
118
+ val offer = "v=0\r\n" + "a=x\r\n".repeat(400)
119
+
120
+ request(endpoint.port, postOffer(offer))
121
+
122
+ assertThat(negotiator.offers.peek().second).isEqualTo(offer)
123
+ }
124
+
125
+ @Test
126
+ fun `deleting the location terminates the publisher`() {
127
+ val negotiator = StubNegotiator()
128
+ val endpoint = start(negotiator)
129
+ request(endpoint.port, postOffer())
130
+
131
+ val response = request(endpoint.port, "DELETE /whip/sess-1 HTTP/1.1\r\nHost: x\r\n\r\n")
132
+
133
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 204 No Content")
134
+ assertThat(negotiator.terminated).containsExactly("sess-1")
135
+ }
136
+
137
+ @Test
138
+ fun `a publisher can rejoin after deleting`() {
139
+ val negotiator = StubNegotiator()
140
+ val endpoint = start(negotiator)
141
+ request(endpoint.port, postOffer())
142
+ request(endpoint.port, "DELETE /whip/sess-1 HTTP/1.1\r\nHost: x\r\n\r\n")
143
+
144
+ val response = request(endpoint.port, postOffer())
145
+
146
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 201 Created")
147
+ assertThat(negotiator.offers).hasSize(2)
148
+ }
149
+
150
+ // -----------------------------------------------------------------
151
+ // Concurrency
152
+ // -----------------------------------------------------------------
153
+
154
+ /**
155
+ * The reason each connection gets its own thread. A gather blocks for as long as ICE takes, and a
156
+ * DELETE arriving during it — the user leaving mid-join — must not sit in the accept queue behind
157
+ * it.
158
+ */
159
+ @Test
160
+ fun `a delete is served while a negotiation is still gathering`() {
161
+ val negotiator = StubNegotiator()
162
+ val gate = CountDownLatch(1)
163
+ negotiator.block = gate
164
+ val endpoint = start(negotiator)
165
+
166
+ val publisher = Thread { runCatching { request(endpoint.port, postOffer()) } }
167
+ publisher.start()
168
+ assertThat(negotiator.entered.await(5, TimeUnit.SECONDS))
169
+ .describedAs("negotiation never started")
170
+ .isTrue()
171
+
172
+ // Stale id, so this exercises routing and response without disturbing the pending session.
173
+ val response = request(endpoint.port, "DELETE /whip/other HTTP/1.1\r\nHost: x\r\n\r\n")
174
+
175
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 404 Not Found")
176
+ gate.countDown()
177
+ publisher.join(5_000)
178
+ }
179
+
180
+ /** A retry that arrives during a gather must not create a second peer for the same publisher. */
181
+ @Test
182
+ fun `a duplicate offer during a gather conflicts instead of negotiating twice`() {
183
+ val negotiator = StubNegotiator()
184
+ val gate = CountDownLatch(1)
185
+ negotiator.block = gate
186
+ val endpoint = start(negotiator)
187
+
188
+ val publisher = Thread { runCatching { request(endpoint.port, postOffer()) } }
189
+ publisher.start()
190
+ assertThat(negotiator.entered.await(5, TimeUnit.SECONDS)).isTrue()
191
+
192
+ val response = request(endpoint.port, postOffer())
193
+
194
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 409 Conflict")
195
+ assertThat(negotiator.offers).hasSize(1)
196
+ gate.countDown()
197
+ publisher.join(5_000)
198
+ }
199
+
200
+ /**
201
+ * Glasses reconnect after `PeerConnection disconnected` POSTs again. If ingest ICE is already
202
+ * failed, 409 wedges the call: StreamManager never started on SoftAP, so nobody tears the slot
203
+ * down. Replace the dead publisher instead.
204
+ */
205
+ @Test
206
+ fun `a second offer replaces a dead publisher instead of 409`() {
207
+ val negotiator = StubNegotiator()
208
+ val endpoint = start(negotiator)
209
+
210
+ val first = request(endpoint.port, postOffer())
211
+ assertThat(first.statusLine).isEqualTo("HTTP/1.1 201 Created")
212
+
213
+ negotiator.failed = true
214
+ val retry = request(endpoint.port, postOffer())
215
+
216
+ assertThat(retry.statusLine).isEqualTo("HTTP/1.1 201 Created")
217
+ assertThat(negotiator.terminated).contains("sess-1")
218
+ assertThat(negotiator.offers).hasSize(2)
219
+ }
220
+
221
+ // -----------------------------------------------------------------
222
+ // Failure handling
223
+ // -----------------------------------------------------------------
224
+
225
+ /**
226
+ * A failed negotiation must free the slot. Otherwise one bad gather wedges the endpoint at 409
227
+ * for the rest of the call and the only recovery is a full teardown.
228
+ */
229
+ @Test
230
+ fun `a failed negotiation returns 500 and frees the session slot`() {
231
+ val negotiator = StubNegotiator(failWith = "gather_timeout")
232
+ val endpoint = start(negotiator)
233
+
234
+ val failed = request(endpoint.port, postOffer())
235
+
236
+ assertThat(failed.statusLine).isEqualTo("HTTP/1.1 500 Internal Server Error")
237
+ assertThat(failed.body).isEqualTo("gather_timeout")
238
+ assertThat(negotiator.terminated).contains("sess-1")
239
+
240
+ val retry = request(endpoint.port, postOffer())
241
+ assertThat(retry.statusLine).isEqualTo("HTTP/1.1 500 Internal Server Error")
242
+ assertThat(negotiator.offers).describedAs("the slot was not freed").hasSize(2)
243
+ }
244
+
245
+ @Test
246
+ fun `a malformed request line gets 400 rather than closing the connection`() {
247
+ val endpoint = start(StubNegotiator())
248
+
249
+ val response = request(endpoint.port, "not-a-request\r\n\r\n")
250
+
251
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 400 Bad Request")
252
+ }
253
+
254
+ @Test
255
+ fun `a get on the base path gets 405 off the wire`() {
256
+ val endpoint = start(StubNegotiator())
257
+
258
+ val response = request(endpoint.port, "GET /whip HTTP/1.1\r\nHost: x\r\n\r\n")
259
+
260
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 405 Method Not Allowed")
261
+ assertThat(response.header("Allow")).isEqualTo("POST")
262
+ }
263
+
264
+ @Test
265
+ fun `an oversized offer is refused without its body being read`() {
266
+ val negotiator = StubNegotiator()
267
+ val endpoint = start(negotiator)
268
+ val declared = WhipIngestProtocol.MAX_BODY_BYTES + 1
269
+
270
+ val response = request(
271
+ endpoint.port,
272
+ "POST /whip HTTP/1.1\r\nHost: x\r\nContent-Type: application/sdp\r\n" +
273
+ "Content-Length: $declared\r\n\r\n",
274
+ )
275
+
276
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 413 Content Too Large")
277
+ assertThat(negotiator.offers).isEmpty()
278
+ }
279
+
280
+ /** Chunked bodies are not supported; the glasses' client sends a length. */
281
+ @Test
282
+ fun `an offer with no content length is refused as unsupported`() {
283
+ val negotiator = StubNegotiator()
284
+ val endpoint = start(negotiator)
285
+
286
+ val response = request(
287
+ endpoint.port,
288
+ "POST /whip HTTP/1.1\r\nHost: x\r\nContent-Type: application/sdp\r\n" +
289
+ "Transfer-Encoding: chunked\r\n\r\n",
290
+ )
291
+
292
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 400 Bad Request")
293
+ assertThat(negotiator.offers).isEmpty()
294
+ }
295
+
296
+ // -----------------------------------------------------------------
297
+ // Teardown
298
+ // -----------------------------------------------------------------
299
+
300
+ /**
301
+ * The tombstone. A connection refused is indistinguishable from a Wi-Fi glitch, so the glasses
302
+ * would retry; `410 Gone` tells them the endpoint is deliberately finished.
303
+ */
304
+ @Test
305
+ fun `an offer after stop gets 410 while the tombstone is up`() {
306
+ val negotiator = StubNegotiator()
307
+ val endpoint = start(negotiator)
308
+ request(endpoint.port, postOffer())
309
+
310
+ server!!.stop()
311
+
312
+ val response = request(endpoint.port, postOffer())
313
+ assertThat(response.statusLine).isEqualTo("HTTP/1.1 410 Gone")
314
+ assertThat(negotiator.offers).describedAs("no new negotiation after stop").hasSize(1)
315
+ }
316
+
317
+ @Test
318
+ fun `stop terminates the live publisher`() {
319
+ val negotiator = StubNegotiator()
320
+ val endpoint = start(negotiator)
321
+ request(endpoint.port, postOffer())
322
+
323
+ server!!.stop()
324
+
325
+ assertThat(negotiator.terminated).containsExactly("sess-1")
326
+ }
327
+
328
+ @Test
329
+ fun `the listener is gone once the tombstone expires`() {
330
+ val negotiator = StubNegotiator()
331
+ val endpoint = start(negotiator)
332
+ server!!.stop()
333
+
334
+ WhipIngestServer.awaitTombstone()
335
+
336
+ assertThat(server!!.boundEndpoint).isNull()
337
+ runCatching { request(endpoint.port, postOffer()) }
338
+ .onSuccess { throw AssertionError("listener still accepting after the tombstone: $it") }
339
+ }
340
+
341
+ @Test
342
+ fun `stop is idempotent and safe before any publisher connects`() {
343
+ val ingest = WhipIngestServer(StubNegotiator())
344
+ server = ingest
345
+ ingest.start(loopback)
346
+
347
+ ingest.stop()
348
+ ingest.stop()
349
+ ingest.closeNow()
350
+
351
+ assertThat(ingest.boundEndpoint).isNull()
352
+ }
353
+
354
+ // -----------------------------------------------------------------
355
+ // Wire helpers
356
+ // -----------------------------------------------------------------
357
+
358
+ private data class WireResponse(
359
+ val statusLine: String,
360
+ val headers: Map<String, String>,
361
+ val body: String,
362
+ ) {
363
+ fun header(name: String) = WhipIngestProtocol.headerValue(headers, name)
364
+ }
365
+
366
+ private fun postOffer(offer: String = OFFER_SDP): String =
367
+ "POST /whip HTTP/1.1\r\nHost: x\r\nContent-Type: application/sdp\r\n" +
368
+ "Content-Length: ${offer.toByteArray().size}\r\n\r\n$offer"
369
+
370
+ private fun request(port: Int, raw: String): WireResponse =
371
+ Socket().use { socket ->
372
+ socket.connect(InetSocketAddress(loopback, port), 3_000)
373
+ socket.soTimeout = 10_000
374
+ socket.getOutputStream().apply {
375
+ write(raw.toByteArray(Charsets.UTF_8))
376
+ flush()
377
+ }
378
+ readResponse(socket.getInputStream().bufferedReader(Charsets.UTF_8))
379
+ }
380
+
381
+ private fun readResponse(reader: BufferedReader): WireResponse {
382
+ val statusLine = reader.readLine() ?: error("no status line")
383
+ val headers = mutableMapOf<String, String>()
384
+ while (true) {
385
+ val line = reader.readLine() ?: break
386
+ if (line.isEmpty()) break
387
+ val separator = line.indexOf(':')
388
+ if (separator > 0) {
389
+ headers[line.substring(0, separator).trim()] = line.substring(separator + 1).trim()
390
+ }
391
+ }
392
+ val length = WhipIngestProtocol.headerValue(headers, "Content-Length")?.toIntOrNull() ?: 0
393
+ val body = CharArray(length)
394
+ var read = 0
395
+ while (read < length) {
396
+ val count = reader.read(body, read, length - read)
397
+ if (count < 0) break
398
+ read += count
399
+ }
400
+ return WireResponse(statusLine, headers, String(body, 0, read))
401
+ }
402
+
403
+ private companion object {
404
+ const val OFFER_SDP =
405
+ "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n" +
406
+ "a=candidate:1 1 udp 2122260223 192.168.43.1 51234 typ host generation 0\r\n"
407
+ const val ANSWER_SDP =
408
+ "v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n" +
409
+ "a=candidate:1 1 udp 2122260223 192.168.43.20 51235 typ host generation 0\r\n"
410
+ }
411
+ }
@@ -0,0 +1,83 @@
1
+ package com.mentra.acsmeeting.telemetry
2
+
3
+ import org.assertj.core.api.Assertions.assertThat
4
+ import org.junit.Test
5
+
6
+ class AvSyncProbeTest {
7
+ @Test
8
+ fun clapInFrameReportsAudioLead() {
9
+ val lines = mutableListOf<String>()
10
+ val probe = AvSyncProbe(log = { lines.add(it) })
11
+
12
+ probe.onAudioLevel(200, 1_000_000_000L)
13
+ probe.onVideoLuma(80, 1_000_000_000L)
14
+ probe.onAudioLevel(4_200, 1_010_000_000L)
15
+ probe.onVideoLuma(96, 1_190_000_000L)
16
+
17
+ assertThat(probe.lastAudioLeadMs()).isEqualTo(180)
18
+ assertThat(probe.clapCount()).isEqualTo(1)
19
+ assertThat(lines.single()).isEqualTo("AVSYNC clap audioLeadMs=180 meanAbs=4200 lumaY=96 clapN=1")
20
+ }
21
+
22
+ @Test
23
+ fun videoFirstReportsNegativeLead() {
24
+ val probe = AvSyncProbe(log = {})
25
+ probe.onAudioLevel(100, 0L)
26
+ probe.onVideoLuma(70, 0L)
27
+ probe.onVideoLuma(90, 20_000_000L)
28
+ probe.onAudioLevel(5_000, 100_000_000L)
29
+
30
+ assertThat(probe.lastAudioLeadMs()).isEqualTo(-80)
31
+ }
32
+
33
+ @Test
34
+ fun lumaJumpAloneIsNotAClap() {
35
+ val lines = mutableListOf<String>()
36
+ val probe = AvSyncProbe(log = { lines.add(it) })
37
+ probe.onVideoLuma(80, 0L)
38
+ probe.onVideoLuma(120, 16_000_000L)
39
+
40
+ assertThat(probe.lastAudioLeadMs()).isNull()
41
+ assertThat(lines).isEmpty()
42
+ }
43
+
44
+ @Test
45
+ fun audioSpikeAloneExpiresWithoutAMatch() {
46
+ val lines = mutableListOf<String>()
47
+ val probe = AvSyncProbe(log = { lines.add(it) })
48
+ probe.onAudioLevel(100, 0L)
49
+ probe.onAudioLevel(5_000, 20_000_000L)
50
+ probe.onVideoLuma(80, 20_000_000L + AvSyncProbe.MATCH_WINDOW_NS + 1)
51
+
52
+ assertThat(probe.lastAudioLeadMs()).isNull()
53
+ assertThat(lines).isEmpty()
54
+ }
55
+
56
+ @Test
57
+ fun cooldownIgnoresTheRingOfOneClap() {
58
+ val probe = AvSyncProbe(log = {})
59
+ probe.onAudioLevel(100, 0L)
60
+ probe.onVideoLuma(80, 0L)
61
+ probe.onAudioLevel(4_000, 20_000_000L)
62
+ probe.onVideoLuma(100, 40_000_000L)
63
+ probe.onAudioLevel(200, 50_000_000L)
64
+ probe.onAudioLevel(6_000, 60_000_000L)
65
+ probe.onVideoLuma(80, 80_000_000L)
66
+
67
+ assertThat(probe.clapCount()).isEqualTo(1)
68
+ assertThat(probe.lastAudioLeadMs()).isEqualTo(20)
69
+ }
70
+
71
+ @Test
72
+ fun tickPrintsNaUntilAClap() {
73
+ val probe = AvSyncProbe(log = {})
74
+ assertThat(probe.tick(videoE2eP50 = "240.0", videoAgeP50 = "180.0"))
75
+ .isEqualTo("AVSYNC live videoE2eP50=240.0 videoAgeP50=180.0 lastClapLeadMs=na clapN=0 meanAbs=-1 lumaY=-1")
76
+ }
77
+
78
+ @Test
79
+ fun meanAbs16MatchesAKnownBuffer() {
80
+ val pcm = byteArrayOf(0x00, 0x10, 0x00, 0x00)
81
+ assertThat(AvSyncProbe.meanAbs16(pcm)).isEqualTo(2048)
82
+ }
83
+ }
@@ -7,15 +7,16 @@ class PipelineStatsTest {
7
7
  @Test
8
8
  fun conservationHoldsAcrossEveryDisposition() {
9
9
  val stats = PipelineStats()
10
- repeat(4) { stats.onSink() }
10
+ repeat(5) { stats.onSink() }
11
11
  stats.onQueued(); stats.onSub()
12
12
  stats.onDropSize()
13
13
  stats.onDropBusy()
14
14
  stats.onDropNullI420()
15
+ stats.onDropPaced()
15
16
  assertThat(stats.conserved()).isTrue()
16
- assertThat(stats.sinkCount()).isEqualTo(4)
17
+ assertThat(stats.sinkCount()).isEqualTo(5)
17
18
  assertThat(stats.subCount()).isEqualTo(1)
18
- assertThat(stats.dropCount()).isEqualTo(3)
19
+ assertThat(stats.dropCount()).isEqualTo(4)
19
20
  assertThat(stats.inFlightCount()).isEqualTo(0)
20
21
  }
21
22
 
@@ -65,6 +66,7 @@ class PipelineStatsTest {
65
66
  assertThat(line).contains("dec=na")
66
67
  assertThat(line).contains("wire=0x0@na kbps=na codec=na")
67
68
  assertThat(line).contains("drop{")
69
+ assertThat(line).contains("pace=0")
68
70
  assertThat(line).contains("ms{")
69
71
  assertThat(line).contains("chroma{")
70
72
  assertThat(line).contains("cum{sink=0 sub=0 drop=0 inFlight=0}")
@@ -251,6 +253,70 @@ class PipelineStatsTest {
251
253
  assertThat(line).contains("dec=8.1")
252
254
  }
253
255
 
256
+ @Test
257
+ fun ladderReportsFrameAgeAndEndToEndLatency() {
258
+ var clock = 0L
259
+ val stats = PipelineStats { clock }
260
+ stats.age.record(40_000_000)
261
+ stats.e2e.record(95_000_000)
262
+ clock += 1000
263
+ val line = stats.tick()
264
+ assertThat(line).contains("ageP50=40.0")
265
+ assertThat(line).contains("e2eP50=95.0")
266
+ }
267
+
268
+ /**
269
+ * A backed-up ACS send thread has no other symptom on this ladder: `sendP95` times the ACS call
270
+ * itself, so the wait in front of it has to be its own measurement.
271
+ */
272
+ @Test
273
+ fun ladderSeparatesSenderQueueWaitFromTheAcsCall() {
274
+ var clock = 0L
275
+ val stats = PipelineStats { clock }
276
+ stats.queue.record(30_000_000)
277
+ stats.send.record(2_000_000)
278
+ clock += 1000
279
+ val line = stats.tick()
280
+ assertThat(line).contains("queueP95=30.0")
281
+ assertThat(line).contains("sendP95=2.0")
282
+ }
283
+
284
+ @Test
285
+ fun slowestStageNamesTheWorstPhoneSideStage() {
286
+ val stats = PipelineStats { 0 }
287
+ stats.scale.record(2_000_000)
288
+ stats.toI420.record(9_000_000)
289
+ stats.copy.record(1_000_000)
290
+ stats.send.record(12_000_000)
291
+ assertThat(stats.slowestStage()).isEqualTo("send=12.0")
292
+ }
293
+
294
+ /** Ranking must compare numbers: as strings "9.0" sorts above "12.0". */
295
+ @Test
296
+ fun slowestStageRanksNumericallyNotLexically() {
297
+ val stats = PipelineStats { 0 }
298
+ stats.toI420.record(9_000_000)
299
+ stats.copy.record(12_000_000)
300
+ assertThat(stats.slowestStage()).isEqualTo("copy=12.0")
301
+ }
302
+
303
+ /**
304
+ * Frame age is dominated by capture, encode and the network, so blaming a phone-side stage for it
305
+ * would make every healthy call accuse a stage this device does not run.
306
+ */
307
+ @Test
308
+ fun slowestStageIgnoresUpstreamFrameAge() {
309
+ val stats = PipelineStats { 0 }
310
+ stats.age.record(400_000_000)
311
+ stats.copy.record(3_000_000)
312
+ assertThat(stats.slowestStage()).isEqualTo("copy=3.0")
313
+ }
314
+
315
+ @Test
316
+ fun slowestStageIsNotAvailableBeforeAnySamples() {
317
+ assertThat(PipelineStats { 0 }.slowestStage()).isEqualTo("na")
318
+ }
319
+
254
320
  private fun extractCum(line: String): String =
255
321
  Regex("""cum\{[^}]+\}""").find(line)?.value ?: line
256
322
  }