Skip to content

Commit b81072b

Browse files
committed
feat: Enhance SCTP data channel support with improved state management and testing; add new tests for out-of-order data handling and SACK chunk processing
1 parent 1ea6c8a commit b81072b

8 files changed

Lines changed: 413 additions & 30 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,10 @@ INIT_ACK, COOKIE_ECHO, COOKIE_ACK, DATA, SACK, parameter padding, and CRC32C
257257
checksum validation, with a unit-tested OpenSSL DTLS application-data
258258
association bootstrap that can exchange SCTP DATA chunks, SACK responses, and
259259
reassemble fragmented SCTP DATA messages, with duplicate DATA TSN suppression
260-
and contiguous cumulative SACK tracking. The shared media/data binder and Room
261-
live-media startup helper can select that association transport in
260+
and contiguous cumulative SACK tracking with gap/duplicate reporting, while
261+
holding out-of-order DATA until contiguous TSNs can be delivered and tracking
262+
outbound DATA TSNs until peer SACK acknowledgement. The shared media/data binder
263+
and Room live-media startup helper can select that association transport in
262264
package-internal opt-in tests, and the LiveKit integration harness now validates
263265
a default-gated two-client reliable `DataPacket` publish/receive path over that
264266
standards-shaped SCTP association transport. The public default Room path

