-
Notifications
You must be signed in to change notification settings - Fork 349
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Monitor visitors' codec lists, and aggregate them into a conference property. #1137
Changes from 5 commits
33f724a
e257dfb
e926015
e71e23e
22d8240
208e1e7
c764b53
3629ab4
b7b53f3
d85aa96
c48563d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -133,6 +133,11 @@ public class JitsiMeetConferenceImpl | |
return null; | ||
}); | ||
|
||
/** | ||
* The aggregated count of visitors' supported codecs | ||
*/ | ||
private final PreferenceAggregator visitorCodecs; | ||
|
||
/** | ||
* The {@link JibriRecorder} instance used to provide live streaming through | ||
* Jibri. | ||
|
@@ -290,6 +295,16 @@ public JitsiMeetConferenceImpl( | |
TimeUnit.MILLISECONDS); | ||
|
||
|
||
visitorCodecs = new PreferenceAggregator( | ||
logger, | ||
(codecs) -> { | ||
setConferenceProperty( | ||
ConferenceProperties.KEY_VISITOR_CODECS, | ||
String.join(",", codecs) | ||
); | ||
return null; | ||
}); | ||
|
||
logger.info("Created new conference."); | ||
} | ||
|
||
|
@@ -816,7 +831,7 @@ private void inviteChatMember(ChatRoomMember chatRoomMember, boolean justJoined) | |
} | ||
else if (participant.getChatMember().getRole() == MemberRole.VISITOR) | ||
{ | ||
visitorAdded(); | ||
visitorAdded(participant.getChatMember().getVideoCodecs()); | ||
} | ||
} | ||
|
||
|
@@ -1042,7 +1057,7 @@ private void terminateParticipant( | |
} | ||
else if (removed.getChatMember().getRole() == MemberRole.VISITOR) | ||
{ | ||
visitorRemoved(); | ||
visitorRemoved(removed.getChatMember().getVideoCodecs()); | ||
} | ||
} | ||
} | ||
|
@@ -2013,15 +2028,21 @@ private void rescheduleSingleParticipantTimeout() | |
} | ||
|
||
/** Called when a new visitor has been added to the conference. */ | ||
private void visitorAdded() | ||
private void visitorAdded(List<String> codecs) | ||
{ | ||
visitorCount.adjustValue(+1); | ||
if (codecs != null) { | ||
visitorCodecs.addPreference(codecs); | ||
} | ||
} | ||
|
||
/** Called when a new visitor has been added to the conference. */ | ||
private void visitorRemoved() | ||
private void visitorRemoved(List<String> codecs) | ||
{ | ||
visitorCount.adjustValue(-1); | ||
if (codecs != null) { | ||
visitorCodecs.removePreference(codecs); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We allow codecs to change from null to a non-null, so if a visitor joins with no codecs and then adds some it would mess with the count. It's a bit of a stretch, but one could trick jicofo into upgrading to av1 thus breaking video for some participants. Unless I misunderstand how the aggregator works |
||
} | ||
} | ||
|
||
/** | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
/* | ||
* Jicofo, the Jitsi Conference Focus. | ||
* | ||
* Copyright @ 2015-Present 8x8, Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
*/ | ||
package org.jitsi.jicofo.util | ||
|
||
import org.jitsi.utils.logging2.Logger | ||
import org.jitsi.utils.logging2.createChildLogger | ||
|
||
/** Aggregate lists of preferences coming from a large group of people, such that the resulting aggregated | ||
* list consists of preference items supported by everyone, and in a rough consensus of preference order. | ||
* | ||
* The intended use case is maintaining the list of supported codecs for conference visitors. | ||
* | ||
* Preference orders are aggregated using the Borda count; this isn't theoretically optimal, but it should be | ||
* good enough, and it's computationally cheap. | ||
*/ | ||
class PreferenceAggregator( | ||
parentLogger: Logger, | ||
private val onChanged: (List<String>) -> Unit | ||
) { | ||
private val logger = createChildLogger(parentLogger) | ||
private val lock = Any() | ||
|
||
var aggregate: List<String> = emptyList() | ||
private set | ||
|
||
var count = 0 | ||
private set | ||
|
||
private val values = mutableMapOf<String, ValueInfo>() | ||
|
||
/** | ||
* Add a preference to the aggregator. | ||
*/ | ||
fun addPreference(prefs: List<String>) { | ||
val distinctPrefs = prefs.distinct() | ||
if (distinctPrefs != prefs) { | ||
logger.warn("Preferences $prefs contains repeated values") | ||
} | ||
synchronized(lock) { | ||
count++ | ||
distinctPrefs.forEachIndexed { index, element -> | ||
val info = values.computeIfAbsent(element) { ValueInfo() } | ||
info.count++ | ||
info.rankAggregate += index | ||
} | ||
updateAggregate() | ||
} | ||
} | ||
|
||
/** | ||
* Remove a preference from the aggregator. | ||
*/ | ||
fun removePreference(prefs: List<String>) { | ||
val distinctPrefs = prefs.distinct() | ||
if (distinctPrefs != prefs) { | ||
logger.warn("Preferences $prefs contains repeated values") | ||
} | ||
synchronized(lock) { | ||
count-- | ||
check(count >= 0) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This could also be force to fail by sending initial presence with no codecs, then updating with some codecs, then leaving repeatedly |
||
"Preference count $count should not be negative" | ||
} | ||
distinctPrefs.forEachIndexed { index, element -> | ||
val info = values[element] | ||
check(info != null) { | ||
"Preference info for $element should exist when preferences are being removed" | ||
} | ||
info.count-- | ||
check(info.count >= 0) { | ||
"Preference count for $element ${info.count} should not be negative" | ||
} | ||
info.rankAggregate -= index | ||
check(info.rankAggregate >= 0) { | ||
"Preference rank aggregate for $element ${info.rankAggregate} should not be negative" | ||
} | ||
if (info.count == 0) { | ||
check(info.rankAggregate == 0) { | ||
"Preference rank aggregate for $element ${info.rankAggregate} should be zero " + | ||
"when preference count is 0" | ||
} | ||
values.remove(element) | ||
} | ||
} | ||
updateAggregate() | ||
} | ||
} | ||
|
||
fun reset() { | ||
synchronized(lock) { | ||
aggregate = emptyList() | ||
count = 0 | ||
values.clear() | ||
} | ||
} | ||
|
||
private fun updateAggregate() { | ||
val newAggregate = values.asSequence() | ||
.filter { it.value.count == count } | ||
.sortedBy { it.value.rankAggregate } | ||
.map { it.key } | ||
.toList() | ||
if (aggregate != newAggregate) { | ||
aggregate = newAggregate | ||
/* ?? Do we need to drop the lock before calling this? */ | ||
onChanged(aggregate) | ||
} | ||
} | ||
|
||
private class ValueInfo { | ||
var count = 0 | ||
var rankAggregate = 0 | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
package org.jitsi.jicofo.util | ||
|
||
import io.kotest.core.spec.IsolationMode | ||
import io.kotest.core.spec.style.ShouldSpec | ||
import io.kotest.matchers.collections.shouldContainExactly | ||
import io.kotest.matchers.collections.shouldContainExactlyInAnyOrder | ||
import io.kotest.matchers.shouldBe | ||
import org.jitsi.utils.logging2.createLogger | ||
|
||
class PreferenceAggregatorTest : ShouldSpec() { | ||
override fun isolationMode() = IsolationMode.InstancePerLeaf | ||
|
||
private val logger = createLogger() | ||
|
||
private val calledWith = mutableListOf<List<String>>() | ||
private val aggregator = PreferenceAggregator(logger) { | ||
calledWith.add(it) | ||
} | ||
|
||
init { | ||
context("An aggregator with no values added") { | ||
should("Not call its callback") { | ||
calledWith shouldBe emptyList() | ||
} | ||
} | ||
context("An aggregator called once") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
should("Call the callback exactly once with that set of values") { | ||
calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) | ||
} | ||
} | ||
context("An aggregator called twice with the same values") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
should("Call the callback exactly once") { | ||
calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) | ||
} | ||
} | ||
context("An aggregator with all preferences removed") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.removePreference(listOf("vp9", "vp8", "h264")) | ||
should("Have its final output be the empty set") { | ||
calledWith.last() shouldBe emptyList() | ||
} | ||
} | ||
context("Aggregating preferences with disparate values (subset)") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("vp8", "h264")) | ||
should("Output the minimal agreed set") { | ||
calledWith.last().shouldContainExactly("vp8", "h264") | ||
} | ||
} | ||
context("Aggregating preferences with disparate values (non-subset)") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("vp8", "h264")) | ||
aggregator.addPreference(listOf("vp9", "vp8")) | ||
should("Output the minimal agreed set") { | ||
calledWith.last().shouldContainExactly("vp8") | ||
} | ||
} | ||
context("Aggregating a new superset") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("av1", "vp9", "vp8", "h264")) | ||
should("Not call the callback a second time") { | ||
calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) | ||
} | ||
} | ||
context("Removing the only preference that does not support a value") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("vp8", "h264")) | ||
aggregator.addPreference(listOf("vp9", "vp8")) | ||
|
||
aggregator.removePreference(listOf("vp8", "h264")) | ||
should("Return that value to the set of preferences") { | ||
calledWith.last().shouldContainExactly(listOf("vp9", "vp8")) | ||
} | ||
} | ||
context("Preferences that express different orders") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("vp9", "h264", "vp8")) | ||
|
||
should("Reflect the majority preference") { | ||
calledWith shouldContainExactly listOf(listOf("vp9", "vp8", "h264")) | ||
} | ||
} | ||
context("Ties in preference order") { | ||
aggregator.addPreference(listOf("vp9", "vp8", "h264")) | ||
aggregator.addPreference(listOf("vp9", "h264", "vp8")) | ||
|
||
should("Result in the correct set, in some order, with consensus where it exists") { | ||
calledWith.last().shouldContainExactlyInAnyOrder("h264", "vp9", "vp8") | ||
calledWith.last().first() shouldBe "vp9" | ||
} | ||
} | ||
context("Repeated values in preferences") { | ||
aggregator.addPreference(listOf("vp9", "vp8")) | ||
aggregator.addPreference(listOf("vp9", "vp8", "vp9")) | ||
should("not confuse things") { | ||
calledWith shouldContainExactly listOf(listOf("vp9", "vp8")) | ||
} | ||
aggregator.removePreference(listOf("vp9", "vp8", "vp9")) | ||
aggregator.removePreference(listOf("vp9", "vp8")) | ||
should("not confuse things on removal") { | ||
calledWith.last().shouldContainExactly(emptyList()) | ||
} | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In JitsiParticipantCodecList.kt you use lowercase(). Is it also required here?