Repository navigation
Data tracks: core #990
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
Data tracks: core #990
Changes from all commits
ec52261
6616150
b7b76dc
0ad5272
3bd7f06
c1a7dde
c4c54c2
2f78e9c
0be0ec1
91fe703
4a43c3e
b5f46dd
036167c
56ebf30
ea9ca4d
4ded19f
737bf63
9283287
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 |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| // Copyright 2026 LiveKit, 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 datatrack | ||
|
|
||
| import ( | ||
| "math/rand/v2" | ||
| "time" | ||
| ) | ||
|
|
||
| const clockRate = 90_000 | ||
|
|
||
| // timestamp is a packet-level timestamp in ticks of clockRate. | ||
| type timestamp uint32 | ||
|
|
||
| func randomTimestamp() timestamp { | ||
| return timestamp(rand.Uint32()) | ||
| } | ||
|
|
||
| func (t timestamp) isBefore(other timestamp) bool { | ||
| if t-other == 1<<31 { | ||
| return t < other | ||
| } | ||
| return int32(t-other) < 0 | ||
| } | ||
|
|
||
| func (t timestamp) wrappingAdd(ticks uint32) timestamp { | ||
| return timestamp(uint32(t) + ticks) | ||
| } | ||
|
|
||
| // clock maps instants to monotonic packet timestamps. | ||
| type clock struct { | ||
| epoch time.Time | ||
| base timestamp | ||
| prev timestamp | ||
| } | ||
|
|
||
| func newClock(base timestamp) *clock { | ||
| return newClockWithEpoch(time.Now(), base) | ||
| } | ||
|
|
||
| func newClockWithEpoch(epoch time.Time, base timestamp) *clock { | ||
| return &clock{epoch: epoch, base: base, prev: base} | ||
| } | ||
|
|
||
| func (c *clock) now() timestamp { | ||
| return c.at(time.Now()) | ||
| } | ||
|
|
||
| func (c *clock) at(instant time.Time) timestamp { | ||
| ts := c.base.wrappingAdd(durationToTicks(instant.Sub(c.epoch))) | ||
| if ts.isBefore(c.prev) { | ||
| ts = c.prev | ||
| } | ||
| c.prev = ts | ||
|
ladvoc marked this conversation as resolved.
|
||
| return ts | ||
| } | ||
|
|
||
| func durationToTicks(d time.Duration) uint32 { | ||
| nanos := d.Nanoseconds() | ||
| if nanos < 0 { | ||
| nanos = 0 | ||
| } | ||
| seconds, remainder := uint64(nanos)/1_000_000_000, uint64(nanos)%1_000_000_000 | ||
| return uint32(seconds*clockRate + (remainder*clockRate+500_000_000)/1_000_000_000) | ||
|
Contributor
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. Can potentially make this simpler. returns
Contributor
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. May need bounds check though of < 0 or > something which will actually overflow
Contributor
Author
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. Went back and forth on this with an agent, and it pointed me to this:
Which I think means the cast to |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,212 @@ | ||
| // Copyright 2026 LiveKit, 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 datatrack | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "slices" | ||
|
|
||
| dtp "github.com/livekit/protocol/datatrack" | ||
| ) | ||
|
|
||
| const maxBufferPackets = 128 | ||
|
|
||
| // depacketizer reassembles packets into frames. | ||
| type depacketizer struct { | ||
| partials map[uint16]*partialFrame | ||
| // frame numbers of partials in insertion order, oldest first | ||
| order []uint16 | ||
| } | ||
|
|
||
| type partialFrame struct { | ||
| startSequence uint16 | ||
| extensions Extensions | ||
| payloads map[uint16][]byte | ||
| } | ||
|
|
||
| type depacketizerFrame struct { | ||
| payload []byte | ||
| extensions Extensions | ||
| } | ||
|
|
||
| type depacketizerPushOptions struct { | ||
| // maxPartialFrames is the number of frames assembled concurrently; the oldest partial is | ||
| // evicted when a new frame arrives at capacity. | ||
| maxPartialFrames int | ||
| } | ||
|
|
||
| // depacketizerPushResult may carry both a completed frame and a dropped one, since a single | ||
| // packet can complete one frame while evicting another. | ||
| type depacketizerPushResult struct { | ||
| frame *depacketizerFrame | ||
| drop *depacketizerDropError | ||
| } | ||
|
|
||
| type depacketizerDropReason int | ||
|
|
||
| const ( | ||
| depacketizerDropReasonInterrupted depacketizerDropReason = iota | ||
| depacketizerDropReasonUnknownFrame | ||
| depacketizerDropReasonBufferFull | ||
| depacketizerDropReasonIncomplete | ||
| ) | ||
|
|
||
| // depacketizerDropError records a dropped frame. | ||
| type depacketizerDropError struct { | ||
| frameNumber uint16 | ||
| reason depacketizerDropReason | ||
| // newFrameNumber is the frame that interrupted the dropped one | ||
| newFrameNumber uint16 | ||
| // received and expected packet counts of an incomplete frame | ||
| received, expected uint16 | ||
| } | ||
|
|
||
| func (e *depacketizerDropError) Error() string { | ||
| var reason string | ||
| switch e.reason { | ||
| case depacketizerDropReasonInterrupted: | ||
| reason = fmt.Sprintf("interrupted by new frame %d", e.newFrameNumber) | ||
| case depacketizerDropReasonUnknownFrame: | ||
| reason = "unknown frame" | ||
| case depacketizerDropReasonBufferFull: | ||
| reason = "buffer full" | ||
| case depacketizerDropReasonIncomplete: | ||
| reason = fmt.Sprintf("incomplete (%d/%d)", e.received, e.expected) | ||
| } | ||
| return fmt.Sprintf("frame %d dropped: %s", e.frameNumber, reason) | ||
| } | ||
|
|
||
| func newDepacketizer() *depacketizer { | ||
| return &depacketizer{partials: make(map[uint16]*partialFrame)} | ||
| } | ||
|
|
||
| func (d *depacketizer) push(packet dtp.Packet, options depacketizerPushOptions) depacketizerPushResult { | ||
| switch markerOf(&packet.Header) { | ||
|
Contributor
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. can this method be pushed to When server needs to depacketize, this whole thing could move to
Contributor
Author
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. I agree, this is something that will likely be shared in the future. I'm assuming you're talking about the
Contributor
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. Yeah, meant |
||
| case FrameMarkerSingle: | ||
| return d.frameFromSingle(packet, options) | ||
| case FrameMarkerStart: | ||
| return d.beginPartial(packet, options) | ||
| default: | ||
| return d.pushToPartial(packet) | ||
| } | ||
| } | ||
|
|
||
| func (d *depacketizer) frameFromSingle(packet dtp.Packet, options depacketizerPushOptions) depacketizerPushResult { | ||
| var result depacketizerPushResult | ||
| // A single packet is a self-contained frame and does not occupy a partials slot, but if | ||
| // the partials map is at capacity it is treated as a signal that the oldest in-flight | ||
| // partial is stale and evicts it. | ||
| if len(d.partials) >= options.maxPartialFrames { | ||
| result.drop = d.evictOldest(packet.FrameNumber) | ||
|
Contributor
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. The single frame has not been appended to the
Contributor
Author
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. A |
||
| } | ||
| result.frame = &depacketizerFrame{payload: packet.Payload, extensions: extensionsOf(&packet.Header)} | ||
|
Contributor
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.
|
||
| return result | ||
| } | ||
|
|
||
| func (d *depacketizer) evictOldest(newFrameNumber uint16) *depacketizerDropError { | ||
| if len(d.order) == 0 { | ||
| return nil | ||
| } | ||
| oldest := d.order[0] | ||
| d.remove(oldest) | ||
| return &depacketizerDropError{frameNumber: oldest, reason: depacketizerDropReasonInterrupted, newFrameNumber: newFrameNumber} | ||
| } | ||
|
|
||
| func (d *depacketizer) remove(frameNumber uint16) { | ||
| delete(d.partials, frameNumber) | ||
| if i := slices.Index(d.order, frameNumber); i >= 0 { | ||
| d.order = slices.Delete(d.order, i, i+1) | ||
|
Contributor
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. do you want another level of protection here and do the map delete only if this condition is true? |
||
| } | ||
| } | ||
|
|
||
| func (d *depacketizer) beginPartial(packet dtp.Packet, options depacketizerPushOptions) depacketizerPushResult { | ||
| var result depacketizerPushResult | ||
| for len(d.partials) >= options.maxPartialFrames { | ||
| evicted := d.evictOldest(packet.FrameNumber) | ||
| if evicted == nil { | ||
| break | ||
| } | ||
| if result.drop == nil { | ||
| result.drop = evicted | ||
|
Contributor
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. Is the |
||
| } | ||
| } | ||
|
|
||
| if _, replacing := d.partials[packet.FrameNumber]; !replacing { | ||
| d.order = append(d.order, packet.FrameNumber) | ||
| } | ||
|
ladvoc marked this conversation as resolved.
|
||
| d.partials[packet.FrameNumber] = &partialFrame{ | ||
| startSequence: packet.SequenceNumber, | ||
| extensions: extensionsOf(&packet.Header), | ||
| payloads: map[uint16][]byte{packet.SequenceNumber: packet.Payload}, | ||
| } | ||
|
Contributor
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 is enforcing that the begin packet has to come first. Guess that is a known limitation per the Devin comment above and your reply, but wanted to flag. |
||
| return result | ||
| } | ||
|
|
||
| func (d *depacketizer) pushToPartial(packet dtp.Packet) depacketizerPushResult { | ||
| frameNumber := packet.FrameNumber | ||
| partial, ok := d.partials[frameNumber] | ||
| if !ok { | ||
| return depacketizerPushResult{drop: &depacketizerDropError{frameNumber: frameNumber, reason: depacketizerDropReasonUnknownFrame}} | ||
| } | ||
|
|
||
| if len(partial.payloads) >= maxBufferPackets { | ||
| d.remove(frameNumber) | ||
| return depacketizerPushResult{drop: &depacketizerDropError{frameNumber: frameNumber, reason: depacketizerDropReasonBufferFull}} | ||
| } | ||
|
|
||
| partial.payloads[packet.SequenceNumber] = packet.Payload | ||
|
|
||
| if markerOf(&packet.Header) == FrameMarkerFinal { | ||
| d.remove(frameNumber) | ||
| return finalize(frameNumber, partial, packet.SequenceNumber) | ||
|
ladvoc marked this conversation as resolved.
|
||
| } | ||
| return depacketizerPushResult{} | ||
| } | ||
|
|
||
| func finalize(frameNumber uint16, partial *partialFrame, endSequence uint16) depacketizerPushResult { | ||
| received := uint16(len(partial.payloads)) | ||
|
ladvoc marked this conversation as resolved.
|
||
| expected := endSequence - partial.startSequence + 1 | ||
| if received != expected { | ||
| return depacketizerPushResult{drop: &depacketizerDropError{ | ||
| frameNumber: frameNumber, | ||
| reason: depacketizerDropReasonIncomplete, | ||
| received: received, | ||
| expected: expected, | ||
| }} | ||
| } | ||
|
|
||
| payloadLen := 0 | ||
| for _, payload := range partial.payloads { | ||
| payloadLen += len(payload) | ||
| } | ||
| payload := make([]byte, 0, payloadLen) | ||
|
|
||
| for sequence := partial.startSequence; ; sequence++ { | ||
| partialPayload, ok := partial.payloads[sequence] | ||
| if !ok { | ||
| break | ||
| } | ||
| payload = append(payload, partialPayload...) | ||
| if sequence == endSequence { | ||
| return depacketizerPushResult{frame: &depacketizerFrame{payload: payload, extensions: partial.extensions}} | ||
| } | ||
| } | ||
| return depacketizerPushResult{drop: &depacketizerDropError{ | ||
| frameNumber: frameNumber, | ||
| reason: depacketizerDropReasonIncomplete, | ||
| received: received, | ||
| expected: expected, | ||
| }} | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.