Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
20 changes: 0 additions & 20 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,26 +251,6 @@ Each item:

---

## SCTP / Data Channel

> Same 2026-05-29 audit; **unverified leads.**

### DataChannel close doesn't send SCTP stream reset (RECONFIG)

- **Found:** 2026-05-29, RFC/W3C divergence audit. **Unverified.**
- **Detail:** RFC 6525 / RFC 8831 §6.7. `DataChannel.close()` flips local state
only; no `OUTGOING_SSN_RESET_REQUEST` is sent, and the SCTP layer doesn't
parse the `reconfig` chunk (declared but unhandled). Also the W3C `closing`
state is effectively skipped (set + cleared in one tick; `onclosing` never
fires).
[dart/lib/sctp/state_machine.dart](dart/lib/sctp/state_machine.dart),
[dart/lib/peer_connection/data_channel.dart](dart/lib/peer_connection/data_channel.dart).
- **Why deferred:** Needs RECONFIG send + parse + handler and an E2E close test.
- **Acceptance:** Close initiates a stream reset, awaits the peer's reset,
fires `onclosing` then `onclose`; RECONFIG round-trips against Chrome.

---

## RTP / RTCP / SDP

> Same 2026-05-29 audit; **unverified leads.**
Expand Down
5 changes: 5 additions & 0 deletions dart/lib/core/types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ final class SctpT3RtxToken extends TimerToken {
SctpT3RtxToken(this.tsn);
}

/// Retransmit timer for an outstanding RE-CONFIG request (RFC 6525 §5.1).
final class SctpReconfigToken extends TimerToken {
SctpReconfigToken();
}

