xref: /aosp_15_r20/external/pigweed/pw_ide/ts/pigweed-vscode/src/refreshManager.ts (revision 61c4878ac05f98d0ceed94b57d316916de578985)
1// Copyright 2024 The Pigweed Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License"); you may not
4// use this file except in compliance with the License. You may obtain a copy of
5// the License at
6//
7//     https://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, WITHOUT
11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12// License for the specific language governing permissions and limitations under
13// the License.
14
15/**
16 * A device for managing "refreshes" of IDE support data.
17 *
18 * Certain IDE support data needs to be refreshed periodically or in response
19 * user actions (for example, generating compile commands, building codegen
20 * files, etc.). The `RefreshManager` provides a mechanism to do those refreshes
21 * in a structured way and provide hooks that UI elements can use to surface
22 * status to the user.
23 *
24 * The `RefreshManager` itself doesn't actually do anything except watch out for
25 * a refresh signal (set by calling `refresh()`), then run through each of its
26 * defined states, calling registered callbacks at each state. So it's up to the
27 * caller to register callbacks that do the real work.
28 *
29 * As an example, if you have a function `refreshCompileCommands`, you can
30 * register it to run during the `refreshing` state:
31 *
32 * `refreshManager.on(refreshCompileCommands, 'refreshing');`
33 *
34 * Now, when `refreshManager.refresh()` is called, the function will be called
35 * when the process enters the `refreshing` stage. You can surface the status
36 * in a UI element by registering callbacks to change a status line or icon
37 * in stages like `idle`, `willRefresh`, and `fault`.
38 *
39 * The abort signal can be set at any time via `abort()` to cancel an
40 * in-progress refresh process.
41 */
42
43import logger from './logging';
44
45/** Refresh statuses that broadly represent a refresh in progress. */
46export type RefreshStatusInProgress =
47  | 'willRefresh'
48  | 'refreshing'
49  | 'didRefresh';
50
51/** The status of the refresh process. */
52export type RefreshStatus =
53  | 'idle'
54  | RefreshStatusInProgress
55  | 'abort'
56  | 'fault';
57
58/** Refresh callback functions return this type. */
59export type RefreshCallbackResult = { error: string | null };
60
61/** A little helper for returning success from a refresh callback. */
62export const OK: RefreshCallbackResult = { error: null };
63
64/** The function signature for refresh callback functions. */
65export type RefreshCallback = () =>
66  | RefreshCallbackResult
67  | Promise<RefreshCallbackResult>;
68
69/**
70 * This type definition defines the refresh manager state machine.
71 *
72 * Essentially, given a current state, it becomes the intersection of valid
73 * next states.
74 */
75type NextState<State extends RefreshStatus> =
76  // prettier-ignore
77  State extends 'idle' ? 'willRefresh' | 'fault' | 'abort' :
78  State extends 'willRefresh' ? 'refreshing' | 'fault' | 'abort' :
79  State extends 'refreshing' ? 'didRefresh' | 'fault' | 'abort' :
80  State extends 'didRefresh' ? 'idle' | 'fault' | 'abort' :
81  State extends 'abort' ? 'idle' | 'fault' | 'abort' :
82  State extends 'fault' ? 'idle' | 'fault' | 'abort' :
83  never;
84
85/** Options for constructing a refresh manager. */
86interface RefreshManagerOptions {
87  /** The timeout period (in ms) between handling the reset signal. */
88  refreshSignalHandlerTimeout: number;
89
90  /** Whether to enable the periodic refresh signal handler. */
91  useRefreshSignalHandler: boolean;
92}
93
94const defaultRefreshManagerOptions: RefreshManagerOptions = {
95  refreshSignalHandlerTimeout: 1000,
96  useRefreshSignalHandler: true,
97};
98
99/**
100 * Encapsulate the status of compile commands refreshing, store callbacks to
101 * be called at each status transition, and provide triggers for status change.
102 */
103export class RefreshManager<State extends RefreshStatus> {
104  /** The current refresh status. */
105  private _state: RefreshStatus;
106
107  /**
108   * Whether it's possible to start a new refresh process. This is essentially
109   * a lock that ensures that we only have a single pending refresh active at
110   * any given time.
111   */
112  private canStart = true;
113
114  /** State of the refresh signal. Set via the `refresh()` method. */
115  private refreshSignal = false;
116
117  /** State of the abort signal. Set via the `abort()` method. */
118  private abortSignal = false;
119
120  /** Handle to the timer used for the refresh signal handler. */
121  private refreshSignalHandlerInterval: NodeJS.Timeout | undefined;
122
123  /** Stored callbacks associated with their state or state change. */
124  private callbacks: Record<string, RefreshCallback[]> = {};
125
126  /** Stored transient callbacks associated with their state or state change. */
127  private transientCallbacks: Record<string, RefreshCallback[]> = {};
128
129  constructor(state: RefreshStatus, options: RefreshManagerOptions) {
130    this._state = state;
131
132    if (options.useRefreshSignalHandler) {
133      this.refreshSignalHandlerInterval = setInterval(
134        () => this.handleRefreshSignal(),
135        options.refreshSignalHandlerTimeout,
136      );
137    }
138  }
139
140  /**
141   * Create a `RefreshHandler`. Use this instead of the constructor.
142   *
143   * The advantage of using this over the constructor is that you get a
144   * well-typed version of the refresh manager from this method, and not from
145   * the constructor.
146   *
147   * Yes, the method overload list is kind of overwhelming. All it's trying to
148   * do is make the most common case trivial (creating a handler in the idle
149   * state), the second most common case easy (creating a handler in another
150   * state), and other cases possible (custom options and/or other states).
151   */
152  static create(): RefreshManager<'idle'>;
153  static create(
154    options: Partial<RefreshManagerOptions>,
155  ): RefreshManager<'idle'>;
156  static create<SpecificRefreshStatus extends RefreshStatus>(
157    state: SpecificRefreshStatus,
158  ): RefreshManager<SpecificRefreshStatus>;
159  static create<SpecificRefreshStatus extends RefreshStatus>(
160    state: SpecificRefreshStatus,
161    options: Partial<RefreshManagerOptions>,
162  ): RefreshManager<SpecificRefreshStatus>;
163  static create<SpecificRefreshStatus extends RefreshStatus>(
164    stateOrOptions?: SpecificRefreshStatus | Partial<RefreshManagerOptions>,
165    options?: Partial<RefreshManagerOptions>,
166  ): RefreshManager<SpecificRefreshStatus> {
167    if (!stateOrOptions) {
168      return new RefreshManager<'idle'>('idle', defaultRefreshManagerOptions);
169    }
170
171    if (typeof stateOrOptions === 'string') {
172      const _state = stateOrOptions;
173      const _options = options ?? {};
174      return new RefreshManager<SpecificRefreshStatus>(_state, {
175        ...defaultRefreshManagerOptions,
176        ..._options,
177      });
178    } else {
179      const _options = stateOrOptions;
180      return new RefreshManager<'idle'>('idle', {
181        ...defaultRefreshManagerOptions,
182        ..._options,
183      });
184    }
185  }
186
187  /** The current refresh status. */
188  get state(): RefreshStatus {
189    return this._state;
190  }
191
192  /** Refresh manager instances must be disposed. */
193  dispose(): void {
194    if (this.refreshSignalHandlerInterval) {
195      clearInterval(this.refreshSignalHandlerInterval);
196    }
197  }
198
199  /**
200   * Transition to a valid next state.
201   *
202   * The `NextState<State>` type ensures that this method will only accept next
203   * states that are valid for the current state. The `SpecificNextState` type
204   * narrows the return type to the one state that was actually provided. For
205   * example, if the current `State` is `'didRefresh'`, `NextState<State>` is
206   * `'idle' | 'fault'`. But only one of those two can be provided, and we
207   * don't want the return type to be `RefreshManager<'idle' | 'fault'>`. So
208   * `SpecificNextState` ensures that the return type is narrowed down.
209   *
210   * Explicitly adding `'fault'` and `'abort'` to the variants for
211   * `SpecificNextState` is just to reassure and convince the compiler that
212   * those states can be transitioned to from any other state. This is clear
213   * from the definition of `NextState`, but without it, the compiler concludes
214   * that it can't conclude anything about what states can be transitioned to
215   * from an unknown state.
216   *
217   * The fact that this method returns itself is just a type system convenience
218   * that lets us do state machine accounting. There's still always just one
219   * refresh manager behind the curtain.
220   *
221   * Something to keep in mind is that the type of the return represents the
222   * "happy path". If the path was actually unhappy, the return will be in the
223   * fault state, but we don't know about that at the type system level. But
224   * you don't need to worry about that; just handle things as if they worked
225   * out, and if they didn't, the next states will just execute an early return.
226   *
227   * @param nextState A valid next state to transition to
228   * @return This object in its new state, or in the fault state
229   */
230  async move<SpecificNextState extends NextState<State> | 'fault' | 'abort'>(
231    nextState: SpecificNextState,
232  ): Promise<RefreshManager<SpecificNextState>> {
233    // If a previous transition moved us into the fault state, and we're not
234    // intentionally moving out of the fault state to idle or abort, this move
235    // is a no-op. So you can transition between the states per the state
236    // machine definition, but with a sort of early return if the terminal fault
237    // state happens in between, and you don't explicitly have to handle the
238    // fault state at each step.
239    if (this._state === 'fault' && !['idle', 'abort'].includes(nextState)) {
240      return this;
241    }
242
243    // Abort is similar to fault in the sense that it short circuits the
244    // following stages. The difference is that abort is considered a voluntary
245    // action, and as such you can register callbacks to be called when moving
246    // into the abort state.
247    if (this.abortSignal) {
248      this.abortSignal = false;
249      const aborted = await this.move('abort');
250      this.transientCallbacks = {};
251      return aborted;
252    }
253
254    const previous = this._state;
255    this._state = nextState;
256
257    if (nextState !== 'fault') {
258      try {
259        // Run the callbacks associated with this state transition.
260        await this.runCallbacks(previous);
261      } catch (err: unknown) {
262        // An error occurred while running the callbacks associated with this
263        // state change.
264
265        // Report errors to the output window.
266        // Errors can come from well-behaved refresh callbacks that return
267        // an object like `{ error: '...' }`, but since those errors are
268        // hoisted to here by `throw`ing them, we will also catch unexpected or
269        // not-well-behaved errors as exceptions (`{ message: '...' }`-style).
270        if (typeof err === 'string') {
271          logger.error(err);
272        } else {
273          const { message } = err as { message: string | undefined };
274
275          if (message) {
276            logger.error(message);
277          } else {
278            logger.error('Unknown error occurred');
279          }
280        }
281
282        // Move into the fault state, running the callbacks associated with
283        // that state transition.
284        const previous = this._state;
285        this._state = 'fault';
286        await this.runCallbacks(previous);
287
288        // Clear all transient callbacks (whether they were called or not).
289        this.transientCallbacks = {};
290      }
291    }
292
293    return this;
294  }
295
296  /**
297   * Register a callback to be called for the specified state transition.
298   *
299   * These callbacks are registered for the lifetime of the refresh manager.
300   * You can specific just the next state, in which case the callback will be
301   * called when entering that state regardless of what the previous state was.
302   * Or you can also specify the previous state to register a callback that will
303   * only be called in that specific state transition.
304   *
305   * @param cb A callback function
306   * @param next The callback will be called when transitioned into this state
307   * @param current The callback will be called when transitioned from this state
308   */
309  on<
310    EventCurrentState extends RefreshStatus,
311    EventNextState extends NextState<EventCurrentState>,
312  >(cb: RefreshCallback, next: EventNextState, current?: EventCurrentState) {
313    const key = current ? `${next}:${current}` : next;
314    this.callbacks[key] = [...(this.callbacks[key] ?? []), cb];
315  }
316
317  /**
318   * Register a transient callback for the specified state transition.
319   *
320   * These are the same as regular callbacks, with the exception that they will
321   * be called at most one time, during the next refresh process that happens
322   * after they are registered. All registered transient callbacks, including
323   * both those that have been called and those that have not been called, will
324   * be cleared when the refresh process reaches a terminal state (idle or
325   * fault).
326   *
327   * @param cb A callback function
328   * @param next The callback will be called when transitioned into this state
329   * @param current The callback will be called when transitioned from this state
330   */
331  onOnce<
332    EventCurrentState extends RefreshStatus,
333    EventNextState extends NextState<EventCurrentState>,
334  >(cb: RefreshCallback, next: EventNextState, current?: EventCurrentState) {
335    const key = current ? `${next}:${current}` : next;
336    this.transientCallbacks[key] = [
337      ...(this.transientCallbacks[key] ?? []),
338      cb,
339    ];
340  }
341
342  /**
343   * Run the callbacks registered for the current state transition.
344   *
345   * The next state is pulled from `this.state`, so the implication is that
346   * the state transition per se (reassigning variables) has already occurred.
347   *
348   * @param previous The state we just transitioned from
349   */
350  private async runCallbacks<EventCurrentState extends RefreshStatus>(
351    previous: EventCurrentState,
352  ) {
353    // Collect the callbacks that run on this state transition.
354    // Those can include callbacks that run whenever we enter the next state,
355    // and callbacks that only run on the specific transition from the current
356    // to the next state.
357    const {
358      [this._state]: transientForState,
359      [`${this._state}:${previous}`]: transientForTransition,
360      ...remainingTransient
361    } = this.transientCallbacks;
362
363    this.transientCallbacks = remainingTransient;
364
365    const callbacks = [
366      ...(this.callbacks[`${this._state}:${previous}`] ?? []),
367      ...(this.callbacks[this._state] ?? []),
368      ...(transientForTransition ?? []),
369      ...(transientForState ?? []),
370    ];
371
372    // Run each callback, throw an error if any of them return an error.
373    // This is sequential, so the first error we get will terminate this
374    // procedure, and the remaining callbacks won't be called.
375    for (const cb of callbacks) {
376      // If the abort signal is set during callback execution, return early.
377      // The rest of the abort signal handling will be handled by this method's
378      // caller.
379      if (this.abortSignal) {
380        return;
381      }
382
383      const { error } = await Promise.resolve(cb());
384
385      if (error) {
386        throw new Error(error);
387      }
388    }
389  }
390
391  /**
392   * Start a refresh process.
393   *
394   * This executes the procedure of moving from the idle state through each
395   * subsequent state, and those moves then trigger the callbacks associated
396   * with those state transitions.
397   *
398   * If this is called when we're not in the idle or fault states, we assume
399   * that a refresh is currently in progress. Then we wait for a while for that
400   * process to complete (return to idle or fault state) before running the
401   * next process.
402   *
403   * We don't want to be able to queue up a limitless number of pending refresh
404   * processes; we just want to represent the concept that there can be a
405   * refresh happening now, and a request for a refresh to happen again after
406   * the first one is done. Any additional requests for refresh are redundant.
407   * So `this.canStart` acts like a lock to enforce that behavior.
408   *
409   * The request to start a new refresh process is signaled by setting
410   * `this.refreshSignal` (by calling `refresh()`). So you generally don't
411   * want to call this method directly.
412   */
413  async start() {
414    if (!this.canStart) {
415      throw new Error('Refresh process already starting');
416    }
417
418    this.canStart = false;
419
420    // If we're in the fault state, we can start the refresh, but we need to
421    // move into idle first. This is mostly just type system accounting, but
422    // callbacks could also be registered in the fault -> idle transition, and
423    // they'll be run now.
424    if (this._state === 'fault') {
425      await (this as RefreshManager<'fault'>).move('idle');
426    }
427
428    // We can only start from the idle state, so if we're not there, we need to
429    // wait until we get there.
430    await this.waitFor('idle');
431
432    // We can cancel this pending refresh start by sending an abort signal.
433    if (this.abortSignal) {
434      this.abortSignal = false;
435      this.canStart = true;
436      return;
437    }
438
439    // If we don't get to idle within the timeout period, give up.
440    if (this._state !== 'idle') {
441      this.canStart = true;
442      return;
443    }
444
445    // Now that we're starting a new refresh, clear the refresh signal.
446    this.refreshSignal = false;
447    this.canStart = true;
448
449    // This moves through each stage of the "happy path", running registered
450    // callbacks at each transition.
451    //
452    // - If a fault occurs, the remaining transitions will be no-op
453    //   passthroughs, and we'll end up in the terminal fault state.
454    //
455    // - If an abort signal is sent, the remaining transitions will also be
456    //   no-op passthroughs as we transition to the abort state. At the end
457    //   of the abort state, we normally return to the idle state (unless a
458    //   fault occurs during a callback called in the abort state).
459    const idle = this as RefreshManager<'idle'>;
460    const willRefresh = await idle.move('willRefresh');
461    const refreshing = await willRefresh.move('refreshing');
462    const didRefresh = await refreshing.move('didRefresh');
463
464    // Don't eagerly reset to the idle state if we ended up in a fault state.
465    if (didRefresh.state !== 'fault') {
466      await didRefresh.move('idle');
467    }
468
469    this.transientCallbacks = {};
470  }
471
472  /**
473   * The refresh signal handler.
474   *
475   * This starts a pending refresh process if the refresh signal is set and
476   * there isn't already a pending process.
477   */
478  async handleRefreshSignal() {
479    if (!this.refreshSignal || !this.canStart) return;
480    await this.start();
481  }
482
483  /** Set the refresh signal to request a refresh. */
484  refresh() {
485    this.refreshSignal = true;
486  }
487
488  /** Set the abort signal to abort all current processing. */
489  abort() {
490    this.abortSignal = true;
491  }
492
493  async waitFor(state: RefreshStatus, delay = 500, retries = 10) {
494    while (this._state !== state && retries > 0 && !this.abortSignal) {
495      await new Promise((resolve) => setTimeout(resolve, delay));
496      retries--;
497    }
498  }
499}
500