From e280d0bd35584516b49023812cfaa923ac2faa49 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sun, 9 Jun 2024 14:50:50 -0700 Subject: [PATCH] chore(clock): split wall and monotonic time (#31198) --- .../src/server/injected/clock.ts | 168 ++++++++---------- tests/library/clock.spec.ts | 23 +-- tests/page/page-clock.spec.ts | 4 +- 3 files changed, 86 insertions(+), 109 deletions(-) diff --git a/packages/playwright-core/src/server/injected/clock.ts b/packages/playwright-core/src/server/injected/clock.ts index af846b42c8..508d7a59e5 100644 --- a/packages/playwright-core/src/server/injected/clock.ts +++ b/packages/playwright-core/src/server/injected/clock.ts @@ -44,7 +44,7 @@ enum TimerType { type Timer = { type: TimerType; func: TimerHandler; - args: () => any[]; + args: any[]; delay: number; callAt: number; createdAt: number; @@ -58,10 +58,9 @@ interface Embedder { } export class ClockController { - readonly start: number; - private _now: number; + readonly timeOrigin: number; + private _now: { time: number, ticks: number, timeFrozen: boolean }; private _loopLimit: number; - private _adjustedSystemTime = 0; private _duringTick = false; private _timers = new Map(); private _uniqueTimerId = idCounterStart; @@ -70,8 +69,8 @@ export class ClockController { constructor(embedder: Embedder, startDate: Date | number | undefined, loopLimit: number = 1000) { const start = Math.floor(getEpoch(startDate)); - this.start = start; - this._now = start; + this.timeOrigin = start; + this._now = { time: start, ticks: 0, timeFrozen: false }; this._embedder = embedder; this._loopLimit = loopLimit; } @@ -82,11 +81,22 @@ export class ClockController { } 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 { - 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 { @@ -94,119 +104,72 @@ export class ClockController { throw new TypeError('Negative ticks are not supported'); const ms = Math.floor(msFloat); - let tickTo = this._now + ms; - let tickFrom = this._now; - let previous = this._now; + const tickTo = this._now.ticks + ms; + let tickFrom = this._now.ticks; + let previous = this._now.ticks; let firstException: Error | undefined; - this._duringTick = true; - - // perform each timer in the requested range let timer = this._firstTimerInRange(tickFrom, tickTo); while (timer && tickFrom <= tickTo) { tickFrom = timer.callAt; - this._now = timer.callAt; - const oldNow = this._now; - try { - this._callTimer(timer); - await new Promise(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; - } - + const error = await this._callTimer(timer).catch(e => e); + firstException = firstException || error; timer = this._firstTimerInRange(previous, tickTo); previous = tickFrom; } - this._duringTick = false; - this._now = tickTo; + this._advanceNow(tickTo); if (firstException) throw firstException; - return this._now; + return this._now.ticks; } async recordTick(tickValue: string | number) { const msFloat = parseTime(tickValue); - this._now += msFloat; + this._advanceNow(this._now.ticks + msFloat); } async tick(tickValue: string | number): Promise { return await this._doTick(parseTime(tickValue)); } - async next() { + async next(): Promise { const timer = this._firstTimer(); if (!timer) - return this._now; - - let err: Error | undefined; - this._duringTick = true; - this._now = timer.callAt; - try { - this._callTimer(timer); - await new Promise(f => this._embedder.postTask(f)); - } catch (e) { - err = e; - } - this._duringTick = false; - - if (err) - throw err; - return this._now; + return this._now.ticks; + await this._callTimer(timer); + return this._now.ticks; } - async runToFrame() { + async runToFrame(): Promise { return this.tick(this.getTimeToNextFrame()); } - async runAll() { + async runAll(): Promise { for (let i = 0; i < this._loopLimit; i++) { const numTimers = this._timers.size; if (numTimers === 0) - return this._now; + return this._now.ticks; await this.next(); } const excessJob = this._firstTimer(); if (!excessJob) - return; + return this._now.ticks; throw this._getInfiniteLoopError(excessJob); } - async runToLast() { + async runToLast(): Promise { const timer = this._lastTimer(); if (!timer) - return this._now; - return await this.tick(timer.callAt - this._now); + return this._now.ticks; + return await this.tick(timer.callAt - this._now.ticks); } reset() { this._timers.clear(); - this._now = this.start; - } - - 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; - } + this._now = { time: this.timeOrigin, ticks: 0, timeFrozen: false }; } async jump(tickValue: string | number): Promise { @@ -214,13 +177,13 @@ export class ClockController { const ms = Math.floor(msFloat); for (const timer of this._timers.values()) { - if (this._now + ms > timer.callAt) - timer.callAt = this._now + ms; + if (this._now.ticks + ms > timer.callAt) + timer.callAt = this._now.ticks + 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) throw new Error('Callback must be provided to timer calls'); @@ -233,10 +196,10 @@ export class ClockController { const timer: Timer = { type: options.type, func: options.func, - args: options.args || (() => []), + args: options.args || [], delay, - callAt: this._now + (delay || (this._duringTick ? 1 : 0)), - createdAt: this._now, + callAt: this._now.ticks + (delay || (this._duringTick ? 1 : 0)), + createdAt: this._now.ticks, id: this._uniqueTimerId++, error: new Error(), }; @@ -278,12 +241,32 @@ export class ClockController { return lastTimer; } - private _callTimer(timer: Timer) { + private async _callTimer(timer: Timer) { + this._advanceNow(timer.callAt); + if (timer.type === TimerType.Interval) this._timers.get(timer.id)!.callAt += timer.delay; else 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(f => this._embedder.postTask(f)); + } finally { + this._duringTick = false; + } } private _getInfiniteLoopError(job: Timer) { @@ -336,7 +319,7 @@ export class ClockController { } getTimeToNextFrame() { - return 16 - ((this._now - this.start) % 16); + return 16 - this._now.ticks % 16; } clearTimer(timerId: number, type: TimerType) { @@ -375,7 +358,7 @@ export class ClockController { advanceAutomatically(advanceTimeDelta: number = 20): () => void { return this._embedder.postTaskPeriodically( - () => this.tick(advanceTimeDelta!), + () => this._doTick(advanceTimeDelta!), advanceTimeDelta, ); } @@ -556,13 +539,6 @@ function compareTimers(a: Timer, b: Timer) { // 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 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({ type: TimerType.Timeout, func, - args: () => args, + args, delay }); }, @@ -618,7 +594,7 @@ function createApi(clock: ClockController, originals: ClockMethods): ClockMethod return clock.addTimer({ type: TimerType.Interval, func, - args: () => args, + args, delay, }); }, @@ -631,7 +607,6 @@ function createApi(clock: ClockController, originals: ClockMethods): ClockMethod type: TimerType.AnimationFrame, func: callback, delay: clock.getTimeToNextFrame(), - args: () => [clock.performanceNow()], }); }, cancelAnimationFrame: (timerId: number): void => { @@ -646,7 +621,6 @@ function createApi(clock: ClockController, originals: ClockMethods): ClockMethod return clock.addTimer({ type: TimerType.IdleCallback, func: callback, - args: () => [], delay: options?.timeout ? Math.min(options?.timeout, timeToNextIdlePeriod) : timeToNextIdlePeriod, }); }, @@ -670,7 +644,7 @@ function getClearHandler(type: TimerType) { function fakePerformance(clock: ClockController, performance: Performance): Performance { const result: any = { now: () => clock.performanceNow(), - timeOrigin: clock.start, + timeOrigin: clock.timeOrigin, }; // eslint-disable-next-line no-proto for (const key of Object.keys((performance as any).__proto__)) { diff --git a/tests/library/clock.spec.ts b/tests/library/clock.spec.ts index cf27d2744b..554b49d1a7 100644 --- a/tests/library/clock.spec.ts +++ b/tests/library/clock.spec.ts @@ -156,7 +156,7 @@ it.describe('setTimeout', () => { const stub = createStub(); clock.setTimeout(stub, 5000); await clock.tick(1000); - clock.setSystemTime(new clock.Date().getTime() + 1000); + clock.setTime(new clock.Date().getTime() + 1000); await clock.tick(3990); expect(stub.callCount).toBe(0); await clock.tick(20); @@ -167,7 +167,7 @@ it.describe('setTimeout', () => { const stub = createStub(); clock.setTimeout(stub, 5000); await clock.tick(1000); - clock.setSystemTime(new clock.Date().getTime() - 1000); + clock.setTime(new clock.Date().getTime() - 1000); await clock.tick(3990); expect(stub.callCount).toBe(0); await clock.tick(20); @@ -502,7 +502,7 @@ it.describe('tick', () => { it('is not influenced by forward system clock changes', async ({ clock }) => { const callback = () => { - clock.setSystemTime(new clock.Date().getTime() + 1000); + clock.setTime(new clock.Date().getTime() + 1000); }; const stub = createStub(); clock.setTimeout(callback, 1000); @@ -515,7 +515,7 @@ it.describe('tick', () => { it('is not influenced by forward system clock changes 2', async ({ clock }) => { const callback = () => { - clock.setSystemTime(new clock.Date().getTime() - 1000); + clock.setTime(new clock.Date().getTime() - 1000); }; const stub = createStub(); 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 }) => { const callback = () => { - clock.setSystemTime(new clock.Date().getTime() + 1000); + clock.setTime(new clock.Date().getTime() + 1000); throw new Error(); }; 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 }) => { const callback = () => { - clock.setSystemTime(new clock.Date().getTime() - 1000); + clock.setTime(new clock.Date().getTime() - 1000); throw new Error(); }; const stub = createStub(); @@ -653,7 +653,7 @@ it.describe('tick', () => { it('is not influenced by forward system clock changes in promises', async ({ clock }) => { const callback = () => { void Promise.resolve().then(() => { - clock.setSystemTime(new clock.Date().getTime() + 1000); + clock.setTime(new clock.Date().getTime() + 1000); }); }; const stub = createStub(); @@ -1363,7 +1363,7 @@ it.describe('setInterval', () => { clock.setInterval(stub, 10); await clock.tick(11); expect(stub.callCount).toBe(1); - clock.setSystemTime(new clock.Date().getTime() + 1000); + clock.setTime(new clock.Date().getTime() + 1000); await clock.tick(8); expect(stub.callCount).toBe(1); await clock.tick(3); @@ -1374,7 +1374,7 @@ it.describe('setInterval', () => { const stub = createStub(); clock.setInterval(stub, 10); await clock.tick(5); - clock.setSystemTime(new clock.Date().getTime() - 1000); + clock.setTime(new clock.Date().getTime() - 1000); await clock.tick(6); expect(stub.callCount).toBe(1); await clock.tick(10); @@ -1465,7 +1465,7 @@ it.describe('date', () => { it('listens to system clock changes', async ({ clock }) => { const date1 = new clock.Date(); - clock.setSystemTime(date1.getTime() + 1000); + clock.setTime(date1.getTime() + 1000); const date2 = new clock.Date(); expect(date2.getTime() - date1.getTime()).toBe(1000); }); @@ -2056,6 +2056,9 @@ it.describe('requestIdleCallback', () => { clock.requestIdleCallback(stub); await clock.tick(1000); 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 }) => { diff --git a/tests/page/page-clock.spec.ts b/tests/page/page-clock.spec.ts index 649ab250bd..4a6ca00896 100644 --- a/tests/page/page-clock.spec.ts +++ b/tests/page/page-clock.spec.ts @@ -587,8 +587,8 @@ it.describe('popup', () => { it('should tick before popup', async ({ page, browserName }) => { const now = new Date('2015-09-25'); await page.clock.installFakeTimers(now); - const newNow = await page.clock.runFor(1000); - expect(newNow).toBe(now.getTime() + 1000); + const ticks = await page.clock.runFor(1000); + expect(ticks).toBe(1000); const [popup] = await Promise.all([ page.waitForEvent('popup'),