Sources/LiveKitNative/Core/ProductionReadiness.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public extension LiveKitNative {
3232
"Fresh join, resume reconnect, disconnect, and failed signal-loop boundaries regenerate local ICE credentials, clear stale peer negotiation state, parse TURN UDP/TCP/TLS endpoint configuration, order TURN relay fallback candidates as UDP, TCP, then TLS while identifying the currently executable UDP datagram path, select supported TURN UDP relay session configurations from parsed ICE server endpoints with credentials, exercise TURN Allocate, Refresh, CreatePermission, and ChannelBind request/authentication/response-validation primitives with one-shot stale nonce retry, cover TURN ChannelData framing, provide deterministic allocation/permission maintenance scheduling and execution, plan relayed ICE candidates from TURN bindings, compose and execute a bounded TURN relay session setup plan over abstract transports, expose a ChannelData relay transport over an abstract media datagram transport, allocate default UDP TURN relay candidates through the bound Room ICE socket, use ChannelData relay bindings for relayed ICE checks and media datagrams, provide deterministic ICE consent freshness planning plus an injected Room startup loop, run ICEAgent connectivity checks through paced scheduling with queued triggered-check priority plus role-conflict role switching and pair-priority recompute, and rebuild reconnect SyncState SDP/trickle state with fresh local ICE credentials, but TCP/TLS TURN transport execution, LiveKit TURN-only validation, and live media recovery are not complete.",
3333
"Publisher camera capture can encode H.264 through VideoToolbox with bounded frame backpressure/drop control and publisher microphone capture can encode Opus through AudioToolbox before RTP/SRTP send, while subscriber RTP can pass through jitter buffering, H.264/Opus packet assembly, NACK/PLI feedback, opt-in Opus decode-to-audio-playout scheduling, opt-in H.264 VideoToolbox decode-to-pixel-buffer scheduling, application-provided subscriber video renderer handoff, UIKit/AppKit VideoView sample-buffer rendering, and opt-in iOS voice-chat audio-session lifecycle configuration; real-device video display validation, LiveKit E2E media validation, route/interruption audio recovery, and production runtime pacing remain incomplete.",
3434
"Production H.264 now uses real VideoToolbox encode output for publish smoke coverage, opt-in VideoToolbox decode output for subscribe smoke coverage, and UIKit/AppKit VideoView rendering for decoded frames, but production readiness still requires real-device display validation, hardware acceleration verification where the OS exposes that signal, and explicit fallback behavior instead of relying on a pure Swift codec implementation.",
35-
"SCTP data channel support includes packet-level DCEP/DataPacket planning, manager-assigned stream routing, queued local publish flushing, inbound DataPacket-to-RoomEvent plumbing, publisher SDP data-channel negotiation, subscriber-side data-channel receive-loop plumbing, OpenSSL DTLS application-data transport coverage, default Room shared-media DTLS/SRTP demux binding, deterministic packet fragmentation/reassembly envelopes, fragmented-packet retransmission scheduling on the DTLS-backed packet transport, opt-in standards-shaped SCTP association bootstrap with DATA/SACK exchange, fragmented DATA reassembly, duplicate DATA TSN suppression, contiguous cumulative SACK tracking, recovery reset that reopens LiveKit data channels after association restart, and default-gated two-client LiveKit DataPacket publish/receive smoke over that standards-shaped association transport, but full standards-compliant SCTP association state, congestion control, LiveKit-validated data-channel recovery, and E2E hardening are incomplete.",
35+
"SCTP data channel support includes packet-level DCEP/DataPacket planning, manager-assigned stream routing, queued local publish flushing, inbound DataPacket-to-RoomEvent plumbing, publisher SDP data-channel negotiation, subscriber-side data-channel receive-loop plumbing, OpenSSL DTLS application-data transport coverage, default Room shared-media DTLS/SRTP demux binding, deterministic packet fragmentation/reassembly envelopes, fragmented-packet retransmission scheduling on the DTLS-backed packet transport, opt-in standards-shaped SCTP association bootstrap with DATA/SACK exchange, fragmented DATA reassembly, duplicate DATA TSN suppression, contiguous cumulative SACK tracking with gap/duplicate reporting, ordered contiguous TSN delivery, outbound DATA TSN acknowledgement tracking, recovery reset that reopens LiveKit data channels after association restart, and default-gated two-client LiveKit DataPacket publish/receive smoke over that standards-shaped association transport, but full standards-compliant SCTP association state, congestion control, LiveKit-validated data-channel recovery, and E2E hardening are incomplete.",
3636
"Signal reconnect resets stale peer negotiation state, restarts local ICE credentials, rebuilds retained subscriber answer and publisher offer SDP with fresh ICE credentials, sends SyncState for retained subscription/local media/data publication state, re-sends local ICE trickle/final-trickle when media startup is configured, preserves publisher offer track state for later publish/unpublish re-offers, clears stale local media/data publication state for server/SFU unpublish responses, and resets injected publisher data channels for post-reconnect DCEP reopen in unit tests, but live media recovery and LiveKit integration hardening are incomplete.",
3737
"General production video meetings require the new audio-session primitives to be validated with meeting-grade capture/playout, echo cancellation, route changes, Bluetooth behavior, interruptions, background/foreground handling, and timing on real iOS devices.",
3838
"A default subscriber RTP receive pipeline now integrates jitter buffering with bounded NACK/PLI feedback, scheduled RTCP Receiver Reports with DLSR timing from observed RTP/Sender Report state, REMB bitrate-feedback packet planning/sending, deterministic RTCP receiver-report bandwidth estimation, adaptive video quality recommendations, publisher RTCP receiver-report ingestion, H.264 encoder bitrate/FPS recommendation application, manual subscriber quality preset signaling, opt-in automatic subscriber adaptive track-settings dispatch, publisher active video-layer UpdateVideoLayers signaling, single-layer H.264 AddTrack high-quality metadata, and camera publish frame backpressure primitives, but general production video meetings still require full packet-loss recovery, TWCC/full REMB interop or equivalent congestion control, default-on LiveKit-validated subscriber adaptation policy, complete encoder control policy, actual multi-encoder simulcast/SVC media production, and weak-network E2E validation.",

Sources/LiveKitNative/LiveKitNative.docc/LiveKitNative.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@ adds queued local data publish flushing through an injected SCTP packet
3737
transport, inbound data-channel `DataPacket` event plumbing, and OpenSSL DTLS
3838
application-data packet transport coverage with deterministic packet
3939
fragmentation/reassembly and fragmented-packet retransmission scheduling.
40-
Standards-shaped SCTP association coverage now suppresses duplicate DATA TSNs
41-
and tracks contiguous cumulative SACK state. Data channel recovery can reset
40+
Standards-shaped SCTP association coverage now suppresses duplicate DATA TSNs,
41+
tracks contiguous cumulative SACK state with gap/duplicate reporting, and holds
42+
out-of-order DATA until contiguous TSNs can be delivered while tracking outbound
43+
DATA TSNs until peer SACK acknowledgement. Data channel recovery can reset
4244
LiveKit channels after association restart and Room reconnect responses reset
43-
injected publisher data channels before post-reconnect publish. Separately
44-
gated LiveKit integration now validates a two-client reliable `DataPacket`
45+
injected publisher data channels before post-reconnect publish. Default-gated
46+
LiveKit integration now validates a two-client reliable `DataPacket`
4547
publish/receive smoke over the standards-shaped SCTP association transport. A
4648
shared WebRTC DTLS/SRTP datagram demux and media/data session binder can keep
4749
persistent OpenSSL DTLS application data and SRTP media on the same selected

Sources/LiveKitNativeWebRTC/SCTPDataChannel.swift

Lines changed: 167 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ package enum SCTPDataChannelError: Error, Equatable, Sendable {
3333
case mismatchedFragmentMetadata(messageID: UInt32)
3434
case missingFragments(messageID: UInt32)
3535
case retransmissionAttemptsExhausted(messageID: UInt32, fragmentIndex: UInt16)
36+
case invalidSCTPAssociationState(expected: String, actual: String)
3637
case invalidUTF8
3738
case duplicateStreamID(UInt16)
3839
case duplicateLabel(String)
@@ -1436,7 +1437,12 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
14361437
private var peerVerificationTag: UInt32?
14371438
private var nextTSN: UInt32
14381439
private var expectedPeerTSN: UInt32?
1440+
private var nextPeerDeliveryTSN: UInt32?
14391441
private var receivedPeerTSNs: Set<UInt32> = []
1442+
private var receivedPeerDataChunks: [UInt32: SCTPDataChunk] = [:]
1443+
private var pendingOutboundDataChunks: [UInt32: SCTPDataChunk] = [:]
1444+
private var mutableLastPeerCumulativeTSNAck: UInt32?
1445+
private var mutableLastPeerSACK: SCTPSACKChunk?
14401446
private var nextStreamSequenceNumbers: [UInt16: UInt16] = [:]
14411447
private var pendingReceivedPackets: [SCTPDataChannelPacket] = []
14421448
private var receivePumpTask: Task<Void, Never>?
@@ -1458,6 +1464,22 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
14581464
state == .established
14591465
}
14601466

1467+
package var pendingReceivedPacketCount: Int {
1468+
pendingReceivedPackets.count
1469+
}
1470+
1471+
package var pendingOutboundTSNCount: Int {
1472+
pendingOutboundDataChunks.count
1473+
}
1474+
1475+
package var lastPeerCumulativeTSNAck: UInt32? {
1476+
mutableLastPeerCumulativeTSNAck
1477+
}
1478+
1479+
package var lastPeerSACK: SCTPSACKChunk? {
1480+
mutableLastPeerSACK
1481+
}
1482+
14611483
package func startAssociation() async throws {
14621484
try await ensureAssociation()
14631485
}
@@ -1471,10 +1493,16 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
14711493
streamSequenceNumber: streamSequenceNumber
14721494
)
14731495
nextTSN &+= UInt32(dataChunks.count)
1474-
try await sendPacket(
1475-
verificationTag: try requirePeerVerificationTag(),
1476-
chunks: dataChunks.map { .data($0) }
1477-
)
1496+
trackOutboundDataChunks(dataChunks)
1497+
do {
1498+
try await sendPacket(
1499+
verificationTag: try requirePeerVerificationTag(),
1500+
chunks: dataChunks.map { .data($0) }
1501+
)
1502+
} catch {
1503+
untrackOutboundDataChunks(dataChunks)
1504+
throw error
1505+
}
14781506
}
14791507

