1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import {Disposable} from '../base/disposable'; 16import {exists} from '../base/utils'; 17import {Registry} from '../base/registry'; 18import {Store} from '../base/store'; 19import {PanelSize} from '../frontend/panel'; 20import {Track, TrackContext, TrackDescriptor, TrackRef} from '../public'; 21 22import {ObjectByKey, State, TrackState} from './state'; 23 24export interface TrackCacheEntry { 25 track: Track; 26 desc: TrackDescriptor; 27 update(): void; 28 render(ctx: CanvasRenderingContext2D, size: PanelSize): void; 29 destroy(): void; 30 getError(): Error | undefined; 31} 32 33// This class is responsible for managing the lifecycle of tracks over render 34// cycles. 35 36// Example usage: 37// function render() { 38// const trackCache = new TrackCache(); 39// const foo = trackCache.resolveTrack('foo', 'exampleURI', {}); 40// const bar = trackCache.resolveTrack('bar', 'exampleURI', {}); 41// trackCache.flushOldTracks(); // <-- Destroys any unused cached tracks 42// } 43 44// Example of how flushing works: 45// First cycle 46// resolveTrack('foo', ...) <-- new track 'foo' created 47// resolveTrack('bar', ...) <-- new track 'bar' created 48// flushTracks() 49// Second cycle 50// resolveTrack('foo', ...) <-- returns cached 'foo' track 51// flushTracks() <-- 'bar' is destroyed, as it was not resolved this cycle 52// Third cycle 53// flushTracks() <-- 'foo' is destroyed. 54export class TrackManager { 55 private _trackKeyByTrackId = new Map<number, string>(); 56 private newTracks = new Map<string, TrackCacheEntry>(); 57 private currentTracks = new Map<string, TrackCacheEntry>(); 58 private trackRegistry = new Registry<TrackDescriptor>(({uri}) => uri); 59 private defaultTracks = new Set<TrackRef>(); 60 61 private store: Store<State>; 62 private trackState?: ObjectByKey<TrackState>; 63 64 constructor(store: Store<State>) { 65 this.store = store; 66 } 67 68 get trackKeyByTrackId() { 69 this.updateTrackKeyByTrackIdMap(); 70 return this._trackKeyByTrackId; 71 } 72 73 registerTrack(trackDesc: TrackDescriptor): Disposable { 74 return this.trackRegistry.register(trackDesc); 75 } 76 77 addPotentialTrack(track: TrackRef): Disposable { 78 this.defaultTracks.add(track); 79 return { 80 dispose: () => this.defaultTracks.delete(track), 81 }; 82 } 83 84 findPotentialTracks(): TrackRef[] { 85 return Array.from(this.defaultTracks); 86 } 87 88 getAllTracks(): TrackDescriptor[] { 89 return Array.from(this.trackRegistry.values()); 90 } 91 92 // Look up track into for a given track's URI. 93 // Returns |undefined| if no track can be found. 94 resolveTrackInfo(uri: string): TrackDescriptor | undefined { 95 return this.trackRegistry.tryGet(uri); 96 } 97 98 // Creates a new track using |uri| and |params| or retrieves a cached track if 99 // |key| exists in the cache. 100 resolveTrack(key: string, trackDesc: TrackDescriptor): TrackCacheEntry { 101 // Search for a cached version of this track, 102 const cached = this.currentTracks.get(key); 103 104 // Ensure the cached track has the same factory type as the resolved track. 105 // If this has changed, the track should be re-created. 106 if (cached && trackDesc.trackFactory === cached.desc.trackFactory) { 107 // Keep our cached track descriptor up to date, if anything's changed. 108 cached.desc = trackDesc; 109 110 // Move this track from the recycle bin to the safe cache, which means 111 // it's safe from disposal for this cycle. 112 this.newTracks.set(key, cached); 113 114 return cached; 115 } else { 116 // Cached track doesn't exist or is out of date, create a new one. 117 const trackContext: TrackContext = { 118 trackKey: key, 119 }; 120 const track = trackDesc.trackFactory(trackContext); 121 const entry = new TrackFSM(track, trackDesc, trackContext); 122 123 // Push track into the safe cache. 124 this.newTracks.set(key, entry); 125 return entry; 126 } 127 } 128 129 // Destroys all current tracks not present in the new cache. 130 flushOldTracks() { 131 for (const [key, entry] of this.currentTracks.entries()) { 132 if (!this.newTracks.has(key)) { 133 entry.destroy(); 134 } 135 } 136 137 this.currentTracks = this.newTracks; 138 this.newTracks = new Map<string, TrackCacheEntry>(); 139 } 140 141 private updateTrackKeyByTrackIdMap() { 142 if (this.trackState === this.store.state.tracks) { 143 return; 144 } 145 146 const trackKeyByTrackId = new Map<number, string>(); 147 148 const trackList = Object.entries(this.store.state.tracks); 149 trackList.forEach(([key, {uri}]) => { 150 const desc = this.trackRegistry.get(uri); 151 for (const trackId of desc?.trackIds ?? []) { 152 const existingKey = trackKeyByTrackId.get(trackId); 153 if (exists(existingKey)) { 154 throw new Error( 155 `Trying to map track id ${trackId} to UI track ${key}, already mapped to ${existingKey}`, 156 ); 157 } 158 trackKeyByTrackId.set(trackId, key); 159 } 160 }); 161 162 this._trackKeyByTrackId = trackKeyByTrackId; 163 this.trackState = this.store.state.tracks; 164 } 165} 166 167enum TrackFSMState { 168 NotCreated = 'not_created', 169 Creating = 'creating', 170 Ready = 'ready', 171 UpdatePending = 'update_pending', 172 Updating = 'updating', 173 DestroyPending = 'destroy_pending', 174 Destroyed = 'destroyed', // <- Final state, cannot escape. 175 Error = 'error', 176} 177 178/** 179 * Wrapper that manages lifecycle hooks on behalf of a track, ensuring lifecycle 180 * hooks are called synchronously and in the correct order. 181 */ 182class TrackFSM implements TrackCacheEntry { 183 private state: TrackFSMState; 184 private error?: Error; 185 186 constructor( 187 public track: Track, 188 public desc: TrackDescriptor, 189 private readonly ctx: TrackContext, 190 ) { 191 this.state = TrackFSMState.NotCreated; 192 } 193 194 update(): void { 195 switch (this.state) { 196 case TrackFSMState.NotCreated: 197 Promise.resolve(this.track.onCreate?.(this.ctx)) 198 .then(() => this.onTrackCreated()) 199 .catch((e) => { 200 this.error = e; 201 this.state = TrackFSMState.Error; 202 }); 203 this.state = TrackFSMState.Creating; 204 break; 205 case TrackFSMState.Creating: 206 case TrackFSMState.Updating: 207 this.state = TrackFSMState.UpdatePending; 208 break; 209 case TrackFSMState.Ready: 210 const result = this.track.onUpdate?.(); 211 Promise.resolve(result) 212 .then(() => this.onTrackUpdated()) 213 .catch((e) => { 214 this.error = e; 215 this.state = TrackFSMState.Error; 216 }); 217 this.state = TrackFSMState.Updating; 218 break; 219 case TrackFSMState.UpdatePending: 220 // Update already pending... do nothing! 221 break; 222 case TrackFSMState.Error: 223 break; 224 default: 225 throw new Error('Invalid state transition'); 226 } 227 } 228 229 destroy(): void { 230 switch (this.state) { 231 case TrackFSMState.NotCreated: 232 // Nothing to do 233 this.state = TrackFSMState.Destroyed; 234 break; 235 case TrackFSMState.Ready: 236 // Don't bother awaiting this as the track can no longer be used. 237 Promise.resolve(this.track.onDestroy?.()).catch(() => { 238 // Track crashed while being destroyed 239 // There's not a lot we can do here - just swallow the error 240 }); 241 this.state = TrackFSMState.Destroyed; 242 break; 243 case TrackFSMState.Creating: 244 case TrackFSMState.Updating: 245 case TrackFSMState.UpdatePending: 246 this.state = TrackFSMState.DestroyPending; 247 break; 248 case TrackFSMState.Error: 249 break; 250 default: 251 throw new Error('Invalid state transition'); 252 } 253 } 254 255 private onTrackCreated() { 256 switch (this.state) { 257 case TrackFSMState.DestroyPending: 258 // Don't bother awaiting this as the track can no longer be used. 259 this.track.onDestroy?.(); 260 this.state = TrackFSMState.Destroyed; 261 break; 262 case TrackFSMState.Creating: 263 case TrackFSMState.UpdatePending: 264 const result = this.track.onUpdate?.(); 265 Promise.resolve(result) 266 .then(() => this.onTrackUpdated()) 267 .catch((e) => { 268 this.error = e; 269 this.state = TrackFSMState.Error; 270 }); 271 this.state = TrackFSMState.Updating; 272 break; 273 case TrackFSMState.Error: 274 break; 275 default: 276 throw new Error('Invalid state transition'); 277 } 278 } 279 280 private onTrackUpdated() { 281 switch (this.state) { 282 case TrackFSMState.DestroyPending: 283 // Don't bother awaiting this as the track can no longer be used. 284 this.track.onDestroy?.(); 285 this.state = TrackFSMState.Destroyed; 286 break; 287 case TrackFSMState.UpdatePending: 288 const result = this.track.onUpdate?.(); 289 Promise.resolve(result) 290 .then(() => this.onTrackUpdated()) 291 .catch((e) => { 292 this.error = e; 293 this.state = TrackFSMState.Error; 294 }); 295 this.state = TrackFSMState.Updating; 296 break; 297 case TrackFSMState.Updating: 298 this.state = TrackFSMState.Ready; 299 break; 300 case TrackFSMState.Error: 301 break; 302 default: 303 throw new Error('Invalid state transition'); 304 } 305 } 306 307 render(ctx: CanvasRenderingContext2D, size: PanelSize): void { 308 try { 309 this.track.render(ctx, size); 310 } catch { 311 this.state = TrackFSMState.Error; 312 } 313 } 314 315 getError(): Error | undefined { 316 if (this.state === TrackFSMState.Error) { 317 return this.error; 318 } else { 319 return undefined; 320 } 321 } 322} 323