Skip to content

Commit 412ef8b

Browse files
feat: Adjust timestamps for clock skew due to machine sleep (#1749)
1 parent 2c2024e commit 412ef8b

5 files changed

Lines changed: 545 additions & 48 deletions

File tree

src/common/timing/time-keeper.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ export class TimeKeeper {
4040
*/
4141
#ready = false
4242

43-
#reportedDrift = false
43+
/**
44+
* The total measured drift in milliseconds. Represents how much performance.now()
45+
* has fallen behind Date.now(), which is used to correct timestamp conversions.
46+
* @type {number}
47+
*/
48+
#measuredDrift = 0
4449

4550
constructor (sessionObj) {
4651
this.#session = sessionObj
@@ -49,7 +54,6 @@ export class TimeKeeper {
4954
}
5055

5156
#detectDrift () {
52-
if (this.#reportedDrift) return
5357
try {
5458
// Drift detection: measures if performance.now() and Date.now() have become desynchronized
5559
// This can happen when a machine sleeps and the performance timer freezes while Date continues
@@ -60,8 +64,13 @@ export class TimeKeeper {
6064
// Note: localTimeDiff (server time offset) is NOT part of drift - that's a legitimate offset
6165
const drift = (Date.now() - originTime) - performance.now()
6266
if (drift > 1000) {
63-
this.#reportedDrift = true
64-
handle(SUPPORTABILITY_METRIC_CHANNEL, ['Generic/TimeKeeper/ClockDrift/Detected', drift], undefined, FEATURE_NAMES.metrics, this.#session.agentRef.ee)
67+
// Check if this is new drift (increase of >1000ms from last measurement)
68+
const newDrift = drift - this.#measuredDrift
69+
if (newDrift > 1000) {
70+
// Update measured drift and report it
71+
this.#measuredDrift = drift
72+
if (this.#session) handle(SUPPORTABILITY_METRIC_CHANNEL, ['Generic/TimeKeeper/ClockDrift/Detected', drift], undefined, FEATURE_NAMES.metrics, this.#session.agentRef.ee)
73+
}
6574
}
6675
} catch (err) {
6776
// Silently ignore drift detection errors to avoid breaking normal operation
@@ -116,7 +125,8 @@ export class TimeKeeper {
116125
*/
117126
convertRelativeTimestamp (relativeTime) {
118127
this.#detectDrift()
119-
return originTime + relativeTime
128+
// Add measured drift to compensate for performance.now() falling behind
129+
return originTime + relativeTime + this.#measuredDrift
120130
}
121131

122132
/**
@@ -127,7 +137,8 @@ export class TimeKeeper {
127137
*/
128138
convertAbsoluteTimestamp (timestamp) {
129139
this.#detectDrift()
130-
return timestamp - originTime
140+
// Subtract measured drift since we're converting from absolute to relative
141+
return timestamp - originTime - this.#measuredDrift
131142
}
132143

133144
/**

tests/assets/clock-drift-simulation.html

Lines changed: 28 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,51 @@
77
<head>
88
<title>Clock Drift Simulation</title>
99
<script>
10-
// Save original performance.now
10+
// Save original performance.now and Date.now
1111
var origPerformanceNow = performance.now.bind(performance);
1212
var origDateNow = Date.now.bind(Date);
1313

1414
// Capture the initial values at page load
1515
var frozenPerformanceValue = null;
16-
var freezeStartTime = null;
16+
var mockDateOffset = 0;
1717

18-
// Function to simulate clock drift by freezing performance.now()
19-
window.simulateClockFreeze = function(durationMs) {
20-
if (!frozenPerformanceValue) {
21-
frozenPerformanceValue = origPerformanceNow();
22-
freezeStartTime = origDateNow();
18+
// Function to simulate clock drift by freezing performance.now() and advancing Date.now()
19+
window.simulateClockFreeze = function(driftDurationMs) {
20+
return new Promise(resolve => {
21+
// Freeze performance.now() at current value on first call, or keep it frozen
22+
if (frozenPerformanceValue === null) {
23+
frozenPerformanceValue = origPerformanceNow();
24+
25+
// Override performance.now to return frozen value
26+
performance.now = function() {
27+
return frozenPerformanceValue;
28+
};
29+
30+
console.log('Clock frozen at performance.now() =', frozenPerformanceValue);
31+
}
32+
33+
// Advance Date.now() by the drift amount (instant, no waiting)
34+
mockDateOffset += driftDurationMs;
2335

24-
// Override performance.now to return frozen value
25-
performance.now = function() {
26-
return frozenPerformanceValue;
36+
Date.now = function() {
37+
return origDateNow() + mockDateOffset;
2738
};
2839

29-
console.log('Clock frozen at performance.now() =', frozenPerformanceValue);
30-
console.log('Date will continue advancing, creating drift');
31-
}
32-
33-
return new Promise(resolve => {
34-
setTimeout(() => {
35-
const actualElapsed = origDateNow() - freezeStartTime;
36-
const drift = actualElapsed; // performance stayed frozen
37-
console.log('Drift created:', drift, 'ms');
38-
resolve(drift);
39-
}, durationMs);
40+
console.log('Date.now() advanced by', driftDurationMs, 'ms (total offset:', mockDateOffset, 'ms)');
41+
console.log('Drift created:', mockDateOffset, 'ms');
42+
43+
// Resolve immediately - no actual waiting
44+
resolve(mockDateOffset);
4045
});
4146
};
4247

4348
// Function to unfreeze (for cleanup between tests)
4449
window.unfreezeClocks = function() {
4550
if (frozenPerformanceValue !== null) {
4651
performance.now = origPerformanceNow;
52+
Date.now = origDateNow;
4753
frozenPerformanceValue = null;
48-
freezeStartTime = null;
54+
mockDateOffset = 0;
4955
console.log('Clocks unfrozen');
5056
}
5157
};

0 commit comments

Comments
 (0)