Просмотр исходного кода

refactor(plugins): share the child close settlement with the package probe

Yichen Jiang 1 месяц назад
Родитель
Сommit
f43259f9de

+ 37 - 0
packages/boot/app-boot/src/child-close.ts

@@ -0,0 +1,37 @@
+/**
+ * One settlement for a spawned child: the exit code on `close`, the spawn
+ * failure on `error`, or a timeout that kills the child. Shared by the
+ * package probe and the plugin manager's pnpm runs, which otherwise each
+ * wrote the same first-outcome-wins guard.
+ */
+
+import type { ChildProcess } from 'node:child_process'
+
+/**
+ * Wait for a child to close, at most `timeoutMs`.
+ *
+ * A spawn failure emits `error` and then `close`, and a timeout kill emits
+ * `close` after the rejection, so the first outcome wins and the later
+ * events are ignored.
+ * @param child - the spawned process; the caller consumes its streams.
+ * @param timeoutMs - how long to wait before killing the child with SIGKILL.
+ * @param timedOut - builds the rejection reported for a timeout.
+ * @returns the exit code `close` reported; null when a signal ended the child.
+ */
+export function awaitChildClose(child: ChildProcess, timeoutMs: number, timedOut: () => Error): Promise<number | null> {
+  return new Promise((resolve, reject) => {
+    let settled = false
+    const settle = (outcome: () => void): void => {
+      if (settled) return
+      settled = true
+      clearTimeout(timer)
+      outcome()
+    }
+    const timer = setTimeout(() => {
+      child.kill('SIGKILL')
+      settle(() => { reject(timedOut()) })
+    }, timeoutMs)
+    child.on('error', (error) => { settle(() => { reject(error) }) })
+    child.on('close', (code) => { settle(() => { resolve(code) }) })
+  })
+}

+ 1 - 0
packages/boot/app-boot/src/index.ts

@@ -74,6 +74,7 @@ export {
 export {
   ProfileRuntime, type ProfileRuntimeOptions, type RowOrigin,
 } from './profile-runtime.ts'
+export { awaitChildClose } from './child-close.ts'
 export {
   PLUGIN_PROBE_DIR, PLUGIN_PROBE_FORMAT, probePackage, readProbeCache, writeProbeCache,
   type PluginProbe, type PluginProbeRow, type ProbeOptions,

+ 21 - 37
packages/boot/app-boot/src/probe.ts

@@ -8,6 +8,7 @@
  */
 
 import { spawn } from 'node:child_process'
+import { awaitChildClose } from './child-close.ts'
 import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
 import { join } from 'node:path'
 import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
@@ -152,45 +153,28 @@ process.stdout.write(JSON.stringify(report))
 `
 
 /** Run the child and parse its report. */
-function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
+async function runChild(options: ProbeOptions, packageDir: string, mainSpecifier: string, addable: string[]): Promise<ChildReport> {
   const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
-  return new Promise((resolve, reject) => {
-    const child = spawn(
-      options.nodeExecutable ?? process.execPath,
-      ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)],
-      { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
+  const child = spawn(
+    options.nodeExecutable ?? process.execPath,
+    ['--experimental-import-meta-resolve', '--input-type=module', '-e', CHILD_SCRIPT, '--', packageDir, mainSpecifier, JSON.stringify(addable)],
+    { cwd: options.profileDir, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, NODE_NO_WARNINGS: '1' } },
+  )
+  const out: Buffer[] = []
+  const err: Buffer[] = []
+  child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
+  child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
+  const code = await awaitChildClose(
+    child, timeoutMs, () => new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`),
+  )
+  const stdout = Buffer.concat(out).toString('utf8')
+  try {
+    return JSON.parse(stdout) as ChildReport
+  } catch {
+    throw new Error(
+      `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`,
     )
-    const out: Buffer[] = []
-    const err: Buffer[] = []
-    child.stdout.on('data', (chunk: Buffer) => out.push(chunk))
-    child.stderr.on('data', (chunk: Buffer) => err.push(chunk))
-    // One settlement: a spawn failure emits `error` and then `close`, and a
-    // timeout kill emits `close` after the rejection below.
-    let settled = false
-    const settle = (outcome: () => void): void => {
-      if (settled) return
-      settled = true
-      clearTimeout(timer)
-      outcome()
-    }
-    const timer = setTimeout(() => {
-      child.kill('SIGKILL')
-      settle(() => { reject(new Error(`${options.binName}: probe of ${options.packageName} timed out after ${String(timeoutMs)}ms`)) })
-    }, timeoutMs)
-    child.on('error', (error) => { settle(() => { reject(error) }) })
-    child.on('close', (code) => {
-      settle(() => {
-        const stdout = Buffer.concat(out).toString('utf8')
-        try {
-          resolve(JSON.parse(stdout) as ChildReport)
-        } catch {
-          reject(new Error(
-            `${options.binName}: probe of ${options.packageName} exited with ${String(code)} without a report: ${Buffer.concat(err).toString('utf8').trim()}`,
-          ))
-        }
-      })
-    })
-  })
+  }
 }
 
 const DEFAULT_TIMEOUT_MS = 20_000

+ 5 - 15
packages/host/plugin-manager/src/index.ts

@@ -24,6 +24,7 @@ import type { Entry } from '@deepseek-ai/cordis-plugin-loader'
 import z from '@deepseek-ai/schemastery'
 import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
 import {
+  awaitChildClose,
   bundleGroupId,
   claimLayerIds,
   disableBundle,
@@ -885,21 +886,10 @@ export class PluginManager extends TypertRemoteService {
     child.stderr?.setEncoding('utf8')
     child.stdout?.on('data', (text: string) => { record('stdout', text) })
     child.stderr?.on('data', (text: string) => { record('stderr', text) })
-    const exitCode = await new Promise<number | null>((resolve, reject) => {
-      let settled = false
-      const settle = (outcome: () => void): void => {
-        if (settled) return
-        settled = true
-        clearTimeout(timer)
-        outcome()
-      }
-      const timer = setTimeout(() => {
-        child.kill('SIGKILL')
-        settle(() => { reject(new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`)) })
-      }, this.config.installTimeoutMs)
-      child.on('error', (error) => { settle(() => { reject(error) }) })
-      child.on('close', (code) => { settle(() => { resolve(code) }) })
-    }).catch((error: unknown) => {
+    const exitCode = await awaitChildClose(
+      child, this.config.installTimeoutMs,
+      () => new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(this.config.installTimeoutMs)}ms`),
+    ).catch((error: unknown) => {
       const message = messageOf(error)
       record('stderr', `${message}\n`)
       this.ctx.emit('plugins/install-log', { jobId, spec, stream: 'stderr', text: '', exitCode: null })