host-process.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  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. readonly injections?: readonly unknown[] | undefined
  9. }
  10. interface FatalEvent {
  11. readonly type: 'fatal'
  12. readonly message: string
  13. }
  14. type DesktopHostEvent = ReadyEvent | FatalEvent | { readonly type: 'shutdown-complete' } | {
  15. readonly type: 'update-tasks'
  16. readonly requestId: number
  17. readonly active: boolean
  18. readonly error?: string
  19. }
  20. const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
  21. function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
  22. if (typeof message !== 'object' || message === null || !('type' in message)) return false
  23. const candidate = message as Record<string, unknown>
  24. switch (candidate.type) {
  25. case 'shutdown-complete':
  26. return true
  27. case 'ready':
  28. return typeof candidate.url === 'string'
  29. case 'fatal':
  30. return typeof candidate.message === 'string'
  31. case 'update-tasks':
  32. return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'
  33. && (candidate.error === undefined || typeof candidate.error === 'string')
  34. default:
  35. return false
  36. }
  37. }
  38. async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
  39. let timer: ReturnType<typeof setTimeout> | undefined
  40. const timeout = new Promise<false>((resolve) => {
  41. timer = setTimeout(() => { resolve(false) }, milliseconds)
  42. timer.unref()
  43. })
  44. try {
  45. return await Promise.race([exit.then(() => true), timeout])
  46. } finally {
  47. if (timer !== undefined) clearTimeout(timer)
  48. }
  49. }
  50. /** Browser authentication URL reported by the running Web application. */
  51. export interface DesktopHostReady {
  52. readonly url: string
  53. readonly injections?: readonly unknown[] | undefined
  54. }
  55. /** The child has exited, but task teardown did not finish successfully. */
  56. export class DesktopHostUncleanExitError extends Error {}
  57. /** One Web backend running under the Electron executable in Node mode. */
  58. export class DesktopHostProcess {
  59. private child: ChildProcess | undefined
  60. private readyResolve!: (ready: DesktopHostReady) => void
  61. private readyReject!: (error: Error) => void
  62. private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
  63. this.readyResolve = resolve
  64. this.readyReject = reject
  65. })
  66. private exitPromise: Promise<void> | undefined
  67. private stderr = ''
  68. private failureReported = false
  69. private stopping = false
  70. private shutdownCompleted = false
  71. private nextControlId = 1
  72. private readonly taskQueries = new Map<number, { resolve: (active: boolean) => void; reject: (error: Error) => void }>()
  73. /**
  74. * @param node - Absolute Electron executable in Node mode.
  75. * @param runtimeDir - Immutable packages carried by the current application.
  76. * @param projectDir - Desktop plugin profile and child working directory.
  77. * @param inspectPort - Optional loopback inspector port for workspace development.
  78. * @param environment - Environment inherited by the Host and its plugin subprocesses.
  79. * @param onFailure - Receives the first unexpected child failure, including after readiness.
  80. * @param primaryRuntime - Optional payload location for bundled script dependencies.
  81. * @param profileResolution - Package resolution mode for the application-owned profile.
  82. */
  83. constructor(
  84. private readonly node: string,
  85. private readonly runtimeDir: string,
  86. private readonly projectDir: string,
  87. private readonly inspectPort?: number,
  88. private readonly environment: NodeJS.ProcessEnv = process.env,
  89. private readonly onFailure?: (error: Error) => void,
  90. private readonly primaryRuntime?: string,
  91. private readonly profileResolution: 'link' | 'runtime' = 'link',
  92. ) {}
  93. /**
  94. * Start this child once and await its Web application URL.
  95. * @returns Ready facts supplied by the child after application startup.
  96. */
  97. async start(): Promise<DesktopHostReady> {
  98. if (this.child !== undefined) return this.readyPromise
  99. const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
  100. const child = spawn(this.node, [
  101. '--expose-internals',
  102. ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
  103. entry,
  104. this.runtimeDir,
  105. this.projectDir,
  106. this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
  107. this.profileResolution,
  108. ], {
  109. cwd: this.projectDir,
  110. env: desktopNodeEnvironment(this.node, undefined, this.environment),
  111. stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
  112. })
  113. this.child = child
  114. child.stderr?.setEncoding('utf8')
  115. child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })
  116. child.stdout?.pipe(process.stdout)
  117. child.on('message', (message: unknown) => {
  118. if (!isDesktopHostEvent(message)) {
  119. this.fail(new Error('dsh desktop host sent an invalid IPC event'))
  120. child.kill('SIGTERM')
  121. return
  122. }
  123. if (message.type === 'ready') this.readyResolve({ url: message.url, injections: message.injections })
  124. else if (message.type === 'shutdown-complete') {
  125. if (this.stopping) this.shutdownCompleted = true
  126. else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown'))
  127. }
  128. else if (message.type === 'fatal') this.fail(new Error(message.message))
  129. else {
  130. const query = this.taskQueries.get(message.requestId)
  131. if (message.error === undefined) query?.resolve(message.active)
  132. else query?.reject(new Error(message.error))
  133. }
  134. })
  135. child.once('error', (error) => { this.fail(error) })
  136. this.exitPromise = new Promise<void>((resolve) => {
  137. child.once('close', (code) => {
  138. const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
  139. if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
  140. else this.fail(new Error(`dsh desktop host stopped${suffix}`))
  141. resolve()
  142. })
  143. })
  144. return this.readyPromise
  145. }
  146. /**
  147. * Inspect active work or lock request admission for update handoff.
  148. * @param action - Read-only inspection, admission lock, or recovery unlock.
  149. * @returns Whether live tasks would be affected. Locking drains admitted API requests before inspecting tasks;
  150. * an unanswered drain fails at the control-request deadline without authorizing installation.
  151. */
  152. async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {
  153. const child = this.child
  154. if (child === undefined || !child.connected || this.failureReported || this.stopping) {
  155. throw new Error('desktop update: Host is unavailable')
  156. }
  157. const requestId = this.nextControlId++
  158. let timer: ReturnType<typeof setTimeout> | undefined
  159. try {
  160. return await new Promise<boolean>((resolve, reject) => {
  161. this.taskQueries.set(requestId, { resolve, reject })
  162. timer = setTimeout(() => { reject(new Error('desktop update: task inspection timed out')) }, 10_000)
  163. child.send({ type: 'update-tasks', requestId, action }, (error) => { if (error !== null) reject(error) })
  164. })
  165. } finally {
  166. clearTimeout(timer)
  167. this.taskQueries.delete(requestId)
  168. }
  169. }
  170. /**
  171. * Request teardown and await child exit, escalating termination when needed.
  172. * @param requireGraceful - Reject update handoff after forced termination or unsuccessful child exit.
  173. * @returns Completion of owned process teardown. DesktopHostUncleanExitError confirms exit but refuses installation;
  174. * other failures do not confirm exit.
  175. */
  176. async stop(requireGraceful = false): Promise<void> {
  177. const child = this.child
  178. if (child === undefined) return
  179. this.stopping = true
  180. if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
  181. const exited = this.exitPromise ?? Promise.resolve()
  182. const graceful = await exitsWithin(exited, 10_000)
  183. if (!graceful) child.kill('SIGTERM')
  184. if (!await exitsWithin(exited, 5_000)) {
  185. child.kill('SIGKILL')
  186. if (!await exitsWithin(exited, 5_000)) {
  187. throw new Error('dsh desktop host did not exit after SIGKILL')
  188. }
  189. }
  190. this.child = undefined
  191. if (requireGraceful && (!graceful || child.exitCode !== 0 || !this.shutdownCompleted)) {
  192. // This diagnostic reaches expandable UI; arbitrary plugin stderr can contain credentials.
  193. throw new DesktopHostUncleanExitError(`desktop update: Host did not complete graceful task teardown (exit ${String(child.exitCode)}, signal ${String(child.signalCode)}, shutdown acknowledged ${String(this.shutdownCompleted)}, graceful deadline exceeded ${String(!graceful)})`)
  194. }
  195. }
  196. private fail(error: Error): void {
  197. this.readyReject(error)
  198. for (const query of this.taskQueries.values()) query.reject(error)
  199. this.taskQueries.clear()
  200. if (!this.failureReported && !this.stopping) {
  201. this.failureReported = true
  202. try { this.onFailure?.(error) } catch (listenerError) {
  203. console.error('desktop host failure listener failed', listenerError)
  204. }
  205. }
  206. }
  207. }