| // Copyright (C) 2023 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 {Disposable, DisposableCallback} from '../base/disposable'; |
| import {exists} from '../base/utils'; |
| import {Registry} from '../base/registry'; |
| import {Store} from '../base/store'; |
| import {PanelSize} from '../frontend/panel'; |
| import { |
| Migrate, |
| Track, |
| TrackContext, |
| TrackDescriptor, |
| TrackRef, |
| } from '../public'; |
| |
| import {ObjectByKey, State, TrackState} from './state'; |
| |
| export interface TrackCacheEntry { |
| track: Track; |
| desc: TrackDescriptor; |
| update(): void; |
| render(ctx: CanvasRenderingContext2D, size: PanelSize): void; |
| destroy(): void; |
| getError(): Error | undefined; |
| } |
| |
| // This class is responsible for managing the lifecycle of tracks over render |
| // cycles. |
| |
| // Example usage: |
| // function render() { |
| // const trackCache = new TrackCache(); |
| // const foo = trackCache.resolveTrack('foo', 'exampleURI', {}); |
| // const bar = trackCache.resolveTrack('bar', 'exampleURI', {}); |
| // trackCache.flushOldTracks(); // <-- Destroys any unused cached tracks |
| // } |
| |
| // Example of how flushing works: |
| // First cycle |
| // resolveTrack('foo', ...) <-- new track 'foo' created |
| // resolveTrack('bar', ...) <-- new track 'bar' created |
| // flushTracks() |
| // Second cycle |
| // resolveTrack('foo', ...) <-- returns cached 'foo' track |
| // flushTracks() <-- 'bar' is destroyed, as it was not resolved this cycle |
| // Third cycle |
| // flushTracks() <-- 'foo' is destroyed. |
| export class TrackManager { |
| private _trackKeyByTrackId = new Map<number, string>(); |
| private newTracks = new Map<string, TrackCacheEntry>(); |
| private currentTracks = new Map<string, TrackCacheEntry>(); |
| private trackRegistry = new Registry<TrackDescriptor>(({uri}) => uri); |
| private defaultTracks = new Set<TrackRef>(); |
| |
| private store: Store<State>; |
| private trackState?: ObjectByKey<TrackState>; |
| |
| constructor(store: Store<State>) { |
| this.store = store; |
| } |
| |
| get trackKeyByTrackId() { |
| this.updateTrackKeyByTrackIdMap(); |
| return this._trackKeyByTrackId; |
| } |
| |
| registerTrack(trackDesc: TrackDescriptor): Disposable { |
| return this.trackRegistry.register(trackDesc); |
| } |
| |
| addPotentialTrack(track: TrackRef): Disposable { |
| this.defaultTracks.add(track); |
| return new DisposableCallback(() => { |
| this.defaultTracks.delete(track); |
| }); |
| } |
| |
| findPotentialTracks(): TrackRef[] { |
| return Array.from(this.defaultTracks); |
| } |
| |
| getAllTracks(): TrackDescriptor[] { |
| return Array.from(this.trackRegistry.values()); |
| } |
| |
| // Look up track into for a given track's URI. |
| // Returns |undefined| if no track can be found. |
| resolveTrackInfo(uri: string): TrackDescriptor | undefined { |
| return this.trackRegistry.tryGet(uri); |
| } |
| |
| // Creates a new track using |uri| and |params| or retrieves a cached track if |
| // |key| exists in the cache. |
| resolveTrack( |
| key: string, |
| trackDesc: TrackDescriptor, |
| params?: unknown, |
| ): TrackCacheEntry { |
| // Search for a cached version of this track, |
| const cached = this.currentTracks.get(key); |
| |
| // Ensure the cached track has the same factory type as the resolved track. |
| // If this has changed, the track should be re-created. |
| if (cached && trackDesc.trackFactory === cached.desc.trackFactory) { |
| // Keep our cached track descriptor up to date, if anything's changed. |
| cached.desc = trackDesc; |
| |
| // Move this track from the recycle bin to the safe cache, which means |
| // it's safe from disposal for this cycle. |
| this.newTracks.set(key, cached); |
| |
| return cached; |
| } else { |
| // Cached track doesn't exist or is out of date, create a new one. |
| const trackContext: TrackContext = { |
| trackKey: key, |
| mountStore: <T>(migrate: Migrate<T>) => { |
| const path = ['tracks', key, 'state']; |
| return this.store.createSubStore(path, migrate); |
| }, |
| params, |
| }; |
| const track = trackDesc.trackFactory(trackContext); |
| const entry = new TrackFSM(track, trackDesc, trackContext); |
| |
| // Push track into the safe cache. |
| this.newTracks.set(key, entry); |
| return entry; |
| } |
| } |
| |
| // Destroys all current tracks not present in the new cache. |
| flushOldTracks() { |
| for (const [key, entry] of this.currentTracks.entries()) { |
| if (!this.newTracks.has(key)) { |
| entry.destroy(); |
| } |
| } |
| |
| this.currentTracks = this.newTracks; |
| this.newTracks = new Map<string, TrackCacheEntry>(); |
| } |
| |
| private updateTrackKeyByTrackIdMap() { |
| if (this.trackState === this.store.state.tracks) { |
| return; |
| } |
| |
| const trackKeyByTrackId = new Map<number, string>(); |
| |
| const trackList = Object.entries(this.store.state.tracks); |
| trackList.forEach(([key, {uri}]) => { |
| const desc = this.trackRegistry.get(uri); |
| for (const trackId of desc?.trackIds ?? []) { |
| const existingKey = trackKeyByTrackId.get(trackId); |
| if (exists(existingKey)) { |
| throw new Error( |
| `Trying to map track id ${trackId} to UI track ${key}, already mapped to ${existingKey}`, |
| ); |
| } |
| trackKeyByTrackId.set(trackId, key); |
| } |
| }); |
| |
| this._trackKeyByTrackId = trackKeyByTrackId; |
| this.trackState = this.store.state.tracks; |
| } |
| } |
| |
| enum TrackFSMState { |
| NotCreated = 'not_created', |
| Creating = 'creating', |
| Ready = 'ready', |
| UpdatePending = 'update_pending', |
| Updating = 'updating', |
| DestroyPending = 'destroy_pending', |
| Destroyed = 'destroyed', // <- Final state, cannot escape. |
| Error = 'error', |
| } |
| |
| /** |
| * Wrapper that manages lifecycle hooks on behalf of a track, ensuring lifecycle |
| * hooks are called synchronously and in the correct order. |
| */ |
| class TrackFSM implements TrackCacheEntry { |
| private state: TrackFSMState; |
| private error?: Error; |
| |
| constructor( |
| public track: Track, |
| public desc: TrackDescriptor, |
| private readonly ctx: TrackContext, |
| ) { |
| this.state = TrackFSMState.NotCreated; |
| } |
| |
| update(): void { |
| switch (this.state) { |
| case TrackFSMState.NotCreated: |
| Promise.resolve(this.track.onCreate?.(this.ctx)) |
| .then(() => this.onTrackCreated()) |
| .catch((e) => { |
| this.error = e; |
| this.state = TrackFSMState.Error; |
| }); |
| this.state = TrackFSMState.Creating; |
| break; |
| case TrackFSMState.Creating: |
| case TrackFSMState.Updating: |
| this.state = TrackFSMState.UpdatePending; |
| break; |
| case TrackFSMState.Ready: |
| const result = this.track.onUpdate?.(); |
| Promise.resolve(result) |
| .then(() => this.onTrackUpdated()) |
| .catch((e) => { |
| this.error = e; |
| this.state = TrackFSMState.Error; |
| }); |
| this.state = TrackFSMState.Updating; |
| break; |
| case TrackFSMState.UpdatePending: |
| // Update already pending... do nothing! |
| break; |
| case TrackFSMState.Error: |
| break; |
| default: |
| throw new Error('Invalid state transition'); |
| } |
| } |
| |
| destroy(): void { |
| switch (this.state) { |
| case TrackFSMState.NotCreated: |
| // Nothing to do |
| this.state = TrackFSMState.Destroyed; |
| break; |
| case TrackFSMState.Ready: |
| // Don't bother awaiting this as the track can no longer be used. |
| Promise.resolve(this.track.onDestroy?.()).catch(() => { |
| // Track crashed while being destroyed |
| // There's not a lot we can do here - just swallow the error |
| }); |
| this.state = TrackFSMState.Destroyed; |
| break; |
| case TrackFSMState.Creating: |
| case TrackFSMState.Updating: |
| case TrackFSMState.UpdatePending: |
| this.state = TrackFSMState.DestroyPending; |
| break; |
| case TrackFSMState.Error: |
| break; |
| default: |
| throw new Error('Invalid state transition'); |
| } |
| } |
| |
| private onTrackCreated() { |
| switch (this.state) { |
| case TrackFSMState.DestroyPending: |
| // Don't bother awaiting this as the track can no longer be used. |
| this.track.onDestroy?.(); |
| this.state = TrackFSMState.Destroyed; |
| break; |
| case TrackFSMState.Creating: |
| case TrackFSMState.UpdatePending: |
| const result = this.track.onUpdate?.(); |
| Promise.resolve(result) |
| .then(() => this.onTrackUpdated()) |
| .catch((e) => { |
| this.error = e; |
| this.state = TrackFSMState.Error; |
| }); |
| this.state = TrackFSMState.Updating; |
| break; |
| case TrackFSMState.Error: |
| break; |
| default: |
| throw new Error('Invalid state transition'); |
| } |
| } |
| |
| private onTrackUpdated() { |
| switch (this.state) { |
| case TrackFSMState.DestroyPending: |
| // Don't bother awaiting this as the track can no longer be used. |
| this.track.onDestroy?.(); |
| this.state = TrackFSMState.Destroyed; |
| break; |
| case TrackFSMState.UpdatePending: |
| const result = this.track.onUpdate?.(); |
| Promise.resolve(result) |
| .then(() => this.onTrackUpdated()) |
| .catch((e) => { |
| this.error = e; |
| this.state = TrackFSMState.Error; |
| }); |
| this.state = TrackFSMState.Updating; |
| break; |
| case TrackFSMState.Updating: |
| this.state = TrackFSMState.Ready; |
| break; |
| case TrackFSMState.Error: |
| break; |
| default: |
| throw new Error('Invalid state transition'); |
| } |
| } |
| |
| render(ctx: CanvasRenderingContext2D, size: PanelSize): void { |
| try { |
| this.track.render(ctx, size); |
| } catch { |
| this.state = TrackFSMState.Error; |
| } |
| } |
| |
| getError(): Error | undefined { |
| if (this.state === TrackFSMState.Error) { |
| return this.error; |
| } else { |
| return undefined; |
| } |
| } |
| } |