UNPKG

chrome-devtools-frontend

Version:
307 lines (262 loc) • 9.84 kB
// Copyright 2021 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. export interface CoordinatorCallback<T> { (): T|PromiseLike<T>; } class WorkItem<T> { readonly promise: Promise<T>; readonly trigger: () => void; readonly cancel: (e: Error) => void; readonly label: string; handler: CoordinatorCallback<T>; constructor(label: string, handler: CoordinatorCallback<T>) { const {promise, resolve, reject} = Promise.withResolvers<void>(); this.promise = promise.then(() => this.handler()); this.trigger = resolve; this.cancel = reject; this.label = label; this.handler = handler; } } export interface LoggingRecord { time: number; value: string; } const enum ACTION { READ = 'read', WRITE = 'write', } export class RenderCoordinatorQueueEmptyEvent extends Event { static readonly eventName = 'renderqueueempty'; constructor() { super(RenderCoordinatorQueueEmptyEvent.eventName); } } export class RenderCoordinatorNewFrameEvent extends Event { static readonly eventName = 'newframe'; constructor() { super(RenderCoordinatorNewFrameEvent.eventName); } } export interface LoggingOptions { // If true, only log activity with an explicit label. // This does not affect logging frames or queue empty events. // Defaults to false. onlyNamed?: boolean; // Configurable log storage limit, defaults to 100. storageLimit?: number; } let loggingEnabled: null|LoggingOptions = null; const loggingRecords: LoggingRecord[] = []; export function setLoggingEnabled(enabled: false): void; export function setLoggingEnabled(enabled: true, options?: LoggingOptions): void; export function setLoggingEnabled(enabled: boolean, options: LoggingOptions = {}): void { if (enabled) { loggingEnabled = { onlyNamed: options.onlyNamed, storageLimit: options.storageLimit, }; } else { loggingEnabled = null; loggingRecords.length = 0; } } const UNNAMED_READ = 'Unnamed read'; const UNNAMED_WRITE = 'Unnamed write'; const UNNAMED_SCROLL = 'Unnamed scroll'; const DEADLOCK_TIMEOUT = 1500; // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).__getRenderCoordinatorPendingFrames = function(): number { return hasPendingWork() ? 1 : 0; }; let pendingReaders: Array<WorkItem<unknown>> = []; let pendingWriters: Array<WorkItem<unknown>> = []; let scheduledWorkId = 0; export function hasPendingWork(): boolean { return pendingReaders.length + pendingWriters.length !== 0; } export function done(options?: {waitForWork: boolean}): Promise<void> { if (!hasPendingWork() && !options?.waitForWork) { logIfEnabled('[Queue empty]'); return Promise.resolve(); } return new Promise( resolve => window.addEventListener(RenderCoordinatorQueueEmptyEvent.eventName, () => resolve(), {once: true})); } /** * Schedules a 'read' job which is being executed within an animation frame * before all 'write' jobs. If multiple jobs are scheduled with the same * non-empty label, only the latest callback would be executed. Such * invocations would return the same promise that will resolve to the value of * the latest callback. */ export async function read<T>(callback: CoordinatorCallback<T>): Promise<T>; export async function read<T>(label: string, callback: CoordinatorCallback<T>): Promise<T>; export async function read<T>( labelOrCallback: CoordinatorCallback<T>|string, callback?: CoordinatorCallback<T>): Promise<T> { if (typeof labelOrCallback === 'string') { if (!callback) { throw new Error('Read called with label but no callback'); } return await enqueueHandler(ACTION.READ, labelOrCallback, callback); } return await enqueueHandler(ACTION.READ, UNNAMED_READ, labelOrCallback); } /** * Schedules a 'write' job which is being executed within an animation frame * after all 'read' and 'scroll' jobs. If multiple jobs are scheduled with * the same non-empty label, only the latest callback would be executed. Such * invocations would return the same promise that will resolve when the latest callback is run. */ export async function write<T>(callback: CoordinatorCallback<T>): Promise<T>; export async function write<T>(label: string, callback: CoordinatorCallback<T>): Promise<T>; export async function write<T>( labelOrCallback: CoordinatorCallback<T>|string, callback?: CoordinatorCallback<T>): Promise<T> { if (typeof labelOrCallback === 'string') { if (!callback) { throw new Error('Write called with label but no callback'); } return await enqueueHandler(ACTION.WRITE, labelOrCallback, callback); } return await enqueueHandler(ACTION.WRITE, UNNAMED_WRITE, labelOrCallback); } export function takeLoggingRecords(): LoggingRecord[] { const logs = [...loggingRecords]; loggingRecords.length = 0; return logs; } /** * We offer a convenience function for scroll-based activity, but often triggering a scroll * requires a layout pass, thus it is better handled as a read activity, i.e. we wait until * the layout-triggering work has been completed then it should be possible to scroll without * first forcing layout. If multiple jobs are scheduled with the same non-empty label, only * the latest callback would be executed. Such invocations would return the same promise that * will resolve when the latest callback is run. */ export async function scroll<T>(callback: CoordinatorCallback<T>): Promise<T>; export async function scroll<T>(label: string, callback: CoordinatorCallback<T>): Promise<T>; export async function scroll<T>( labelOrCallback: CoordinatorCallback<T>|string, callback?: CoordinatorCallback<T>): Promise<T> { if (typeof labelOrCallback === 'string') { if (!callback) { throw new Error('Scroll called with label but no callback'); } return await enqueueHandler(ACTION.READ, labelOrCallback, callback); } return await enqueueHandler(ACTION.READ, UNNAMED_SCROLL, labelOrCallback); } function enqueueHandler<T>(action: ACTION, label: string, callback: CoordinatorCallback<T>): Promise<T> { const hasName = ![UNNAMED_READ, UNNAMED_WRITE, UNNAMED_SCROLL].includes(label); label = `${action === ACTION.READ ? '[Read]' : '[Write]'}: ${label}`; let workItems = null; switch (action) { case ACTION.READ: workItems = pendingReaders; break; case ACTION.WRITE: workItems = pendingWriters; break; default: throw new Error(`Unknown action: ${action}`); } let workItem = hasName ? workItems.find(w => w.label === label) as WorkItem<T>| undefined : undefined; if (!workItem) { workItem = new WorkItem<T>(label, callback); workItems.push(workItem); } else { // We are always using the latest handler, so that we don't end up with a // stale results. We are reusing the promise to avoid blocking the first invocation, when // it is being "overridden" by another one. workItem.handler = callback; } scheduleWork(); return workItem.promise; } function scheduleWork(): void { if (scheduledWorkId !== 0) { return; } scheduledWorkId = requestAnimationFrame(async () => { if (!hasPendingWork()) { // All pending work has completed. // The events dispatched below are mostly for testing contexts. window.dispatchEvent(new RenderCoordinatorQueueEmptyEvent()); logIfEnabled('[Queue empty]'); scheduledWorkId = 0; return; } window.dispatchEvent(new RenderCoordinatorNewFrameEvent()); logIfEnabled('[New frame]'); const readers = pendingReaders; pendingReaders = []; const writers = pendingWriters; pendingWriters = []; // Start with all the readers and allow them // to proceed together. for (const reader of readers) { logIfEnabled(reader.label); reader.trigger(); } // Wait for them all to be done. try { await Promise.race([ Promise.all(readers.map(r => r.promise)), new Promise((_, reject) => { window.setTimeout( () => reject(new Error(`Readers took over ${DEADLOCK_TIMEOUT}ms. Possible deadlock?`)), DEADLOCK_TIMEOUT); }), ]); } catch (err) { rejectAll(readers, err); } // Next do all the writers as a block. for (const writer of writers) { logIfEnabled(writer.label); writer.trigger(); } // And wait for them to be done, too. try { await Promise.race([ Promise.all(writers.map(w => w.promise)), new Promise((_, reject) => { window.setTimeout( () => reject(new Error(`Writers took over ${DEADLOCK_TIMEOUT}ms. Possible deadlock?`)), DEADLOCK_TIMEOUT); }), ]); } catch (err) { rejectAll(writers, err); } // Since there may have been more work requested in // the callback of a reader / writer, we attempt to schedule // it at this point. scheduledWorkId = 0; scheduleWork(); }); } function rejectAll(handlers: Array<WorkItem<unknown>>, error: Error): void { for (const handler of handlers) { handler.cancel(error); } } export function cancelPending(): void { const error = new Error(); rejectAll(pendingReaders, error); rejectAll(pendingWriters, error); } function logIfEnabled(value: string): void { if (loggingEnabled === null) { return; } if (loggingEnabled.onlyNamed) { if (value.endsWith(UNNAMED_READ) || value.endsWith(UNNAMED_WRITE) || value.endsWith(UNNAMED_SCROLL)) { return; } } loggingRecords.push({time: performance.now(), value}); // Keep the log at the log size. const loggingLimit = loggingEnabled.storageLimit ?? 100; while (loggingRecords.length > loggingLimit) { loggingRecords.shift(); } }