You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

599 lines
19 KiB

/**
*
* @copyright Copyright (c) 2021, Daniel Calviño Sánchez (danxuliu@gmail.com)
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* HOW TO SETUP:
* -----------------------------------------------------------------------------
* - Set the right values in "user" and "password" (a user must be used; guests
* do not work).
* - If HPB clustering is enabled, set the token of a conversation in "token"
* (otherwise leave empty).
* - Set whether to use audio, video or both in "mediaConstraints".
* - Set the desired numbers in "publishersCount" and
* "subscribersPerPublisherCount" (in a regular call with N participants you
* would have N publishers and N-1 subscribers).
*
* HOW TO RUN:
* -----------------------------------------------------------------------------
* - In the browser, log in the Nextcloud server (with the same user as in this
* script).
* - Copy and paste the full script in the console of the browser to run it.
* - To run it again execute "closeConnections()" in the console; then you must
* reload the page and copy and paste the script again.
*
* HOW TO ENABLE AND DISABLE THE MEDIA DURING A TEST:
* -----------------------------------------------------------------------------
* You can manually enable and disable the media during a test by copying and
* pasting in the browser console the following commands:
* - For audio:
* stream.getAudioTracks()[0].enabled = TRUE_OR_FALSE
* - For video:
* stream.getVideoTracks()[0].enabled = TRUE_OR_FALSE
*
* Note that you can only enable and disable the original media specified in the
* "getUserMedia" call.
*
* HOW TO CALIBRATE:
* -----------------------------------------------------------------------------
* The script starts as many publishers and subscribers for each publisher as
* specified, each one being a real and independent peer connection to Janus.
* Therefore, how many peer connections can be established with a single script
* run depends on the client machine, both its CPU and its network.
*
* You should first "calibrate" the client by running it with different numbers
* of publishers and subscribers to check how many peer connections are
* supported before doing the actual stress test on Janus.
*
* It is most likely that the CPU or network of a single client will be
* saturated before Janus is. The script will write to the console when a peer
* could not be connected or if it was disconnected after being connected; if
* there is a large number of those messages or the CPU consumption is very high
* the client has probably reached its limit.
*
* Besides the messages written by the script itself you can manually check the
* connection state by copying and pasting in the browser console the following
* commands:
* - For the publishers:
* Object.values(publishers).forEach(publisher => { console.log(publisher.peerConnection.iceConnectionState) })
* - For the subscribers:
* subscribers.forEach(subscriber => { console.log(subscriber.peerConnection.iceConnectionState) })
*
* DISCLAIMER:
* -----------------------------------------------------------------------------
* Talk performs some optimizations during calls, like reducing the video
* quality based on the number of participants. This script does not take into
* account those things; it is meant to be used for stress tests of Janus rather
* than to accurately simulate Talk behaviour.
*
* Independently of that, please note that an accurate simulation would not be
* possible given that a single client has to behave as several different
* clients. In a real call each client could have a different behaviour (not
* only due to sending different media streams, but also from having different
* CPU and network conditions), and that might even also affect Janus and the
* server regarding very low level things (but relevant for performance on
* highly loaded systems) like caches.
*
* Nevertheless, if those things are kept in mind the script can be anyway used
* as a rough performance test of specific call scenarios. Just remember that in
* regular calls peer connections increase quadratically with the number of
* participants; specifically, publishers increase linearly while subscribers
* increase quadratically.
*
* For example a call between 10 participants has 10 publishers and 90
* subscribers (9 for each publisher) for a total of 100 peer connections, while
* a call between 30 participants has 30 publishers and 870 subscribers (29 for
* each publisher) for a total of 900 peer connections.
* Due to this, if you have several clients that can only withstand ~100 peer
* connections each in order to simulate a 30 participants call you could run
* the script with 3 publishers and 29 subscribers per publisher on 10 clients
* at the same time.
*/
// Guest users do not currently work, as they must join the conversation to not
// be kicked out from the signaling server. However, joining the conversation
// a second time causes the first guest to be unregistered.
// Regular users do not need to join the conversation, so the same user can be
// connected several times to the HPB.
const user = ''
const password = ''
const signalingApiVersion = 2 // FIXME get from capabilities endpoint
const conversationApiVersion = 3 // FIXME get from capabilities endpoint
// The conversation token is only strictly needed for guests or if HPB
// clustering is enabled.
const token = ''
// Number of streams to send
const publishersCount = 5
// Number of streams to receive
const subscribersPerPublisherCount = 40
const mediaConstraints = {
audio: true,
video: false,
}
const connectionWarningTimeout = 5000
/*
* End of configuration section
*/
// To run the script the current page in the browser must be a page of the
// target Nextcloud instance, as cross-doman requests are not allowed, so the
// host is directly got from the current location.
const talkOcsApiUrl = 'https://' + window.location.host + '/ocs/v2.php/apps/spreed/api/'
const signalingSettingsUrl = talkOcsApiUrl + 'v' + signalingApiVersion + '/signaling/settings'
const signalingBackendUrl = talkOcsApiUrl + 'v' + signalingApiVersion + '/signaling/backend'
const joinRoomUrl = talkOcsApiUrl + 'v' + conversationApiVersion + '/room/' + token + '/participants/active'
const publishers = []
const subscribers = []
async function getSignalingSettings(user, password, token) {
const fetchOptions = {
headers: {
'OCS-ApiRequest': true,
'Accept': 'json',
},
}
if (user) {
fetchOptions.headers['Authorization'] = 'Basic ' + btoa(user + ':' + password)
}
const signalingSettingsResponse = await fetch(signalingSettingsUrl + '?token=' + token, fetchOptions)
const signalingSettings = await signalingSettingsResponse.json()
return signalingSettings.ocs.data
}
/**
* Helper class to interact with the signaling server.
*
* A new signaling session is started when a Signaling object is created.
* Messages can be sent using the sendXXX methods. Received messages are emitted
* using events, so a listener for the type of received message can be set to
* listen to them; the message data is provided in the "detail" attribute of the
* event.
*/
class Signaling extends EventTarget {
constructor(user, signalingSettings) {
super();
this.user = user
this.signalingTicket = signalingSettings.ticket
let resolveSessionId
this.sessionId = new Promise((resolve, reject) => {
resolveSessionId = resolve
})
const signalingUrl = this.sanitizeSignalingUrl(signalingSettings.server)
this.socket = new WebSocket(signalingUrl)
this.socket.onopen = () => {
this.sendHello()
}
this.socket.onmessage = event => {
const data = JSON.parse(event.data)
this.dispatchEvent(new CustomEvent(data.type, { detail: data[data.type] }))
}
this.socket.onclose = () => {
console.warn('Socket closed')
}
this.addEventListener('hello', async event => {
const hello = event.detail
const sessionId = hello.sessionid
this.sessionId = sessionId
resolveSessionId(sessionId)
if (!user) {
// If the current user is a guest the room needs to be joined,
// as guests are kicked out if they just open a session in the
// signaling server.
this.joinRoom()
}
})
this.addEventListener('error', event => {
const error = event.detail
console.warn(error)
})
}
sanitizeSignalingUrl(url) {
if (url.indexOf('https://') === 0) {
url = 'wss://' + url.substr(8)
} else if (url.indexOf('http://') === 0) {
url = 'ws://' + url.substr(7)
}
if (url[url.length - 1] === '/') {
url = url.substr(0, url.length - 1)
}
return url + '/spreed'
}
/**
* Returns the session ID.
*
* It can return either the actual session ID or a promise that is fulfilled
* once the session ID is available. Therefore this should be called with
* something like "sessionId = await signaling.getSessionId()".
*/
async getSessionId() {
return this.sessionId
}
send(message) {
this.socket.send(JSON.stringify(message))
}
sendHello() {
this.send({
'type': 'hello',
'hello': {
'version': '1.0',
'auth': {
'url': signalingBackendUrl,
'params': {
'userid': this.user,
'ticket': this.signalingTicket,
},
},
},
})
}
sendMessage(data) {
this.send({
'type': 'message',
'message': {
'recipient': {
'type': 'session',
'sessionid': data.to,
},
'data': data
}
})
}
sendRequestOffer(publisherSessionId) {
this.send({
'type': 'message',
'message': {
'recipient': {
'type': 'session',
'sessionid': publisherSessionId,
},
'data': {
'type': 'requestoffer',
'roomType': 'video',
},
},
})
}
async joinRoom() {
const fetchOptions = {
headers: {
'OCS-ApiRequest': true,
'Accept': 'json',
},
method: 'POST',
}
const joinRoomResponse = await fetch(joinRoomUrl, fetchOptions)
const joinRoomResult = await joinRoomResponse.json()
const nextcloudSessionId = joinRoomResult.ocs.data.sessionId
this.send({
'type': 'room',
'room': {
'roomid': token,
'sessionid': nextcloudSessionId,
},
})
}
}
/**
* Base class for publishers and subscribers.
*
* After a Peer is created it must be explicitly connected to the HPB by calling
* "connect()". This method returns a promise that is fulfilled once the peer
* has connected, or rejected if the peer has not connected yet after some time.
* "connect()" must be called once the signaling is already connected; this can
* be done by waiting for "signaling.getSessionId()".
*
* Subclasses must set the "sessionId" attribute.
*/
class Peer {
constructor(user, signalingSettings, signaling) {
this.signaling = signaling
let iceServers = signalingSettings.stunservers
iceServers = iceServers.concat(signalingSettings.turnservers)
this.peerConnection = new RTCPeerConnection({ iceServers: iceServers })
this.peerConnection.onicecandidate = async event => {
const candidate = event.candidate
if (candidate) {
this.send('candidate', candidate)
}
}
this.signaling.addEventListener('message', event => {
const message = event.detail
if (message.data.type === 'candidate' && message.data.from === this.sessionId) {
const candidate = message.data.payload
this.peerConnection.addIceCandidate(candidate.candidate)
}
})
this.connectedPromiseResolve = undefined
this.connectedPromiseReject = undefined
this.connectedPromise = new Promise((resolve, reject) => {
this.connectedPromiseResolve = resolve
this.connectedPromiseReject = reject
})
}
async connect() {
this.peerConnection.addEventListener('iceconnectionstatechange', () => {
if (this.peerConnection.iceConnectionState === 'connected' || this.peerConnection.iceConnectionState === 'completed') {
this.connectedPromiseResolve()
this.connected = true
}
})
const connectionTimeout = 5
setTimeout(() => {
if (!this.connected) {
this.connectedPromiseReject('Peer has not connected in ' + connectionTimeout + ' seconds')
}
}, connectionTimeout * 1000)
return this.connectedPromise
}
send(type, data) {
this.signaling.sendMessage({
to: this.sessionId,
roomType: 'video',
type: type,
payload: data
})
}
}
/**
* Helper class for publishers.
*
* A single publisher can be used on each signaling session.
*
* A publisher is connected to the HPB by sending an offer and handling the
* returned answer.
*/
class Publisher extends Peer {
constructor(user, signalingSettings, signaling, stream) {
super(user, signalingSettings, signaling)
stream.getTracks().forEach(track => {
this.peerConnection.addTrack(track, stream)
})
this.signaling.addEventListener('message', event => {
const message = event.detail
if (message.data.type === 'answer') {
const answer = message.data.payload
this.peerConnection.setRemoteDescription(answer)
}
})
}
async connect() {
this.sessionId = await this.signaling.getSessionId()
const offer = await this.peerConnection.createOffer({ offerToReceiveAudio: 0, offerToReceiveVideo: 0 })
await this.peerConnection.setLocalDescription(offer)
this.send('offer', offer)
return super.connect()
}
}
async function newPublisher(signalingSettings, signaling, stream) {
const publisher = new Publisher(user, signalingSettings, signaling, stream)
const sessionId = await publisher.signaling.getSessionId()
return [sessionId, publisher]
}
/**
* Helper class for subscribers.
*
* Several subscribers can be used on a single signaling session provided that
* each subscriber subscribes to a different publisher.
*
* A subscriber is connected to the HPB by requesting an offer, handling the
* returned offer and sending back an answer.
*/
class Subscriber extends Peer {
constructor(user, signalingSettings, signaling, publisherSessionId) {
super(user, signalingSettings, signaling)
this.sessionId = publisherSessionId
this.signaling.addEventListener('message', async event => {
const message = event.detail
if (message.data.type === 'offer' && message.data.from === this.sessionId) {
const offer = message.data.payload
await this.peerConnection.setRemoteDescription(offer)
const answer = await this.peerConnection.createAnswer()
await this.peerConnection.setLocalDescription(answer)
this.send('answer', answer)
}
})
}
async connect() {
this.signaling.sendRequestOffer(this.sessionId)
return super.connect()
}
}
function listenToPublisherConnectionChanges() {
Object.values(publishers).forEach(publisher => {
publisher.peerConnection.addEventListener('iceconnectionstatechange', event => {
if (publisher.peerConnection.iceConnectionState === 'connected'
|| publisher.peerConnection.iceConnectionState === 'completed') {
clearTimeout(publisher.connectionWarning)
publisher.connectionWarning = null
return
}
if (publisher.peerConnection.iceConnectionState === 'disconnected') {
// Brief disconnections are normal and expected; they are only
// relevant if the connection has not been restored after some
// seconds.
publisher.connectionWarning = setTimeout(() => {
console.warn('Publisher disconnected', publisher.sessionId)
}, connectionWarningTimeout)
} else if (publisher.peerConnection.iceConnectionState === 'failed') {
console.warn('Publisher connection failed', publisher.sessionId)
}
})
})
}
async function initPublishers() {
for (let i = 0; i < publishersCount; i++) {
const signalingSettings = await getSignalingSettings(user, password, token)
const signaling = new Signaling(user, signalingSettings)
const [publisherSessionId, publisher] = await newPublisher(signalingSettings, signaling, stream)
try {
await publisher.connect()
} catch (exception) {
console.warn('Publisher ' + i + ' error: ' + exception)
}
publishers[publisherSessionId] = publisher
}
console.info('Publishers started the siege')
listenToPublisherConnectionChanges()
}
function listenToSubscriberConnectionChanges() {
subscribers.forEach(subscriber => {
subscriber.peerConnection.addEventListener('iceconnectionstatechange', event => {
if (subscriber.peerConnection.iceConnectionState === 'connected'
|| subscriber.peerConnection.iceConnectionState === 'completed') {
clearTimeout(subscriber.connectionWarning)
subscriber.connectionWarning = null
return
}
if (subscriber.peerConnection.iceConnectionState === 'disconnected') {
// Brief disconnections are normal and expected; they are only
// relevant if the connection has not been restored after some
// seconds.
subscriber.connectionWarning = setTimeout(() => {
console.warn('Subscriber disconnected', subscriber.sessionId)
}, connectionWarningTimeout)
} else if (subscriber.peerConnection.iceConnectionState === 'failed') {
console.warn('Subscriber connection failed', subscriber.sessionId)
}
})
})
}
async function initSubscribers() {
for (let i = 0; i < subscribersPerPublisherCount; i++) {
// The same signaling session can be shared between subscribers to
// different publishers.
const signalingSettings = await getSignalingSettings(user, password, token)
const signaling = new Signaling(user, signalingSettings)
await signaling.getSessionId()
Object.keys(publishers).forEach(async publisherSessionId => {
const subscriber = new Subscriber(user, signalingSettings, signaling, publisherSessionId)
subscribers.push(subscriber)
})
}
for (let i = 0; i < subscribers.length; i++) {
try {
await subscribers[i].connect()
} catch (exception) {
console.warn('Subscriber ' + i + ' error: ' + exception)
}
}
console.info('Subscribers started the siege')
listenToSubscriberConnectionChanges()
}
const closeConnections = function() {
subscribers.forEach(subscriber => {
subscriber.peerConnection.close()
})
Object.values(publishers).forEach(publisher => {
publisher.peerConnection.close()
})
stream.getTracks().forEach(track => {
track.stop()
})
}
console.info('Preparing to siege')
const stream = await navigator.mediaDevices.getUserMedia(mediaConstraints)
await initPublishers()
await initSubscribers()