| // Copyright (C) 2022 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| import {assertExists} from '../base/assert'; |
| import {Registry} from '../base/registry'; |
| import type {PerfettoPlugin, PerfettoPluginStatic} from '../public/plugin'; |
| import type {Trace} from '../public/trace'; |
| import {featureFlags} from './feature_flags'; |
| import type {Flag} from '../public/feature_flag'; |
| import type {TraceImpl} from './trace_impl'; |
| import type {AppImpl} from './app_impl'; |
| import {createProxy} from '../base/utils'; |
| import type {RouteArgs} from '../public/route_schema'; |
| import type {SettingsManagerImpl} from './settings_manager'; |
| import type {PageManager} from '../public/page'; |
| |
| function makePlugin( |
| desc: PerfettoPluginStatic<PerfettoPlugin>, |
| trace: Trace, |
| ): PerfettoPlugin { |
| const PluginClass = desc; |
| return new PluginClass(trace); |
| } |
| |
| // Contains information about a plugin. |
| export interface PluginWrapper { |
| // A reference to the plugin descriptor |
| readonly desc: PerfettoPluginStatic<PerfettoPlugin>; |
| |
| // The feature flag used to allow users to change whether this plugin should |
| // be enabled or not. |
| readonly enableFlag: Flag; |
| |
| // Record whether this plugin was enabled for this session, regardless of the |
| // current flag setting. I.e. this captures the state of the enabled flag at |
| // boot time. |
| readonly enabled: boolean; |
| |
| // Whether this is a core plugin (part of CORE_PLUGINS) or not. |
| readonly isCore: boolean; |
| |
| // Keeps track of whether this plugin is active. A plugin can be active even |
| // if it's disabled, if another plugin depends on it. |
| // |
| // In summary, a plugin can be in one of three states: |
| // - Inactive: Disabled and no active plugins depend on it. |
| // - Transitively active: Disabled but active because another plugin depends |
| // on it. |
| // - Explicitly active: Active because it was explicitly enabled by the user. |
| active?: boolean; |
| |
| // If a trace has been loaded, this object stores the relevant trace-scoped |
| // plugin data |
| traceContext?: { |
| // The concrete plugin instance, created on trace load. |
| readonly instance: PerfettoPlugin; |
| |
| // How long it took for the plugin's onTraceLoad() function to run. |
| readonly loadTimeMs: number; |
| }; |
| } |
| |
| export class PluginManagerImpl { |
| private readonly registry = new Registry<PluginWrapper>((x) => x.desc.id); |
| private orderedPlugins: Array<PluginWrapper> = []; |
| constructor(private readonly defaultPluginIds: ReadonlyArray<string>) {} |
| |
| registerPlugin(desc: PerfettoPluginStatic<PerfettoPlugin>, isCore = false) { |
| const flagId = `plugin_${desc.id}`; |
| const name = `Plugin: ${desc.id}`; |
| const flag = featureFlags.register({ |
| id: flagId, |
| name, |
| description: `Overrides '${desc.id}' plugin.`, |
| defaultValue: this.defaultPluginIds.includes(desc.id), |
| }); |
| this.registry.register({ |
| desc, |
| enableFlag: flag, |
| enabled: flag.get(), |
| isCore, |
| }); |
| } |
| |
| /** |
| * Activates all registered plugins that have not already been registered. |
| * |
| * @param app - The application instance. |
| * @param enableOverrides - The list of plugins that are enabled regardless of |
| * the current flag setting. |
| */ |
| activatePlugins(app: AppImpl, enableOverrides: ReadonlyArray<string> = []) { |
| const enabledPlugins = this.registry |
| .valuesAsArray() |
| .filter((p) => p.enableFlag.get() || enableOverrides.includes(p.desc.id)); |
| |
| this.orderedPlugins = this.sortPluginsTopologically(enabledPlugins); |
| |
| this.orderedPlugins.forEach((p) => { |
| if (p.active) return; |
| const appProxy = createAppProxy(app, p.desc.id); |
| const pluginArgs = getPluginArgs(app, p.desc.id); |
| p.desc.onActivate?.(appProxy, pluginArgs); |
| p.active = true; |
| }); |
| } |
| |
| async onTraceLoad( |
| trace: TraceImpl, |
| beforeEach?: (id: string) => void, |
| ): Promise<void> { |
| // Awaiting all plugins in parallel will skew timing data as later plugins |
| // will spend most of their time waiting for earlier plugins to load. |
| // Running in parallel will have very little performance benefit assuming |
| // most plugins use the same engine, which can only process one query at a |
| // time. |
| for (const p of this.orderedPlugins) { |
| if (p.active) { |
| beforeEach?.(p.desc.id); |
| const traceProxy = createTraceProxy(trace, p.desc.id); |
| const instance = makePlugin(p.desc, traceProxy); |
| const args = getOpenerArgs(trace, p.desc.id); |
| const before = performance.now(); |
| await instance.onTraceLoad?.(traceProxy, args); |
| const loadTimeMs = performance.now() - before; |
| p.traceContext = { |
| instance, |
| loadTimeMs, |
| }; |
| trace.trash.defer(() => { |
| p.traceContext = undefined; |
| }); |
| } |
| } |
| } |
| |
| getAllPlugins() { |
| return this.registry.valuesAsArray(); |
| } |
| |
| getPluginContainer(id: string): PluginWrapper | undefined { |
| return this.registry.tryGet(id); |
| } |
| |
| isPluginEnabled(id: string): boolean { |
| const plugin = this.registry.tryGet(id); |
| return plugin?.traceContext !== undefined; |
| } |
| |
| getPlugin<T extends PerfettoPlugin>( |
| pluginDescriptor: PerfettoPluginStatic<T>, |
| ): T { |
| const plugin = this.registry.get(pluginDescriptor.id); |
| return assertExists(plugin.traceContext).instance as T; |
| } |
| |
| isCorePlugin(pluginId: string): boolean { |
| const plugin = this.registry.tryGet(pluginId); |
| return plugin?.isCore ?? false; |
| } |
| |
| /** |
| * Sort plugins in dependency order, ensuring that if a plugin depends on |
| * other plugins, those plugins will appear fist in the list. |
| */ |
| private sortPluginsTopologically( |
| plugins: ReadonlyArray<PluginWrapper>, |
| ): Array<PluginWrapper> { |
| const orderedPlugins = new Array<PluginWrapper>(); |
| const visiting = new Set<string>(); |
| |
| const visit = (p: PluginWrapper) => { |
| // Continue if we've already added this plugin, there's no need to add it |
| // again |
| if (orderedPlugins.includes(p)) { |
| return; |
| } |
| |
| // Detect circular dependencies |
| if (visiting.has(p.desc.id)) { |
| const cycle = Array.from(visiting).concat(p.desc.id); |
| throw new Error( |
| `Cyclic plugin dependency detected: ${cycle.join(' -> ')}`, |
| ); |
| } |
| |
| // Temporarily push this plugin onto the visiting stack while visiting |
| // dependencies, to allow circular dependencies to be detected |
| visiting.add(p.desc.id); |
| |
| // Recursively visit dependencies |
| p.desc.dependencies?.forEach((d) => { |
| visit(this.registry.get(d.id)); |
| }); |
| |
| visiting.delete(p.desc.id); |
| |
| // Finally add this plugin to the ordered list |
| orderedPlugins.push(p); |
| }; |
| |
| plugins.forEach((p) => visit(p)); |
| |
| return orderedPlugins; |
| } |
| } |
| |
| /** |
| * Creates a plugin-scoped proxy for the App instance. |
| * |
| * This proxy automatically injects the plugin's ID into any pages or settings |
| * registered by the plugin, ensuring proper attribution and enabling cleanup |
| * when the plugin is unloaded. It also recursively proxies the trace property |
| * if one is loaded. |
| */ |
| function createAppProxy(app: AppImpl, pluginId: string): AppImpl { |
| return createProxy(app, { |
| get trace() { |
| if (app.trace) { |
| return createTraceProxy(app.trace, pluginId); |
| } else { |
| return undefined; |
| } |
| }, |
| get pages() { |
| return createPagesProxy(app.pages, pluginId); |
| }, |
| get settings() { |
| return createSettingsProxy(app.settings, pluginId); |
| }, |
| }); |
| } |
| |
| /** |
| * Creates a plugin-scoped proxy for the Trace instance. |
| * |
| * This proxy automatically injects the plugin's ID into any pages, settings, |
| * and tracks registered by the plugin. This ensures that all trace-scoped |
| * resources created by the plugin are properly attributed and can be |
| * automatically cleaned up when the trace is closed. It also proxies |
| * the trace property back to itself. |
| */ |
| function createTraceProxy(trace: TraceImpl, pluginId: string): TraceImpl { |
| const traceProxy = createProxy(trace, { |
| get engine() { |
| return trace.engine.getProxy(pluginId); |
| }, |
| get trace(): TraceImpl { |
| return traceProxy; // Return this proxy. |
| }, |
| get pages() { |
| return createPagesProxy(trace.pages, pluginId); |
| }, |
| get settings() { |
| return createSettingsProxy(trace.settings, pluginId); |
| }, |
| get tracks() { |
| return createProxy(trace.tracks, { |
| registerTrack(track) { |
| return trace.tracks.registerTrack({ |
| ...track, |
| pluginId, |
| }); |
| }, |
| }); |
| }, |
| }); |
| return traceProxy; |
| } |
| |
| /** |
| * Creates a proxy for the PageManager that automatically injects the pluginId |
| * into any registered pages. |
| */ |
| function createPagesProxy<T extends PageManager>( |
| pages: T, |
| pluginId: string, |
| ): T { |
| return createProxy(pages, { |
| registerPage(page) { |
| return pages.registerPage({ |
| ...page, |
| pluginId, |
| }); |
| }, |
| } as Partial<T>); |
| } |
| |
| /** |
| * Creates a proxy for the SettingsManager that automatically injects the |
| * pluginId into any registered settings. |
| */ |
| function createSettingsProxy<T extends SettingsManagerImpl>( |
| settings: T, |
| pluginId: string, |
| ): T { |
| return createProxy(settings, { |
| register(setting) { |
| return settings.register(setting, pluginId); |
| }, |
| } as Partial<T>); |
| } |
| |
| function getPluginArgs(app: AppImpl, pluginId: string): RouteArgs { |
| return Object.entries(app.initialRouteArgs).reduce((result, [key, value]) => { |
| // Create a regex to match keys starting with pluginId |
| const regex = new RegExp(`^${pluginId}:(.+)$`); |
| const match = key.match(regex); |
| |
| // Only include entries that match the regex |
| if (match) { |
| const newKey = match[1]; |
| // Use the capture group (what comes after the prefix) as the new key |
| result[newKey] = value; |
| } |
| return result; |
| }, {} as RouteArgs); |
| } |
| |
| function getOpenerArgs( |
| trace: TraceImpl, |
| pluginId: string, |
| ): {[key: string]: unknown} | undefined { |
| const traceSource = trace.traceInfo.source; |
| if (traceSource.type !== 'ARRAY_BUFFER') { |
| return undefined; |
| } |
| const pluginArgs = traceSource.pluginArgs; |
| return (pluginArgs ?? {})[pluginId]; |
| } |