| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152 |
- /** Electron Node-mode child lifecycle for the shared Web application. */
- import { spawn, type ChildProcess } from 'node:child_process'
- import { join } from 'node:path'
- import { desktopNodeEnvironment } from './node-environment.ts'
- interface ReadyEvent {
- readonly type: 'ready'
- readonly url: string
- }
- interface FatalEvent {
- readonly type: 'fatal'
- readonly message: string
- }
- type DesktopHostEvent = ReadyEvent | FatalEvent
- const MAX_HOST_DIAGNOSTIC_CHARS = 64 * 1024
- function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
- if (typeof message !== 'object' || message === null || !('type' in message)) return false
- const candidate = message as Record<string, unknown>
- switch (candidate.type) {
- case 'ready':
- return typeof candidate.url === 'string'
- case 'fatal':
- return typeof candidate.message === 'string'
- default:
- return false
- }
- }
- async function exitsWithin(exit: Promise<void>, milliseconds: number): Promise<boolean> {
- let timer: ReturnType<typeof setTimeout> | undefined
- const timeout = new Promise<false>((resolve) => {
- timer = setTimeout(() => { resolve(false) }, milliseconds)
- timer.unref()
- })
- try {
- return await Promise.race([exit.then(() => true), timeout])
- } finally {
- if (timer !== undefined) clearTimeout(timer)
- }
- }
- /** Browser authentication URL reported by the running Web application. */
- export interface DesktopHostReady {
- readonly url: string
- }
- /** One Web backend running under the Electron executable in Node mode. */
- export class DesktopHostProcess {
- private child: ChildProcess | undefined
- private readyResolve!: (ready: DesktopHostReady) => void
- private readyReject!: (error: Error) => void
- private readonly readyPromise = new Promise<DesktopHostReady>((resolve, reject) => {
- this.readyResolve = resolve
- this.readyReject = reject
- })
- private exitPromise: Promise<void> | undefined
- private stderr = ''
- private failureReported = false
- private stopping = false
- /**
- * @param node - Absolute Electron executable in Node mode.
- * @param runtimeDir - Immutable packages carried by the current application.
- * @param projectDir - Desktop plugin profile and child working directory.
- * @param inspectPort - Optional loopback inspector port for workspace development.
- * @param environment - Environment inherited by the Host and its plugin subprocesses.
- * @param onFailure - Receives the first unexpected child failure, including after readiness.
- */
- constructor(
- private readonly node: string,
- private readonly runtimeDir: string,
- private readonly projectDir: string,
- private readonly inspectPort?: number,
- private readonly environment: NodeJS.ProcessEnv = process.env,
- private readonly onFailure?: (error: Error) => void,
- ) {}
- /**
- * Start this child once and await its Web application URL.
- * @returns Ready facts supplied by the child after application startup.
- */
- async start(): Promise<DesktopHostReady> {
- if (this.child !== undefined) return this.readyPromise
- const entry = join(this.runtimeDir, 'node_modules', '@deepseek-ai', 'dsh-desktop-host', 'lib', 'index.js')
- const child = spawn(this.node, [
- '--expose-internals',
- ...(this.inspectPort === undefined ? [] : [`--inspect=127.0.0.1:${String(this.inspectPort)}`]),
- entry,
- this.runtimeDir,
- this.projectDir,
- ], {
- cwd: this.projectDir,
- env: desktopNodeEnvironment(this.node, undefined, this.environment),
- stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
- })
- this.child = child
- child.stderr?.setEncoding('utf8')
- child.stderr?.on('data', (chunk: string) => { this.stderr = (this.stderr + chunk).slice(-MAX_HOST_DIAGNOSTIC_CHARS) })
- child.stdout?.pipe(process.stdout)
- child.on('message', (message: unknown) => {
- if (!isDesktopHostEvent(message)) {
- this.fail(new Error('dsh desktop host sent an invalid IPC event'))
- child.kill('SIGTERM')
- return
- }
- if (message.type === 'ready') this.readyResolve({ url: message.url })
- else this.fail(new Error(message.message))
- })
- child.once('error', (error) => { this.fail(error) })
- this.exitPromise = new Promise<void>((resolve) => {
- child.once('close', (code) => {
- const suffix = this.stderr.trim() === '' ? '' : `: ${this.stderr.trim()}`
- if (code !== 0 && code !== null) this.fail(new Error(`dsh desktop host exited with ${String(code)}${suffix}`))
- else this.fail(new Error(`dsh desktop host stopped${suffix}`))
- resolve()
- })
- })
- return this.readyPromise
- }
- /** Request graceful teardown and await exit, escalating termination when needed. */
- async stop(): Promise<void> {
- const child = this.child
- if (child === undefined) return
- this.stopping = true
- if (child.connected) child.send({ type: 'shutdown' }, (error) => { if (error !== null) this.fail(error) })
- const exited = this.exitPromise ?? Promise.resolve()
- if (!await exitsWithin(exited, 10_000)) child.kill('SIGTERM')
- if (!await exitsWithin(exited, 5_000)) {
- child.kill('SIGKILL')
- if (!await exitsWithin(exited, 5_000)) {
- throw new Error('dsh desktop host did not exit after SIGKILL')
- }
- }
- this.child = undefined
- }
- private fail(error: Error): void {
- this.readyReject(error)
- if (!this.failureReported && !this.stopping) {
- this.failureReported = true
- try { this.onFailure?.(error) } catch (listenerError) {
- console.error('desktop host failure listener failed', listenerError)
- }
- }
- }
- }
|