14801508
package func receive() async throws -> SCTPDataChannelPacket {
@@ -1541,7 +1569,7 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
15411569
case .data:
15421570
try await acceptData(chunk, packet: packet)
15431571
case .sack:
1544-
_ = try SCTPSACKChunk(chunk: chunk)
1572+
try acceptSACK(chunk)
15451573
default:
15461574
continue
15471575
}
@@ -1633,8 +1661,7 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
16331661
try validateVerificationTag(packet.verificationTag, expected: 0)
16341662
let initChunk = try SCTPInitChunk(chunk: chunk)
16351663
peerVerificationTag = initChunk.initiateTag
1636-
expectedPeerTSN = initChunk.initialTSN
1637-
receivedPeerTSNs.removeAll()
1664+
resetPeerReceiveState(initialTSN: initChunk.initialTSN)
16381665

16391666
let initAck = SCTPInitChunk(
16401667
type: .initAck,
@@ -1657,11 +1684,14 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
16571684
}
16581685

16591686
private func acceptInitAck(_ chunk: SCTPChunk, packet: SCTPPacket) async throws {
1687+
try requireAssociationState(
1688+
allowed: [.initSent, .initAckSent],
1689+
expected: "INIT_ACK after local INIT"
1690+
)
16601691
try validateVerificationTag(packet.verificationTag, expected: configuration.localInitiateTag)
16611692
let initAck = try SCTPInitChunk(chunk: chunk)
16621693
peerVerificationTag = initAck.initiateTag
1663-
expectedPeerTSN = initAck.initialTSN
1664-
receivedPeerTSNs.removeAll()
1694+
resetPeerReceiveState(initialTSN: initAck.initialTSN)
16651695
guard let stateCookie = initAck.parameters.first(where: { $0.type == SCTPParameterType.stateCookie })?.value else {
16661696
throw SCTPDataChannelError.missingSCTPStateCookie
16671697
}
@@ -1674,6 +1704,10 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
16741704
}
16751705

