@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.
- package/README.md +4 -2
- package/android/build.gradle +23 -0
- package/android/src/androidTest/java/com/mentra/acsmeeting/network/SoftApFeasibilityGateTest.kt +291 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/com/mentra/acsmeeting/AbandonedCallAgent.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +392 -22
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +909 -56
- package/android/src/main/java/com/mentra/acsmeeting/CallCapabilities.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +21 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioUplinkChain.kt +126 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +27 -4
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +45 -10
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +110 -5
- package/android/src/main/java/com/mentra/acsmeeting/network/InternetHold.kt +501 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/Ipv4Prefix.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/NetworkFacts.kt +81 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetector.kt +355 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkError.kt +109 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkObserver.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkReadiness.kt +103 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpec.kt +53 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedNetworkState.kt +127 -0
- package/android/src/main/java/com/mentra/acsmeeting/network/ScopedSoftApNetwork.kt +573 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +10 -101
- package/android/src/main/java/com/mentra/acsmeeting/source/DecodedTrackRelay.kt +147 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +47 -5
- package/android/src/main/java/com/mentra/acsmeeting/source/LocalWhipIngestSource.kt +772 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpec.kt +73 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SdpAdapter.kt +17 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SelectedIcePair.kt +139 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIceDiagnosis.kt +144 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApIcePolicy.kt +46 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SoftApSdpGuard.kt +160 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +3 -1
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +69 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestProtocol.kt +207 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/WhipIngestServer.kt +356 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/AvSyncProbe.kt +155 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +94 -3
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +33 -5
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoQuality.kt +178 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/VideoRateVerdict.kt +145 -0
- package/android/src/main/java/com/mentra/acsmeeting/trace/SoftApTrace.kt +143 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +23 -4
- package/android/src/main/java/com/mentra/acsmeeting/video/FramePacer.kt +70 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +1 -1
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +36 -3
- package/android/src/test/java/com/mentra/acsmeeting/AbandonedCallAgentTest.kt +74 -0
- package/android/src/test/java/com/mentra/acsmeeting/CallCapabilitiesTest.kt +47 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioUplinkChainTest.kt +250 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +44 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +34 -4
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldLifecycleTest.kt +102 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/InternetHoldTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/Ipv4PrefixTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/NetworkFactsTest.kt +59 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedLocalNetworkPermissionTest.kt +35 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkChangeDetectorTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkErrorTest.kt +96 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkLifecycleTest.kt +187 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkObserverTest.kt +172 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkReadinessTest.kt +141 -0
- package/android/src/test/java/com/mentra/acsmeeting/network/ScopedNetworkRequestSpecTest.kt +69 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/DecodedTrackRelayTest.kt +41 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +26 -8
- package/android/src/test/java/com/mentra/acsmeeting/source/MeetingVideoSourceSpecTest.kt +109 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SelectedIcePairTest.kt +165 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIceDiagnosisTest.kt +148 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApIcePolicyTest.kt +76 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SoftApSdpGuardTest.kt +275 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +9 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestProtocolTest.kt +356 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/WhipIngestServerLoopbackTest.kt +411 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/AvSyncProbeTest.kt +83 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +69 -3
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoQualityTest.kt +182 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/VideoRateVerdictTest.kt +149 -0
- package/android/src/test/java/com/mentra/acsmeeting/trace/SoftApTraceTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +34 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FramePacerTest.kt +105 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +5 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +38 -3
- package/build/AcsMeeting.types.d.ts +5 -0
- package/build/AcsMeeting.types.d.ts.map +1 -1
- package/build/AcsMeeting.types.js.map +1 -1
- package/build/AcsMeetingModule.d.ts +23 -0
- package/build/AcsMeetingModule.d.ts.map +1 -1
- package/build/AcsMeetingModule.js.map +1 -1
- package/build/AcsMeetingModule.web.d.ts +15 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -1
- package/build/AcsMeetingModule.web.js +13 -0
- package/build/AcsMeetingModule.web.js.map +1 -1
- package/ios/AcsMeetingModule.swift +213 -2
- package/package.json +1 -1
- package/src/AcsMeeting.types.ts +5 -0
- package/src/AcsMeetingModule.ts +13 -0
- 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(
|
|
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(
|
|
17
|
+
assertThat(stats.sinkCount()).isEqualTo(5)
|
|
17
18
|
assertThat(stats.subCount()).isEqualTo(1)
|
|
18
|
-
assertThat(stats.dropCount()).isEqualTo(
|
|
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
|
}
|