chore(clock): split wall and monotonic time (#31198)

This commit is contained in:
Pavel Feldman 2024-06-09 14:50:50 -07:00 committed by GitHub
parent 43d6d012d4
commit e280d0bd35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 86 additions and 109 deletions

View file

@ -44,7 +44,7 @@ enum TimerType {
type Timer = { type Timer = {
type: TimerType; type: TimerType;
func: TimerHandler; func: TimerHandler;
args: () => any[]; args: any[];
delay: number; delay: number;
callAt: number; callAt: number;
createdAt: number; createdAt: number;
@ -58,10 +58,9 @@ interface Embedder {
} }
export class ClockController { export class ClockController {
readonly start: number; readonly timeOrigin: number;
private _now: number; private _now: { time: number, ticks: number, timeFrozen: boolean };
private _loopLimit: number; private _loopLimit: number;
private _adjustedSystemTime = 0;
private _duringTick = false; private _duringTick = false;
private _timers = new Map<number, Timer>(); private _timers = new Map<number, Timer>();
private _uniqueTimerId = idCounterStart; private _uniqueTimerId = idCounterStart;
@ -70,8 +69,8 @@ export class ClockController {
constructor(embedder: Embedder, startDate: Date | number | undefined, loopLimit: number = 1000) { constructor(embedder: Embedder, startDate: Date | number | undefined, loopLimit: number = 1000) {
const start = Math.floor(getEpoch(startDate)); const start = Math.floor(getEpoch(startDate));
this.start = start; this.timeOrigin = start;
this._now = start; this._now = { time: start, ticks: 0, timeFrozen: false };
this._embedder = embedder; this._embedder = embedder;
this._loopLimit = loopLimit; this._loopLimit = loopLimit;
} }
@ -82,11 +81,22 @@ export class ClockController {
} }
now(): number { now(): number {
return this._now; return this._now.time;
}
setTime(now: Date | number, options: { freeze?: boolean } = {}) {
this._now.time = getEpoch(now);
this._now.timeFrozen = !!options.freeze;
} }
performanceNow(): DOMHighResTimeStamp { performanceNow(): DOMHighResTimeStamp {
return this._now - this._adjustedSystemTime - this.start; return this._now.ticks;
}
private _advanceNow(toTicks: number) {
if (!this._now.timeFrozen)
this._now.time += toTicks - this._now.ticks;
this._now.ticks = toTicks;
} }
private async _doTick(msFloat: number): Promise<number> { private async _doTick(msFloat: number): Promise<number> {
@ -94,119 +104,72 @@ export class ClockController {
throw new TypeError('Negative ticks are not supported'); throw new TypeError('Negative ticks are not supported');
const ms = Math.floor(msFloat); const ms = Math.floor(msFloat);
let tickTo = this._now + ms; const tickTo = this._now.ticks + ms;
let tickFrom = this._now; let tickFrom = this._now.ticks;
let previous = this._now; let previous = this._now.ticks;
let firstException: Error | undefined; let firstException: Error | undefined;
this._duringTick = true;
// perform each timer in the requested range
let timer = this._firstTimerInRange(tickFrom, tickTo); let timer = this._firstTimerInRange(tickFrom, tickTo);
while (timer && tickFrom <= tickTo) { while (timer && tickFrom <= tickTo) {
tickFrom = timer.callAt; tickFrom = timer.callAt;
this._now = timer.callAt; const error = await this._callTimer(timer).catch(e => e);
const oldNow = this._now; firstException = firstException || error;
try {
this._callTimer(timer);
await new Promise<void>(f => this._embedder.postTask(f));
} catch (e) {
firstException = firstException || e;
}
// compensate for any setSystemTime() call during timer callback
if (oldNow !== this._now) {
tickFrom += this._now - oldNow;
tickTo += this._now - oldNow;
previous += this._now - oldNow;
}
timer = this._firstTimerInRange(previous, tickTo); timer = this._firstTimerInRange(previous, tickTo);
previous = tickFrom; previous = tickFrom;
} }
this._duringTick = false; this._advanceNow(tickTo);
this._now = tickTo;
if (firstException) if (firstException)
throw firstException; throw firstException;
return this._now; return this._now.ticks;
} }
async recordTick(tickValue: string | number) { async recordTick(tickValue: string | number) {
const msFloat = parseTime(tickValue); const msFloat = parseTime(tickValue);
this._now += msFloat; this._advanceNow(this._now.ticks + msFloat);
} }
async tick(tickValue: string | number): Promise<number> { async tick(tickValue: string | number): Promise<number> {
return await this._doTick(parseTime(tickValue)); return await this._doTick(parseTime(tickValue));
} }
async next() { async next(): Promise<number> {
const timer = this._firstTimer(); const timer = this._firstTimer();
if (!timer) if (!timer)
return this._now; return this._now.ticks;
await this._callTimer(timer);
let err: Error | undefined; return this._now.ticks;
this._duringTick = true;
this._now = timer.callAt;
try {
this._callTimer(timer);
await new Promise<void>(f => this._embedder.postTask(f));
} catch (e) {
err = e;
}
this._duringTick = false;
if (err)
throw err;
return this._now;
} }
async runToFrame() { async runToFrame(): Promise<number> {
return this.tick(this.getTimeToNextFrame()); return this.tick(this.getTimeToNextFrame());
} }
async runAll() { async runAll(): Promise<number> {
for (let i = 0; i < this._loopLimit; i++) { for (let i = 0; i < this._loopLimit; i++) {
const numTimers = this._timers.size; const numTimers = this._timers.size;
if (numTimers === 0) if (numTimers === 0)
return this._now; return this._now.ticks;
await this.next(); await this.next();
} }
const excessJob = this._firstTimer(); const excessJob = this._firstTimer();
if (!excessJob) if (!excessJob)
return; return this._now.ticks;
throw this._getInfiniteLoopError(excessJob); throw this._getInfiniteLoopError(excessJob);
} }
async runToLast() { async runToLast(): Promise<number> {
const timer = this._lastTimer(); const timer = this._lastTimer();
if (!timer) if (!timer)
return this._now; return this._now.ticks;
return await this.tick(timer.callAt - this._now); return await this.tick(timer.callAt - this._now.ticks);
} }
reset() { reset() {
this._timers.clear(); this._timers.clear();
this._now = this.start; this._now = { time: this.timeOrigin, ticks: 0, timeFrozen: false };
}
setSystemTime(systemTime: Date | number) {
// determine time difference
const newNow = getEpoch(systemTime);
const difference = newNow - this._now;
this._adjustedSystemTime = this._adjustedSystemTime + difference;
// update 'system clock'
this._now = newNow;
// update timers and intervals to keep them stable
for (const timer of this._timers.values()) {
timer.createdAt += difference;
timer.callAt += difference;
}
} }
async jump(tickValue: string | number): Promise<number> { async jump(tickValue: string | number): Promise<number> {
@ -214,13 +177,13 @@ export class ClockController {
const ms = Math.floor(msFloat); const ms = Math.floor(msFloat);
for (const timer of this._timers.values()) { for (const timer of this._timers.values()) {
if (this._now + ms > timer.callAt) if (this._now.ticks + ms > timer.callAt)
timer.callAt = this._now + ms; timer.callAt = this._now.ticks + ms;
} }
return await this.tick(ms); return await this.tick(ms);
} }
addTimer(options: { func: TimerHandler, type: TimerType, delay?: number | string, args?: () => any[] }): number { addTimer(options: { func: TimerHandler, type: TimerType, delay?: number | string, args?: any[] }): number {
if (options.func === undefined) if (options.func === undefined)
throw new Error('Callback must be provided to timer calls'); throw new Error('Callback must be provided to timer calls');
@ -233,10 +196,10 @@ export class ClockController {
const timer: Timer = { const timer: Timer = {
type: options.type, type: options.type,
func: options.func, func: options.func,
args: options.args || (() => []), args: options.args || [],
delay, delay,
callAt: this._now + (delay || (this._duringTick ? 1 : 0)), callAt: this._now.ticks + (delay || (this._duringTick ? 1 : 0)),
createdAt: this._now, createdAt: this._now.ticks,
id: this._uniqueTimerId++, id: this._uniqueTimerId++,
error: new Error(), error: new Error(),
}; };
@ -278,12 +241,32 @@ export class ClockController {
return lastTimer; return lastTimer;
} }
private _callTimer(timer: Timer) { private async _callTimer(timer: Timer) {
this._advanceNow(timer.callAt);
if (timer.type === TimerType.Interval) if (timer.type === TimerType.Interval)
this._timers.get(timer.id)!.callAt += timer.delay; this._timers.get(timer.id)!.callAt += timer.delay;
else else
this._timers.delete(timer.id); this._timers.delete(timer.id);
callFunction(timer.func, timer.args());
this._duringTick = true;
try {
if (typeof timer.func !== 'function') {
(() => { eval(timer.func); })();
return;
}
let args = timer.args;
if (timer.type === TimerType.AnimationFrame)
args = [this._now.ticks];
else if (timer.type === TimerType.IdleCallback)
args = [{ didTimeout: false, timeRemaining: () => 0 }];
timer.func.apply(null, args);
await new Promise<void>(f => this._embedder.postTask(f));
} finally {
this._duringTick = false;
}
} }
private _getInfiniteLoopError(job: Timer) { private _getInfiniteLoopError(job: Timer) {
@ -336,7 +319,7 @@ export class ClockController {
} }
getTimeToNextFrame() { getTimeToNextFrame() {
return 16 - ((this._now - this.start) % 16); return 16 - this._now.ticks % 16;
} }
clearTimer(timerId: number, type: TimerType) { clearTimer(timerId: number, type: TimerType) {
@ -375,7 +358,7 @@ export class ClockController {
advanceAutomatically(advanceTimeDelta: number = 20): () => void { advanceAutomatically(advanceTimeDelta: number = 20): () => void {
return this._embedder.postTaskPeriodically( return this._embedder.postTaskPeriodically(
() => this.tick(advanceTimeDelta!), () => this._doTick(advanceTimeDelta!),
advanceTimeDelta, advanceTimeDelta,
); );
} }
@ -556,13 +539,6 @@ function compareTimers(a: Timer, b: Timer) {
// As timer ids are unique, no fallback `0` is necessary // As timer ids are unique, no fallback `0` is necessary
} }
function callFunction(func: TimerHandler, args: any[]) {
if (typeof func === 'function')
func.apply(null, args);
else
(() => { eval(func); })();
}
const maxTimeout = Math.pow(2, 31) - 1; // see https://heycam.github.io/webidl/#abstract-opdef-converttoint const maxTimeout = Math.pow(2, 31) - 1; // see https://heycam.github.io/webidl/#abstract-opdef-converttoint
const idCounterStart = 1e12; // arbitrarily large number to avoid collisions with native timer IDs const idCounterStart = 1e12; // arbitrarily large number to avoid collisions with native timer IDs
@ -605,7 +581,7 @@ function createApi(clock: ClockController, originals: ClockMethods): ClockMethod
return clock.addTimer({ return clock.addTimer({
type: TimerType.Timeout, type: TimerType.Timeout,
func, func,
args: () => args, args,
delay delay
}); });
}, },
@ -618,7 +594,7 @@ function createApi(clock: ClockController, originals: ClockMethods): ClockMethod
return clock.addTimer({ return clock.addTimer({
type: TimerType.Interval, type: TimerType.Interval,
func, func,
args: () => args, args,
delay, delay,
}); });
}, },
@ -631,7 +607,6 @@ function createApi(clock: ClockController, originals: ClockMethods): ClockMethod
type: TimerType.AnimationFrame, type: TimerType.AnimationFrame,
func: callback, func: callback,
delay: clock.getTimeToNextFrame(), delay: clock.getTimeToNextFrame(),
args: () => [clock.performanceNow()],
}); });
}, },
cancelAnimationFrame: (timerId: number): void => { cancelAnimationFrame: (timerId: number): void => {
@ -646,7 +621,6 @@ function createApi(clock: ClockController, originals: ClockMethods): ClockMethod
return clock.addTimer({ return clock.addTimer({
type: TimerType.IdleCallback, type: TimerType.IdleCallback,
func: callback, func: callback,
args: () => [],
delay: options?.timeout ? Math.min(options?.timeout, timeToNextIdlePeriod) : timeToNextIdlePeriod, delay: options?.timeout ? Math.min(options?.timeout, timeToNextIdlePeriod) : timeToNextIdlePeriod,
}); });
}, },
@ -670,7 +644,7 @@ function getClearHandler(type: TimerType) {
function fakePerformance(clock: ClockController, performance: Performance): Performance { function fakePerformance(clock: ClockController, performance: Performance): Performance {
const result: any = { const result: any = {
now: () => clock.performanceNow(), now: () => clock.performanceNow(),
timeOrigin: clock.start, timeOrigin: clock.timeOrigin,
}; };
// eslint-disable-next-line no-proto // eslint-disable-next-line no-proto
for (const key of Object.keys((performance as any).__proto__)) { for (const key of Object.keys((performance as any).__proto__)) {

View file

@ -156,7 +156,7 @@ it.describe('setTimeout', () => {
const stub = createStub(); const stub = createStub();
clock.setTimeout(stub, 5000); clock.setTimeout(stub, 5000);
await clock.tick(1000); await clock.tick(1000);
clock.setSystemTime(new clock.Date().getTime() + 1000); clock.setTime(new clock.Date().getTime() + 1000);
await clock.tick(3990); await clock.tick(3990);
expect(stub.callCount).toBe(0); expect(stub.callCount).toBe(0);
await clock.tick(20); await clock.tick(20);
@ -167,7 +167,7 @@ it.describe('setTimeout', () => {
const stub = createStub(); const stub = createStub();
clock.setTimeout(stub, 5000); clock.setTimeout(stub, 5000);
await clock.tick(1000); await clock.tick(1000);
clock.setSystemTime(new clock.Date().getTime() - 1000); clock.setTime(new clock.Date().getTime() - 1000);
await clock.tick(3990); await clock.tick(3990);
expect(stub.callCount).toBe(0); expect(stub.callCount).toBe(0);
await clock.tick(20); await clock.tick(20);
@ -502,7 +502,7 @@ it.describe('tick', () => {
it('is not influenced by forward system clock changes', async ({ clock }) => { it('is not influenced by forward system clock changes', async ({ clock }) => {
const callback = () => { const callback = () => {
clock.setSystemTime(new clock.Date().getTime() + 1000); clock.setTime(new clock.Date().getTime() + 1000);
}; };
const stub = createStub(); const stub = createStub();
clock.setTimeout(callback, 1000); clock.setTimeout(callback, 1000);
@ -515,7 +515,7 @@ it.describe('tick', () => {
it('is not influenced by forward system clock changes 2', async ({ clock }) => { it('is not influenced by forward system clock changes 2', async ({ clock }) => {
const callback = () => { const callback = () => {
clock.setSystemTime(new clock.Date().getTime() - 1000); clock.setTime(new clock.Date().getTime() - 1000);
}; };
const stub = createStub(); const stub = createStub();
clock.setTimeout(callback, 1000); clock.setTimeout(callback, 1000);
@ -528,7 +528,7 @@ it.describe('tick', () => {
it('is not influenced by forward system clock changes when an error is thrown', async ({ clock }) => { it('is not influenced by forward system clock changes when an error is thrown', async ({ clock }) => {
const callback = () => { const callback = () => {
clock.setSystemTime(new clock.Date().getTime() + 1000); clock.setTime(new clock.Date().getTime() + 1000);
throw new Error(); throw new Error();
}; };
const stub = createStub(); const stub = createStub();
@ -544,7 +544,7 @@ it.describe('tick', () => {
it('is not influenced by forward system clock changes when an error is thrown 2', async ({ clock }) => { it('is not influenced by forward system clock changes when an error is thrown 2', async ({ clock }) => {
const callback = () => { const callback = () => {
clock.setSystemTime(new clock.Date().getTime() - 1000); clock.setTime(new clock.Date().getTime() - 1000);
throw new Error(); throw new Error();
}; };
const stub = createStub(); const stub = createStub();
@ -653,7 +653,7 @@ it.describe('tick', () => {
it('is not influenced by forward system clock changes in promises', async ({ clock }) => { it('is not influenced by forward system clock changes in promises', async ({ clock }) => {
const callback = () => { const callback = () => {
void Promise.resolve().then(() => { void Promise.resolve().then(() => {
clock.setSystemTime(new clock.Date().getTime() + 1000); clock.setTime(new clock.Date().getTime() + 1000);
}); });
}; };
const stub = createStub(); const stub = createStub();
@ -1363,7 +1363,7 @@ it.describe('setInterval', () => {
clock.setInterval(stub, 10); clock.setInterval(stub, 10);
await clock.tick(11); await clock.tick(11);
expect(stub.callCount).toBe(1); expect(stub.callCount).toBe(1);
clock.setSystemTime(new clock.Date().getTime() + 1000); clock.setTime(new clock.Date().getTime() + 1000);
await clock.tick(8); await clock.tick(8);
expect(stub.callCount).toBe(1); expect(stub.callCount).toBe(1);
await clock.tick(3); await clock.tick(3);
@ -1374,7 +1374,7 @@ it.describe('setInterval', () => {
const stub = createStub(); const stub = createStub();
clock.setInterval(stub, 10); clock.setInterval(stub, 10);
await clock.tick(5); await clock.tick(5);
clock.setSystemTime(new clock.Date().getTime() - 1000); clock.setTime(new clock.Date().getTime() - 1000);
await clock.tick(6); await clock.tick(6);
expect(stub.callCount).toBe(1); expect(stub.callCount).toBe(1);
await clock.tick(10); await clock.tick(10);
@ -1465,7 +1465,7 @@ it.describe('date', () => {
it('listens to system clock changes', async ({ clock }) => { it('listens to system clock changes', async ({ clock }) => {
const date1 = new clock.Date(); const date1 = new clock.Date();
clock.setSystemTime(date1.getTime() + 1000); clock.setTime(date1.getTime() + 1000);
const date2 = new clock.Date(); const date2 = new clock.Date();
expect(date2.getTime() - date1.getTime()).toBe(1000); expect(date2.getTime() - date1.getTime()).toBe(1000);
}); });
@ -2056,6 +2056,9 @@ it.describe('requestIdleCallback', () => {
clock.requestIdleCallback(stub); clock.requestIdleCallback(stub);
await clock.tick(1000); await clock.tick(1000);
expect(stub.called).toBeTruthy(); expect(stub.called).toBeTruthy();
const idleCallbackArg = stub.firstCall.args[0];
expect(idleCallbackArg.didTimeout).toBeFalsy();
expect(idleCallbackArg.timeRemaining()).toBe(0);
}); });
it('runs no later than timeout option even if there are any timers', async ({ clock }) => { it('runs no later than timeout option even if there are any timers', async ({ clock }) => {

View file

@ -587,8 +587,8 @@ it.describe('popup', () => {
it('should tick before popup', async ({ page, browserName }) => { it('should tick before popup', async ({ page, browserName }) => {
const now = new Date('2015-09-25'); const now = new Date('2015-09-25');
await page.clock.installFakeTimers(now); await page.clock.installFakeTimers(now);
const newNow = await page.clock.runFor(1000); const ticks = await page.clock.runFor(1000);
expect(newNow).toBe(now.getTime() + 1000); expect(ticks).toBe(1000);
const [popup] = await Promise.all([ const [popup] = await Promise.all([
page.waitForEvent('popup'), page.waitForEvent('popup'),