/// Fires the periodic STUN consent-freshness check on the selected pair
/// (RFC 7675 §5.1) — also serves as the keepalive (§6).
final class IceConsentToken extends TimerToken {
Expand Down
33 changes: 32 additions & 1 deletion dart/lib/peer_connection/data_channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,18 @@ final class DataChannel {

final _messageController = StreamController<DataChannelMessageEvent>.broadcast();
final _openController = StreamController<void>.broadcast();
final _closingController = StreamController<void>.broadcast();
final _closeController = StreamController<void>.broadcast();
final _errorController = StreamController<Object>.broadcast();

// Callback set by PeerConnection to send data via SCTP.
void Function(Uint8List data, {bool binary})? _sendCallback;

// Callback set by PeerConnection to initiate an SCTP stream reset when the
// channel is closed (RFC 8831 §6.7). Null until wired or when there is no
// association to reset.
void Function()? _closeCallback;

DataChannel({
required this.label,
this.ordered = true,
Expand All @@ -54,6 +60,10 @@ final class DataChannel {
/// Fired when the channel opens.
Stream<void> get onOpen => _openController.stream;

/// Fired when the channel begins closing (W3C `onclosing`) — i.e. the SCTP
/// stream reset has been initiated but not yet completed.
Stream<void> get onClosing => _closingController.stream;

/// Fired when the channel closes.
Stream<void> get onClose => _closeController.stream;

Expand All @@ -78,9 +88,29 @@ final class DataChannel {
_bytesSent += data.length;
}

/// Begin closing the channel (W3C close procedure). Transitions to
/// `closing`, fires `onClosing`, and initiates the SCTP stream reset
/// (RFC 8831 §6.7). The transition to `closed` (and `onClose`) happens in
/// [_finalizeClose] once the reset completes. With no association to reset
/// (e.g. SCTP not established) it closes immediately.
void close() {
if (_readyState == DataChannelState.closed) return;
if (_readyState == DataChannelState.closed ||
_readyState == DataChannelState.closing) {
return;
}
_readyState = DataChannelState.closing;
_closingController.add(null);
final cb = _closeCallback;
if (cb != null) {
cb();
} else {
_finalizeClose();
}
}

/// Complete the close once the SCTP stream reset finishes. Idempotent.
void _finalizeClose() {
if (_readyState == DataChannelState.closed) return;
_readyState = DataChannelState.closed;
_closeController.add(null);
_disposeControllers();
Expand Down Expand Up @@ -109,6 +139,7 @@ final class DataChannel {
void _disposeControllers() {
_messageController.close();
_openController.close();
_closingController.close();
_closeController.close();
_errorController.close();
}
Expand Down
23 changes: 22 additions & 1 deletion dart/lib/peer_connection/peer_connection.dart
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ final class PeerConnection {
_transport.scheduleSctpTimeout(result.value.nextTimeout);
}
};
channel._closeCallback = () => _resetSctpStream(id);
_dataChannels[id] = channel;

// Send DCEP OPEN when SCTP is established
Expand Down Expand Up @@ -849,7 +850,9 @@ final class PeerConnection {
_rtcpTimer?.cancel();
for (final r in _receivers.values) { r._close(); }
_receivers.clear();
for (final ch in _dataChannels.values) { ch.close(); }
// Tear down channels immediately — the whole transport is going away, so
// a graceful SCTP stream reset would never complete.
for (final ch in _dataChannels.values) { ch._finalizeClose(); }
_dataChannels.clear();
await _transport.stop();
unawaited(_iceCandidateController.close());
Expand Down Expand Up @@ -915,6 +918,7 @@ final class PeerConnection {
_sctp.onEstablished = _notifySctpEstablished;
_sctp.onDataChannelOpen = _onRemoteDataChannelOpen;
_sctp.onData = _onSctpData;
_sctp.onStreamReset = _onSctpStreamReset;

_transport.attachIce(_ice);
_transport.attachDtls(_dtls);
Expand Down Expand Up @@ -1114,6 +1118,7 @@ final class PeerConnection {
ordered: ordered,
ppid: _dataChannelPpid(data, binary));
};
channel._closeCallback = () => _resetSctpStream(streamId);
channel._open();
_dataChannels[streamId] = channel;
_dataChannelController.add(DataChannelEvent(channel));
Expand All @@ -1123,6 +1128,22 @@ final class PeerConnection {
_dataChannels[streamId]?._deliverMessage(data, isBinary);
}

/// Initiate an SCTP stream reset to close a data channel (RFC 8831 §6.7).
void _resetSctpStream(int streamId) {
final result = _sctp.resetStreams([streamId]);
if (result.isOk) {
for (final pkt in result.value.outputPackets) {
_transport.sendSctp(pkt.data);
}
_transport.scheduleSctpTimeout(result.value.nextTimeout);
}
}

/// A stream reset completed (RFC 6525) — finalize the channel's close.
void _onSctpStreamReset(int streamId) {
_dataChannels[streamId]?._finalizeClose();
}

/// SCTP PPID for a data-channel message (RFC 8831 §6.6). An empty
/// message uses the "Empty" PPID so the SCTP layer can carry it as a
/// single padding byte instead of an invalid zero-length DATA chunk.
Expand Down
200 changes: 182 additions & 18 deletions dart/lib/sctp/chunk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,119 @@ final class SctpShutdownCompleteChunk extends SctpChunk {
Uint8List encode() => _wrapChunk(type, flags, Uint8List(0));
}

/// RE-CONFIG chunk (RFC 6525 §3.1) — carries one or two reconfiguration
/// parameters. Used by WebRTC data channels to reset (close) streams
/// (RFC 8831 §6.7).
final class SctpReconfigChunk extends SctpChunk {
final List<SctpReconfigParameter> parameters;
const SctpReconfigChunk(this.parameters) : super(SctpChunkType.reconfig, 0);

@override
Uint8List encode() =>
_wrapChunk(type, flags, _concatBytes([for (final p in parameters) p.encode()]));
}

// ── RE-CONFIG parameters (RFC 6525 §4) ──────────────────────────────────────────

/// RE-CONFIG parameter types (RFC 6525 §4).
abstract final class SctpReconfigParamType {
SctpReconfigParamType._();
static const int outgoingSsnReset = 13; // 0x000D — §4.1
static const int incomingSsnReset = 14; // 0x000E — §4.2
static const int reconfigResponse = 16; // 0x0010 — §4.4
}

sealed class SctpReconfigParameter {
final int type;
const SctpReconfigParameter(this.type);

/// The parameter value (everything after the 4-byte TLV header).
Uint8List encodeValue();

/// Encode as a TLV, padded to a 4-byte boundary (padding not counted in
/// the length field, RFC 6525 §4).
Uint8List encode() => _encodeTlv(type, encodeValue());
}

/// Outgoing SSN Reset Request Parameter (RFC 6525 §4.1) — asks the peer to
/// reset the sender's outgoing streams (i.e. the peer's incoming streams).
/// An empty [streams] list means "all streams".
final class SctpOutgoingSsnResetRequest extends SctpReconfigParameter {
final int requestSeq;
final int responseSeq;
final int lastAssignedTsn;
final List<int> streams;
const SctpOutgoingSsnResetRequest({
required this.requestSeq,
required this.responseSeq,
required this.lastAssignedTsn,
this.streams = const [],
}) : super(SctpReconfigParamType.outgoingSsnReset);

@override
Uint8List encodeValue() {
final out = Uint8List(12 + streams.length * 2);
_writeU32(out, 0, requestSeq);
_writeU32(out, 4, responseSeq);
_writeU32(out, 8, lastAssignedTsn);
var offset = 12;
for (final s in streams) {
_writeU16(out, offset, s);
offset += 2;
}
return out;
}
}

/// Incoming SSN Reset Request Parameter (RFC 6525 §4.2) — asks the peer to
/// reset its outgoing streams (our incoming). An empty [streams] list means
/// "all streams".
final class SctpIncomingSsnResetRequest extends SctpReconfigParameter {
final int requestSeq;
final List<int> streams;
const SctpIncomingSsnResetRequest({
required this.requestSeq,
this.streams = const [],
}) : super(SctpReconfigParamType.incomingSsnReset);

@override
Uint8List encodeValue() {
final out = Uint8List(4 + streams.length * 2);
_writeU32(out, 0, requestSeq);
var offset = 4;
for (final s in streams) {
_writeU16(out, offset, s);
offset += 2;
}
return out;
}
}

/// Re-configuration Response Parameter (RFC 6525 §4.4).
final class SctpReconfigResponse extends SctpReconfigParameter {
// Result codes (RFC 6525 §4.4).
static const int resultSuccessNop = 0;
static const int resultSuccessPerformed = 1;
static const int resultDenied = 2;
static const int resultErrorWrongSsn = 3;
static const int resultErrorRequestInProgress = 4;
static const int resultErrorBadSequence = 5;
static const int resultInProgress = 6;

final int responseSeq;
final int result;
const SctpReconfigResponse({required this.responseSeq, required this.result})
: super(SctpReconfigParamType.reconfigResponse);

@override
Uint8List encodeValue() {
final out = Uint8List(8);
_writeU32(out, 0, responseSeq);
_writeU32(out, 4, result);
return out;
}
}

// ── Parameters ────────────────────────────────────────────────────────────────

sealed class SctpParameter {
Expand Down Expand Up @@ -383,11 +496,61 @@ SctpChunk? _parseChunk(int type, int flags, Uint8List body) {
return const SctpShutdownAckChunk();
case SctpChunkType.shutdownComplete:
return const SctpShutdownCompleteChunk();
case SctpChunkType.reconfig:
return SctpReconfigChunk(_parseReconfigParams(body));
default:
return null;
}
}

List<SctpReconfigParameter> _parseReconfigParams(Uint8List body) {
final params = <SctpReconfigParameter>[];
var offset = 0;
while (offset + 4 <= body.length) {
final type = _u16(body, offset);
final len = _u16(body, offset + 2);
if (len < 4 || offset + len > body.length) break; // malformed
final end = offset + len;
switch (type) {
case SctpReconfigParamType.outgoingSsnReset:
if (len >= 16) {
final streams = <int>[];
for (var o = offset + 16; o + 2 <= end; o += 2) {
streams.add(_u16(body, o));
}
params.add(SctpOutgoingSsnResetRequest(
requestSeq: _u32(body, offset + 4),
responseSeq: _u32(body, offset + 8),
lastAssignedTsn: _u32(body, offset + 12),
streams: streams,
));
}
case SctpReconfigParamType.incomingSsnReset:
if (len >= 8) {
final streams = <int>[];
for (var o = offset + 8; o + 2 <= end; o += 2) {
streams.add(_u16(body, o));
}
params.add(SctpIncomingSsnResetRequest(
requestSeq: _u32(body, offset + 4),
streams: streams,
));
}
case SctpReconfigParamType.reconfigResponse:
if (len >= 12) {
params.add(SctpReconfigResponse(
responseSeq: _u32(body, offset + 4),
result: _u32(body, offset + 8),
));
}
default:
break; // ignore unknown reconfig parameters
}
offset += (len + 3) & ~3;
}
return params;
}

Uint8List _extractCookie(Uint8List params) {
var offset = 0;
while (offset + 4 <= params.length) {
Expand Down Expand Up @@ -419,28 +582,29 @@ Uint8List _wrapChunk(int type, int flags, Uint8List body) {
return out;
}

Uint8List _encodeParams(List<SctpParameter> params) {
final parts = <Uint8List>[];
for (final p in params) {
final val = p.encodeValue();
final len = 4 + val.length;
final padded = (len + 3) & ~3;
final out = Uint8List(padded);
out[0] = (p.type >> 8) & 0xFF;
out[1] = p.type & 0xFF;
out[2] = (len >> 8) & 0xFF;
out[3] = len & 0xFF;
out.setRange(4, 4 + val.length, val);
parts.add(out);
}
final total = parts.fold(0, (s, p) => s + p.length);
final result = Uint8List(total);
Uint8List _encodeParams(List<SctpParameter> params) =>
_concatBytes([for (final p in params) _encodeTlv(p.type, p.encodeValue())]);

/// Encode a `type`/`length`/`value` parameter, padded to a 4-byte boundary
/// (padding is not counted in the length field — RFC 4960 §3.2.1, RFC 6525 §4).
Uint8List _encodeTlv(int type, Uint8List value) {
final len = 4 + value.length;
final out = Uint8List((len + 3) & ~3);
_writeU16(out, 0, type);
_writeU16(out, 2, len);
out.setRange(4, 4 + value.length, value);
return out;
}

Uint8List _concatBytes(List<Uint8List> parts) {
final total = parts.fold<int>(0, (s, p) => s + p.length);
final out = Uint8List(total);
var offset = 0;
for (final p in parts) {
result.setRange(offset, offset + p.length, p);
out.setRange(offset, offset + p.length, p);
offset += p.length;
}
return result;
return out;
}

void _writeU16(Uint8List d, int o, int v) {
Expand Down
Loading
Loading