| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- /** 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
- readonly injections?: readonly unknown[] | undefined
- }
- interface FatalEvent {
- readonly type: 'fatal'
- readonly message: string
- }
- type DesktopHostEvent = ReadyEvent | FatalEvent | { readonly type: 'shutdown-complete' } | {
- readonly type: 'update-tasks'
- readonly requestId: number
- readonly active: boolean
- readonly error?: string
- }
- 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 'shutdown-complete':
- return true
- case 'ready':
- return typeof candidate.url === 'string'
- case 'fatal':
- return typeof candidate.message === 'string'
- case 'update-tasks':
- return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'
- && (candidate.error === undefined || typeof candidate.error === '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
- readonly injections?: readonly unknown[] | undefined
- }
- /** The child has exited, but task teardown did not finish successfully. */
- export class DesktopHostUncleanExitError extends Error {}
- /** 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
- private shutdownCompleted = false
- private nextControlId = 1
- private readonly taskQueries = new Map<number, { resolve: (active: boolean) => void; reject: (error: Error) => void }>()
- /**
- * @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.
- * @param primaryRuntime - Optional payload location for bundled script dependencies.
- * @param profileResolution - Package resolution mode for the application-owned profile.
- */
- 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,
- private readonly primaryRuntime?: string,
- private readonly profileResolution: 'link' | 'runtime' = 'link',
- ) {}
- /**
- * 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,
- this.primaryRuntime ?? join(this.runtimeDir, '..', 'runtime', 'primary-runtime'),
- this.profileResolution,
- ], {
- 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, injections: message.injections })
- else if (message.type === 'shutdown-complete') {
- if (this.stopping) this.shutdownCompleted = true
- else this.fail(new Error('dsh desktop host acknowledged an unrequested shutdown'))
- }
- else if (message.type === 'fatal') this.fail(new Error(message.message))
- else {
- const query = this.taskQueries.get(message.requestId)
- if (message.error === undefined) query?.resolve(message.active)
- else query?.reject(new Error(message.error))
- }
- })
- 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
- }
- /**
- * Inspect active work or lock request admission for update handoff.
- * @param action - Read-only inspection, admission lock, or recovery unlock.
- * @returns Whether live tasks would be affected. Locking drains admitted API requests before inspecting tasks;
- * an unanswered drain fails at the control-request deadline without authorizing installation.
- */
- async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {
- const child = this.child
- if (child === undefined || !child.connected || this.failureReported || this.stopping) {
- throw new Error('desktop update: Host is unavailable')
- }
- const requestId = this.nextControlId++
- let timer: ReturnType<typeof setTimeout> | undefined
- try {
- return await new Promise<boolean>((resolve, reject) => {
- this.taskQueries.set(requestId, { resolve, reject })
- timer = setTimeout(() => { reject(new Error('desktop update: task inspection timed out')) }, 10_000)
- child.send({ type: 'update-tasks', requestId, action }, (error) => { if (error !== null) reject(error) })
- })
- } finally {
- clearTimeout(timer)
- this.taskQueries.delete(requestId)
- }
- }
- /**
- * Request teardown and await child exit, escalating termination when needed.
- * @param requireGraceful - Reject update handoff after forced termination or unsuccessful child exit.
- * @returns Completion of owned process teardown. DesktopHostUncleanExitError confirms exit but refuses installation;
- * other failures do not confirm exit.
- */
- async stop(requireGraceful = false): 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()
- const graceful = await exitsWithin(exited, 10_000)
- if (!graceful) 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
- if (requireGraceful && (!graceful || child.exitCode !== 0 || !this.shutdownCompleted)) {
- // This diagnostic reaches expandable UI; arbitrary plugin stderr can contain credentials.
- 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)})`)
- }
- }
- private fail(error: Error): void {
- this.readyReject(error)
- for (const query of this.taskQueries.values()) query.reject(error)
- this.taskQueries.clear()
- if (!this.failureReported && !this.stopping) {
- this.failureReported = true
- try { this.onFailure?.(error) } catch (listenerError) {
- console.error('desktop host failure listener failed', listenerError)
- }
- }
- }
- }
|