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