• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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