Files
deepseek-harness/apps/desktop/src/host-process.ts
T

165 lines
6.4 KiB
TypeScript

/** 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
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
readonly injections?: readonly unknown[] | undefined
}
/** 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.
* @param primaryRuntime - Optional bundled dependency payload; when supplied, missing sibling
* `office-skills` resources fail Host startup.
* @param packageManager - Bundled pnpm entry and Node launcher directory, scoped to package operations.
* @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',
private readonly packageManager?: { readonly pnpm: string; readonly nodeBin: string },
) {}
/**
* 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,
...this.packageManager === undefined ? [] : [this.packageManager.pnpm, this.packageManager.nodeBin],
], {
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 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)
}
}
}
}