blob: 5d05a134dc319dc57f1ef2d6fb1eb8ec20f3b9ca [file] [log] [blame]
// 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;
}
}
}