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
5 changes: 5 additions & 0 deletions .changeset/mighty-beans-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@livekit/protocol": patch
---

encode monotonic time in wall clock
45 changes: 17 additions & 28 deletions utils/mono/mono.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.

// Package mono enforces use of monotonic time when creating/parsing time.Time from external sources.
//
// Using time.Now produces monotonic time values that correctly measure time difference in the presence of clock resets.
//
// On the other hand, time produce by time.Unix or time.Parse doesn't have this property. Clock reset may lead to incorrect
// durations computed from these timestamps. To fix this, prefer using Unix and Parse provided by this package.
//
// Monotonic time could also be erased when using functions like Truncate, Round, In, UTC. Be careful when using these.
// These APIs encode monotonic time into time.Time wall-clock fields. Returned
// values intentionally do not carry Go's internal monotonic payload and are
// meant to be compared with other mono timestamps.
//
// More details: https://go.googlesource.com/proposal/+/master/design/12914-monotonic.md
package mono
Expand All @@ -38,37 +33,31 @@ func resetClock() {
epochNano = epoch.UnixNano()
}

// jumpClock adjusts reference timestamp by a given duration emulating a clock reset/jump.
// Used in tests only.
// jumpClock adjusts reference timestamp by a given duration emulating a clock
// reset/jump. Used in tests only.
func jumpClock(dt time.Duration) {
epoch = epoch.Add(-dt) // we pretend time.Now() jumps, not the reference
epochNano = epoch.UnixNano()
}

// FromTime ensures that time.Time value uses monotonic clock.
//
// Deprecated: You should probably use Unix or Parse instead.
// FromTime creates a Time from the monotonic part of t. Note that the monotonic
// part of t could have been erased when using functions like Truncate, Round,
// In, UTC, etc... Be careful when using this
func FromTime(t time.Time) time.Time {
return fromTime(t)
}

func fromTime(t time.Time) time.Time {
if t.IsZero() {
return time.Time{}
}
return epoch.Add(t.Sub(epoch))
return time.Unix(0, epochNano+int64(t.Sub(epoch)))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

to avoid any confusion we store the epoch relative monotonic time in the wall clock field of a time without the hasMonotonic flag so normal time mutation apis work and the time remains comparable to other mono produced times

}

// Now is a wrapper for time.Time.
//
// Deprecated: time.Now always uses monotonic clock.
// Now creates a monotonic time without reading the system wall clock
func Now() time.Time {
return time.Now()
return time.Unix(0, epochNano+int64(time.Since(epoch)))
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

time.Since skips reading the wall clock time so it's ~30% faster than time.Now

pkg: github.com/livekit/protocol/utils/mono
cpu: Apple M1 Pro
BenchmarkTime/Now()-10         	40969438	        24.71 ns/op
BenchmarkTime/time.Now()-10    	34145271	        34.97 ns/op

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Awesome 💯

Copy link
Copy Markdown
Contributor

@dennwc dennwc Mar 5, 2026

Choose a reason for hiding this comment

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

TIL! Nice optimisation!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The only concern is that it requires the use of mono.Now() to properly account for time jumps, right?

Previously, any value produced by time.Now() would still work, since it had monotonic field set. Only requirement was that timestamps from API/files should be passed through FromTime to capture the monotonic offset (from epoch).

The nice thing about the previous approach was that it's idiomatic, one may still use stdlib for everything, except getting timestamps from external sources.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

mono.FromTime(time.Now()) still works but yeah, for times to be comparable they have to have been updated here. we could define a monotonic time type (int64) and reimplement the time.Time api but idk... i think anyone reaching for this can be trusted to use it correctly

}

// Unix is an analog of time.Unix that produces monotonic time.
func Unix(sec, nsec int64) time.Time {
return fromTime(time.Unix(sec, nsec))
return FromTime(time.Unix(sec, nsec))
}

// Parse is an analog of time.Parse that produces monotonic time.
Expand All @@ -77,17 +66,17 @@ func Parse(layout, value string) (time.Time, error) {
if err != nil {
return time.Time{}, err
}
return fromTime(t), nil
return FromTime(t), nil
}

// UnixNano returns the number of nanoseconds elapsed, based on the application start time.
// This value may be different from time.Now().UnixNano() in the presence of time resets.
// UnixNano returns the number of nanoseconds elapsed, based on the application
// start time.
func UnixNano() int64 {
return epochNano + int64(time.Since(epoch))
}

// UnixMicro returns the number of microseconds elapsed, based on the application start time.
// This value may be different from time.Now().UnixMicro() in the presence of time resets.
// UnixMicro returns the number of microseconds elapsed, based on the
// application start time.
func UnixMicro() int64 {
return UnixNano() / 1000
}
67 changes: 63 additions & 4 deletions utils/mono/mono_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,83 @@ import (

func TestMonoZero(t *testing.T) {
ts := time.Time{}
ts2 := fromTime(ts)
ts2 := FromTime(ts)
require.True(t, ts.IsZero())
require.True(t, ts2.IsZero())
require.True(t, ts.Equal(ts2))
require.Equal(t, ts.String(), ts2.String())
}

func TestMono(t *testing.T) {
t.Cleanup(resetClock) // restore
t.Cleanup(resetClock)

ts1 := time.Now()
ts2 := ts1.Add(time.Second)

ts1m := fromTime(ts1)
ts1m := FromTime(ts1)
// emulate a clock reset, +1h jump
// TODO: use synctest when we switch to Go 1.25
jumpClock(time.Hour)
ts2m := fromTime(ts2)
ts2m := FromTime(ts2)

require.Equal(t, ts2.Sub(ts1), ts2m.Sub(ts1m))
}

func TestNoGoMonotonicPayload(t *testing.T) {
t.Cleanup(resetClock)

now := Now()
fromTime := FromTime(time.Now())
fromUnix := Unix(123, 456)
fromParse, err := Parse(time.RFC3339Nano, "2026-03-05T12:34:56.789123456Z")
require.NoError(t, err)

require.Equal(t, now, now.Round(0))
require.Equal(t, fromTime, fromTime.Round(0))
require.Equal(t, fromUnix, fromUnix.Round(0))
require.Equal(t, fromParse, fromParse.Round(0))
}

func TestSerializationRoundTripComparableAcrossClockJump(t *testing.T) {
t.Cleanup(resetClock)

t1 := Now()
t2 := t1.Add(10 * time.Second)

t1Encoded := t1.UnixNano()
t2Encoded := t2.UnixNano()

// emulate a clock reset, +1h jump
jumpClock(time.Hour)

t1Decoded := Unix(0, t1Encoded)
t2Decoded := Unix(0, t2Encoded)

require.Equal(t, t2.Sub(t1), t2Decoded.Sub(t1Decoded))
}

func TestNowProgressesAcrossClockJump(t *testing.T) {
t.Cleanup(resetClock)

t1 := Now()
time.Sleep(time.Millisecond)

// emulate a clock reset, +1h jump
jumpClock(time.Hour)
t2 := Now()

require.Greater(t, t2.Sub(t1), time.Duration(0))
}

func BenchmarkTime(b *testing.B) {
b.Run("Now()", func(b *testing.B) {
for b.Loop() {
_ = Now()
}
})
b.Run("time.Now()", func(b *testing.B) {
for b.Loop() {
_ = time.Now()
}
})
}
Loading