host-process.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. /** Electron Node-mode child lifecycle for the shared Web application. */
  2. import { spawn, type ChildProcess } from 'node:child_process'
  3. import { join } from 'node:path'
  4. import { desktopNodeEnvironment } from './node-environment.ts'
  5. interface ReadyEvent {
  6. readonly type: 'ready'
  7. readonly url: string
  8. }
  9. interface FatalEvent {
  10. readonly type: 'fatal'
  11. readonly message: string
  12. }
  13. type DesktopHostEvent = ReadyEvent | FatalEvent
  14. const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
  15. function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
  16. if (typeof message !== 'object' || message === null || !('type' in message)) return false
  17. const candidate = message as Record<string, unknown>
  18. switch (candidate.type) {
  19. case 'ready':
  20. return typeof candidate.url === 'string'
  21. case 'fatal':
  22. return typeof candidate.message === 'string'
  23. default:
  24. return false
  25. }
  26. }
  27. async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
  28. let timer: ReturnType<typeof setTimeout> | undefined
  29. const timeout = new Promise<false>((resolve) => {
  30. timer = setTimeout(() => { resolve(false) }, milliseconds)
  31. timer.unref()
  32. })
  33. try {
  34. return await Promise.race([exit.then(() => true), timeout])
  35. } finally {
  36. if (timer !== undefined) clearTimeout(timer)
  37. }
  38. }
  39. /** Browser authentication URL reported by the running Web application. */
  40. export interface DesktopHostReady {
  41. readonly url: string
  42. }
  43. /** One Web backend running under the Electron executable in Node mode. */
  44. export class DesktopHostProcess {
  45. private child: ChildProcess | undefined
  46. private readyResolve!: (ready: DesktopHostReady) => void
  47. private readyReject!: (error: Error) => void
  48. private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
  49. this.readyResolve = resolve
  50. this.readyReject = reject
  51. })
  52. private exitPromise: Promise<void> | undefined
  53. private stderr = ''
  54. private failureReported = false
  55. private stopping = false
  56. /**
  57. * @param node - Absolute Electron executable in Node mode.
  58. * @param runtimeDir - Immutable packages carried by the current application.
  59. * @param projectDir - Desktop plugin profile and child working directory.
  60. * @param inspectPort - Optional loopback inspector port for workspace development.
  61. * @param environment - Environment inherited by the Host and its plugin subprocesses.
  62. * @param onFailure - Receives the first unexpected child failure, including after readiness.
  63. */
  64. constructor(
  65. private readonly node: string,
  66. private readonly runtimeDir: string,
  67. private readonly projectDir: string,
  68. private readonly inspectPort?: number,
  69. private readonly environment: NodeJS.ProcessEnv = process.env,
  70. private readonly onFailure?: (error: Error) => void,
  71. ) {}
  72. /**
  73. * Start this child once and await its Web application URL.
  74. * @returns Ready facts supplied by the child after application startup.
  75. */
  76. async start(): Promise<DesktopHostReady> {
  77. if (this.child !== undefined) return this.readyPromise
  78. const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
  79. const child = spawn(this.node, [
  80. '--expose-internals',
  81. ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
  82. entry,
  83. this.runtimeDir,
  84. this.projectDir,
  85. ], {
  86. cwd: this.projectDir,
  87. env: desktopNodeEnvironment(this.node, undefined, this.environment),
  88. stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
  89. })
  90. this.child = child
  91. child.stderr?.setEncoding('utf8')
  92. child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })
  93. child.stdout?.pipe(process.stdout)
  94. child.on('message', (message: unknown) => {
  95. if (!isDesktopHostEvent(message)) {
  96. this.fail(new Error('dsh desktop host sent an invalid IPC event'))
  97. child.kill('SIGTERM')
  98. return
  99. }
  100. if (message.type === 'ready') this.readyResolve({ url: message.url })
  101. else this.fail(new Error(message.message))
  102. })
  103. child.once('error', (error) => { this.fail(error) })
  104. this.exitPromise = new Promise<void>((resolve) => {
  105. child.once('close', (code) => {
  106. const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
  107. if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
  108. else this.fail(new Error(`dsh desktop host stopped${suffix}`))
  109. resolve()
  110. })
  111. })
  112. return this.readyPromise
  113. }
  114. /** Request graceful teardown and await exit, escalating termination when needed. */
  115. async stop(): Promise<void> {
  116. const child = this.child
  117. if (child === undefined) return
  118. this.stopping = true
  119. if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
  120. const exited = this.exitPromise ?? Promise.resolve()
  121. if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
  122. if (!await exitsWithin(exited, 5_000)) {
  123. child.kill('SIGKILL')
  124. if (!await exitsWithin(exited, 5_000)) {
  125. throw new Error('dsh desktop host did not exit after SIGKILL')
  126. }
  127. }
  128. this.child = undefined
  129. }
  130. private fail(error: Error): void {
  131. this.readyReject(error)
  132. if (!this.failureReported && !this.stopping) {
  133. this.failureReported = true
  134. try { this.onFailure?.(error) } catch (listenerError) {
  135. console.error('desktop host failure listener failed', listenerError)
  136. }
  137. }
  138. }
  139. }