16761706
private func acceptCookieEcho(packet: SCTPPacket) async throws {
1707+
try requireAssociationState(
1708+
allowed: [.initAckSent, .cookieEchoSent, .established],
1709+
expected: "COOKIE_ECHO after local INIT_ACK"
1710+
)
16771711
try validateVerificationTag(packet.verificationTag, expected: configuration.localInitiateTag)
16781712
try await sendPacket(
16791713
verificationTag: try requirePeerVerificationTag(),
@@ -1683,31 +1717,109 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
16831717
}
16841718

16851719
private func acceptCookieAck(packet: SCTPPacket) throws {
1720+
try requireAssociationState(
1721+
allowed: [.cookieEchoSent, .established],
1722+
expected: "COOKIE_ACK after local COOKIE_ECHO"
1723+
)
16861724
try validateVerificationTag(packet.verificationTag, expected: configuration.localInitiateTag)
16871725
state = .established
16881726
}
16891727

1728+
private func acceptSACK(_ chunk: SCTPChunk) throws {
1729+
try requireAssociationState(
1730+
allowed: [.established],
1731+
expected: "SACK after association establishment"
1732+
)
1733+
let sack = try SCTPSACKChunk(chunk: chunk)
1734+
mutableLastPeerSACK = sack
1735+
mutableLastPeerCumulativeTSNAck = sack.cumulativeTSNAck
1736+
acknowledgeOutboundChunks(with: sack)
1737+
}
1738+
16901739
private func acceptData(_ chunk: SCTPChunk, packet: SCTPPacket) async throws {
1740+
try requireAssociationState(
1741+
allowed: [.established],
1742+
expected: "DATA after association establishment"
1743+
)
16911744
try validateVerificationTag(packet.verificationTag, expected: configuration.localInitiateTag)
16921745
let dataChunk = try SCTPDataChunk(chunk: chunk)
16931746
let isNewTSN = markPeerTSNReceived(dataChunk.tsn)
16941747
let cumulativeTSNAck = expectedPeerTSN.map { $0 &- 1 } ?? dataChunk.tsn
1748+
if isNewTSN {
1749+
receivedPeerDataChunks[dataChunk.tsn] = dataChunk
1750+
}
16951751

16961752
try await sendPacket(
16971753
verificationTag: try requirePeerVerificationTag(),
16981754
chunks: [
16991755
.sack(SCTPSACKChunk(
17001756
cumulativeTSNAck: cumulativeTSNAck,
1701-
advertisedReceiverWindowCredit: configuration.advertisedReceiverWindowCredit
1757+
advertisedReceiverWindowCredit: configuration.advertisedReceiverWindowCredit,
1758+
gapAckBlocks: sackGapAckBlocks(cumulativeTSNAck: cumulativeTSNAck),
1759+
duplicateTSNs: isNewTSN ? [] : [dataChunk.tsn]
17021760
)),
17031761
]
17041762
)
17051763
guard isNewTSN else {
17061764
return
17071765
}
17081766

1709-
if let packet = try fragmentReassembler.append(dataChunk) {
1710-
pendingReceivedPackets.append(packet)
1767+
try drainContiguousPeerDataChunks()
1768+
}
1769+
1770+
private func resetPeerReceiveState(initialTSN: UInt32) {
1771+
expectedPeerTSN = initialTSN
1772+
nextPeerDeliveryTSN = initialTSN
1773+
receivedPeerTSNs.removeAll()
1774+
receivedPeerDataChunks.removeAll()
1775+
fragmentReassembler = SCTPDataChunkFragmentReassembler()
1776+
}
1777+
1778+
private func trackOutboundDataChunks(_ chunks: [SCTPDataChunk]) {
1779+
for chunk in chunks {
1780+
pendingOutboundDataChunks[chunk.tsn] = chunk
1781+
}
1782+
}
1783+
1784+
private func untrackOutboundDataChunks(_ chunks: [SCTPDataChunk]) {
1785+
for chunk in chunks {
1786+
pendingOutboundDataChunks.removeValue(forKey: chunk.tsn)
1787+
}
1788+
}
1789+
1790+
private func acknowledgeOutboundChunks(with sack: SCTPSACKChunk) {
1791+
let cumulativelyAcknowledgedTSNs = pendingOutboundDataChunks.keys.filter {
1792+
tsnIsAcknowledged($0, by: sack.cumulativeTSNAck)
1793+
}
1794+
for tsn in cumulativelyAcknowledgedTSNs {
1795+
pendingOutboundDataChunks.removeValue(forKey: tsn)
1796+
}
1797+
1798+
for block in sack.gapAckBlocks {
1799+
var offset = block.start
1800+
while offset <= block.end {
1801+
pendingOutboundDataChunks.removeValue(forKey: sack.cumulativeTSNAck &+ UInt32(offset))
1802+
if offset == UInt16.max {
1803+
break
1804+
}
1805+
offset += 1
1806+
}
1807+
}
1808+
}
1809+
1810+
private func tsnIsAcknowledged(_ tsn: UInt32, by cumulativeTSNAck: UInt32) -> Bool {
1811+
tsn == cumulativeTSNAck || Int32(bitPattern: cumulativeTSNAck &- tsn) > 0
1812+
}
1813+
1814+
private func requireAssociationState(
1815+
allowed: [AssociationState],
1816+
expected: String
1817+
) throws {
1818+
guard allowed.contains(state) else {
1819+
throw SCTPDataChannelError.invalidSCTPAssociationState(
1820+
expected: expected,
1821+
actual: String(describing: state)
1822+
)
17111823
}
17121824
}
17131825

@@ -1732,6 +1844,49 @@ package actor DTLSSCTPAssociationDataChannelPacketTransport: SCTPDataChannelPack
17321844
expectedPeerTSN = nextTSN
17331845
}
17341846

1847+
private func sackGapAckBlocks(cumulativeTSNAck: UInt32) -> [SCTPSACKGapAckBlock] {
1848+
let offsets = receivedPeerTSNs.compactMap { tsn -> UInt16? in
1849+
let offset = tsn &- cumulativeTSNAck
1850+
guard offset > 0, offset <= UInt32(UInt16.max) else {
1851+
return nil
1852+
}
1853+
return UInt16(offset)
1854+
}.sorted()
1855+
1856+
guard !offsets.isEmpty else {
1857+
return []
1858+
}
1859+
1860+
var blocks: [SCTPSACKGapAckBlock] = []
1861+
var start = offsets[0]
1862+
var end = offsets[0]
1863+
for offset in offsets.dropFirst() {
1864+
guard offset == end &+ 1 else {
1865+
blocks.append(SCTPSACKGapAckBlock(start: start, end: end))
1866+
start = offset
1867+
end = offset
1868+
continue
1869+
}
1870+
end = offset
1871+
}
1872+
blocks.append(SCTPSACKGapAckBlock(start: start, end: end))
1873+
return blocks
1874+
}
1875+
1876+
private func drainContiguousPeerDataChunks() throws {
1877+
guard var nextTSN = nextPeerDeliveryTSN else {
1878+
return
1879+
}
1880+
1881+
while let chunk = receivedPeerDataChunks.removeValue(forKey: nextTSN) {
1882+
if let packet = try fragmentReassembler.append(chunk) {
1883+
pendingReceivedPackets.append(packet)
1884+
}
1885+
nextTSN &+= 1
1886+
}
1887+
nextPeerDeliveryTSN = nextTSN
1888+
}
1889+
17351890
private func dataChunks(
17361891
for packet: SCTPDataChannelPacket,
17371892
firstTSN: UInt32,

0 commit comments

Comments
 (0)