Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/RPCMessages/RPCConnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ export const DEFAULT_CONNECT_VERSION = {
major: 3,
minor: 0,
revision: 0,
string: 'bkw',
}

export const UNIFIED_CONNECT_VERSION = {
major: 4,
minor: 0,
revision: 0,
string: 'bkw',
}

export const RPCConnect = (params: RPCConnectParams) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"description": "SignalWire JS SDK",
"author": "SignalWire Team <[email protected]>",
"license": "MIT",
"version": "3.29.1",
"version": "3.29.1-bkw",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"version": "3.29.1-bkw",
"version": "3.29.1",

We use Changeset to manage dev releases; no need to add developer tags.

"main": "dist/index.js",
"module": "dist/index.esm.js",
"unpkg": "dist/index.umd.js",
Expand Down
25 changes: 17 additions & 8 deletions packages/js/src/fabric/workers/callSegmentWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export const callSegmentWorker = function* (
case 'call.room':
cfRoomSession.emit(type, payload)
break
case 'call.state':
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already being emitted in L#58 fabricWorker.ts

cfRoomSession.emit(type, payload)
break

/**
* The Core module includes a generic worker, {@link memberPositionWorker},
Expand Down Expand Up @@ -120,17 +123,23 @@ export const callSegmentWorker = function* (
}

const isSegmentEvent = (action: SDKActions) => {
const { type, payload } = action as FabricAction
const shouldWatch =
type.startsWith('call.') ||
type.startsWith('member.') ||
type.startsWith('layout.')
const { payload } = action as FabricAction

// Check both direct payload fields and nested params (for call.state events)
const hasSegmentCallId =
'call_id' in payload && segmentCallId === payload.call_id
('call_id' in payload && segmentCallId === payload.call_id) ||
('params' in payload &&
'call_id' in (payload as any).params &&
(payload as any).params.call_id === segmentCallId)

const hasSegmentRoomSessionId =
segmentRooSessionId === payload.room_session_id
(segmentRooSessionId === payload.room_session_id) ||
('params' in payload &&
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This worker don't receive the raw payload from the WS instead it get a the params object already

'room_session_id' in (payload as any).params &&
(payload as any).params.room_session_id === segmentRooSessionId)

if (shouldWatch && (hasSegmentCallId || hasSegmentRoomSessionId)) {
// Allow ALL events that belong to this segment (no type filtering)
if (hasSegmentCallId || hasSegmentRoomSessionId) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldWatch is required since we have other event workers each specialized in some set of events.

return true
}

Expand Down
120 changes: 109 additions & 11 deletions packages/webrtc/src/RTCPeer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,18 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
this.logger.info(
'Using pre-warmed connection from session pool with ICE candidates ready'
)

// Debug the pooled connection state
this.logger.debug('Pooled connection state:', pooledConnection.connectionState)
this.logger.debug('Pooled connection signaling state:', pooledConnection.signalingState)
this.logger.debug('Pooled connection ICE state:', pooledConnection.iceConnectionState)

const transceivers = pooledConnection.getTransceivers()
this.logger.debug(`Pooled connection has ${transceivers.length} transceivers`)
transceivers.forEach((t, i) => {
this.logger.debug(` Transceiver ${i}: mid=${t.mid}, direction=${t.direction}`)
})

this.instance = pooledConnection

// The connection is already clean:
Expand Down Expand Up @@ -653,18 +665,28 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
t.receiver.track?.kind === 'audio' ||
(!t.sender.track &&
!t.receiver.track &&
t.mid?.includes('audio'))
(t.mid === null || t.mid?.includes('audio')))
)

this.logger.debug(
`Found ${existingAudioTransceivers.length} existing audio transceivers for ${audioTracks.length} audio tracks`
)

audioTracks.forEach((track, index) => {
if (index < existingAudioTransceivers.length) {
// Reuse existing transceiver
const transceiver = existingAudioTransceivers[index]
this.logger.debug(
'Reusing existing audio transceiver',
transceiver.mid
`Reusing existing audio transceiver with mid=${transceiver.mid}, direction=${transceiver.direction}`
)
transceiver.sender.replaceTrack(track)

try {
transceiver.sender.replaceTrack(track)
this.logger.debug(`Successfully replaced track on audio transceiver`)
} catch (error) {
this.logger.error('Failed to replace track on audio transceiver:', error)
}

transceiver.direction =
audioTransceiverParams.direction || 'sendrecv'
// Add stream association
Expand Down Expand Up @@ -704,7 +726,7 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
t.receiver.track?.kind === 'video' ||
(!t.sender.track &&
!t.receiver.track &&
t.mid?.includes('video'))
(t.mid === null || t.mid?.includes('video')))
)

videoTracks.forEach((track, index) => {
Expand Down Expand Up @@ -844,6 +866,16 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
}

stop() {
// Reset negotiation flag to prevent further negotiation attempts
this._negotiating = false
this._processingLocalSDP = false
this._processingRemoteSDP = false

// Clear any active timers
this.clearTimers()
this.clearResumeTimer()
this.clearConnectionStateTimer()

// Do not use `stopTrack` util to not dispatch the `ended` event
this._localStream?.getTracks().forEach((track) => track.stop())
this._remoteStream?.getTracks().forEach((track) => track.stop())
Expand All @@ -867,7 +899,7 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
.find(
(t) =>
t.receiver.track?.kind === kind ||
(!t.sender.track && !t.receiver.track && t.mid?.includes(kind))
(!t.sender.track && !t.receiver.track && (t.mid === null || t.mid?.includes(kind)))
)

if (existingTransceiver) {
Expand Down Expand Up @@ -898,6 +930,14 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
return
}

// Check if the peer connection is still valid before processing
if (!this.instance || this.instance.connectionState === 'closed' ||
this.instance.connectionState === 'failed') {
this.logger.warn('Peer connection is closed or failed in _sdpReady, skipping')
this._negotiating = false
return
}

this._processingLocalSDP = true
clearTimeout(this._iceTimeout)

Expand Down Expand Up @@ -1062,6 +1102,13 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
}

private _retryWithMoreCandidates() {
// Skip renegotiation for answer-type connections (incoming calls)
// This prevents verto.modify spam on Windows where ICE gathering is delayed
if (this.type === 'answer') {
this.logger.info('Skipping renegotiation for answer-type connection (incoming call)')
return;
}

// Check if we have better candidates now than when we first sent SDP
const hasMoreCandidates = this._hasMoreCandidates()

Expand Down Expand Up @@ -1165,6 +1212,13 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
this._restartingIce = false
this.resetNeedResume()

// For answer types, check if we need to send the SDP after becoming stable
if (this.isAnswer && this.instance.iceGatheringState === 'complete' &&
this.instance.localDescription && !this._processingLocalSDP) {
this.logger.debug('Answer type stable with complete ICE - sending SDP')
this._sdpReady()
}

if (this.instance.connectionState === 'connected') {
// An ice restart won't change the connectionState so we emit the same event in here
// since the signalingState is "stable" again.
Expand All @@ -1178,7 +1232,11 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
}
break
}
// case 'have-remote-offer': {}
case 'have-remote-offer':
// We have a remote offer, need to create an answer
// Don't set _negotiating here as it will be set when we actually start negotiation
this.logger.debug('Have remote offer state - ready to create answer')
break
case 'closed':
// @ts-ignore
delete this.instance
Expand All @@ -1194,14 +1252,20 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
// case 'new':
// break
case 'connecting':
// Use longer timeout for answer-type connections (incoming calls)
// These need more time on Windows due to network complexity
const timeout = this.type === 'answer'
? 10000 // 10 seconds for incoming calls
: this.options.maxConnectionStateTimeout; // 3 seconds for outgoing

this._connectionStateTimer = setTimeout(() => {
this.logger.warn('connectionState timed out')
this.logger.warn(`connectionState timed out after ${timeout}ms (type: ${this.type})`)
if (this._hasMoreCandidates()) {
this._retryWithMoreCandidates()
} else {
this.restartIceWithRelayOnly()
}
}, this.options.maxConnectionStateTimeout)
}, timeout)
break
case 'connected':
this.clearConnectionStateTimer()
Expand All @@ -1213,15 +1277,49 @@ export default class RTCPeer<EventTypes extends EventEmitter.ValidEventTypes> {
this.logger.debug('[test] Prevent reattach!')
break
case 'failed': {
this.logger.error('RTCPeerConnection entered failed state')
this.logger.error('Connection details:')
this.logger.error(' - connectionState:', this.instance.connectionState)
this.logger.error(' - iceConnectionState:', this.instance.iceConnectionState)
this.logger.error(' - signalingState:', this.instance.signalingState)

// Debug ICE candidates
this.instance.getStats().then(stats => {
let candidatePairs = 0
stats.forEach(stat => {
if (stat.type === 'candidate-pair') {
candidatePairs++
this.logger.error(` - Candidate pair: state=${stat.state}, nominated=${stat.nominated}`)
}
})
this.logger.error(` - Total candidate pairs: ${candidatePairs}`)
}).catch(err => {
this.logger.error('Failed to get stats:', err)
})

this.triggerResume()
break
}
}
})

this.instance.addEventListener('negotiationneeded', () => {
this.logger.debug('Negotiation needed event')
this.startNegotiation()
this.logger.debug('Negotiation needed event, signaling state:', this.instance.signalingState)

// For answer types (incoming calls), only negotiate if we're in specific states
if (this.isAnswer) {
// Only start negotiation if we haven't answered yet or have a new remote offer
if (!this.instance.remoteDescription ||
this.instance.signalingState === 'have-remote-offer') {
this.logger.debug('Answer type: proceeding with negotiation')
this.startNegotiation()
} else {
this.logger.debug('Answer type: skipping negotiation - already answered or in stable state')
}
} else {
// For offer types (outgoing calls), always negotiate
this.startNegotiation()
}
})

this.instance.addEventListener('iceconnectionstatechange', () => {
Expand Down
Loading
Loading