backend-controller.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. /** Owns one backend startup and its quiescent teardown independently of windows. */
  2. import { desktopErrorState } from './startup-error.ts'
  3. /** Backend availability presented by the desktop window. */
  4. export type DesktopBackendState = { readonly phase: 'starting' } | { readonly phase: 'ready' } | { readonly phase: 'error'; readonly message: string; readonly profileRecovery?: boolean }
  5. /** Child lifecycle owned by the desktop backend controller. */
  6. export interface DesktopBackendHost {
  7. /** @returns Readiness after the child accepts application requests. */
  8. start(): Promise<unknown>
  9. /** @returns Completion of child exit. */
  10. stop(): Promise<void>
  11. }
  12. interface Attempt<Host> {
  13. cancelled: boolean
  14. failure?: Error
  15. host?: Host
  16. cleanup?: Promise<void>
  17. }
  18. /** Serializes retries and prevents children from outliving a closed window. */
  19. export class DesktopBackendController<Host extends DesktopBackendHost> {
  20. private current: DesktopBackendState = { phase: 'starting' }
  21. private attempt: Attempt<Host> | undefined
  22. private pending: Promise<void> | undefined
  23. private stopping: Promise<void> | undefined
  24. private closed = false
  25. /**
  26. * @param createHost - Allocate a child and route its fatal failures to the supplied callback.
  27. * @param publish - Receive availability changes until the controller closes.
  28. */
  29. constructor(
  30. private readonly createHost: (onFailure: (error: Error) => void) => Host,
  31. private readonly publish: (state: DesktopBackendState) => void,
  32. ) {}
  33. /** Current availability, including the last startup or child failure. */
  34. get state(): DesktopBackendState { return this.current }
  35. /** Child available to application requests; absent during startup and teardown. */
  36. get host(): Host | undefined { return !this.closed && !this.attempt?.cancelled && this.current.phase === 'ready' ? this.attempt?.host : undefined }
  37. /**
  38. * Prepare the profile and start one child; concurrent callers share the attempt.
  39. * @param prepare - Profile preparation that must finish before spawning.
  40. * @returns Completion of startup, rejecting on preparation, startup, or cleanup failure.
  41. */
  42. start(prepare: () => Promise<void>): Promise<void> {
  43. if (this.closed) return Promise.reject(new Error('desktop backend is closed'))
  44. if (this.stopping !== undefined) return Promise.reject(new Error('desktop backend is stopping'))
  45. if (this.pending !== undefined) return this.pending
  46. if (this.current.phase === 'ready') return Promise.resolve()
  47. const previous = this.attempt
  48. const attempt: Attempt<Host> = { cancelled: false, ...(previous?.cleanup === undefined ? {} : { cleanup: previous.cleanup }) }
  49. this.attempt = attempt
  50. this.update({ phase: 'starting' })
  51. const pending = Promise.resolve().then(async () => {
  52. try {
  53. await previous?.cleanup
  54. delete attempt.cleanup
  55. if (attempt.cancelled) return
  56. await prepare()
  57. // stop() can cancel this attempt while preparation is pending.
  58. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  59. if (attempt.cancelled) return
  60. const host = this.createHost((error) => { this.failed(attempt, error) })
  61. attempt.host = host
  62. await host.start()
  63. if (attempt.failure !== undefined) throw attempt.failure
  64. // stop() can cancel this attempt while the child starts.
  65. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  66. if (!attempt.cancelled) this.update({ phase: 'ready' })
  67. } catch (error) {
  68. const cancelled = attempt.cancelled
  69. attempt.cancelled = true
  70. let failure = error
  71. try { await this.cleanup(attempt) } catch (cleanupError) {
  72. if (cleanupError !== error) failure = new AggregateError([error, cleanupError], 'desktop backend startup and cleanup failed')
  73. }
  74. if (!cancelled) this.update(desktopErrorState(failure))
  75. throw failure
  76. }
  77. }).finally(() => { if (this.pending === pending) this.pending = undefined })
  78. this.pending = pending
  79. return pending
  80. }
  81. /**
  82. * Stop pending preparation and the child before allowing another start.
  83. * @returns Completion of pending work and child exit; rejects if cleanup fails.
  84. */
  85. stop(): Promise<void> {
  86. if (this.stopping !== undefined) return this.stopping
  87. const attempt = this.attempt
  88. if (attempt !== undefined) attempt.cancelled = true
  89. if (!this.closed) this.update({ phase: 'starting' })
  90. const pending = this.pending
  91. const stopping = Promise.allSettled([
  92. attempt === undefined ? Promise.resolve() : this.cleanup(attempt),
  93. pending,
  94. ]).then((results) => {
  95. const cleanup = results[0]
  96. if (cleanup.status === 'rejected') throw cleanup.reason
  97. if (this.attempt === attempt) this.attempt = undefined
  98. }).finally(() => { if (this.stopping === stopping) this.stopping = undefined })
  99. this.stopping = stopping
  100. return stopping
  101. }
  102. /**
  103. * Permanently prevent startup and suppress further availability notifications.
  104. * @returns Completion of pending work and child exit; rejects if cleanup fails.
  105. */
  106. close(): Promise<void> {
  107. this.closed = true
  108. return this.stop()
  109. }
  110. private cleanup(attempt: Attempt<Host>): Promise<void> {
  111. if (attempt.cleanup === undefined) {
  112. attempt.cleanup = Promise.resolve().then(async () => { await attempt.host?.stop() })
  113. }
  114. return attempt.cleanup
  115. }
  116. private failed(attempt: Attempt<Host>, error: Error): void {
  117. if (this.attempt !== attempt || attempt.cancelled) return
  118. attempt.failure = error
  119. if (this.current.phase !== 'ready') return
  120. attempt.cancelled = true
  121. const cleanup = this.cleanup(attempt)
  122. this.update(desktopErrorState(error))
  123. void cleanup.catch((cleanupError: unknown) => {
  124. if (this.attempt === attempt) this.update(desktopErrorState(new AggregateError([error, cleanupError], 'Desktop backend failed and could not stop')))
  125. })
  126. }
  127. private update(state: DesktopBackendState): void {
  128. if (this.closed) return
  129. this.current = state
  130. try { this.publish(state) } catch (error) {
  131. console.error('desktop backend state listener failed', error)
  132. }
  133. }
  134. }