index.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. /**
  2. * Local Service Provider for the subprocess capability seam. Each spawn is a detached
  3. * process tree with the spec's per-stream stdio dispositions. Normal disposal
  4. * terminates and joins live trees; Node's synchronous exit phase force-stops
  5. * any trees the service still owns. It has no config: every disposition and
  6. * limit arrives on the spec, so the deployment-varying choices stay with the
  7. * caller's config (the bash executor's, the LSP host's, …).
  8. * @module @deepseek-ai/dsh-subprocess-local
  9. */
  10. import { constants } from 'node:fs'
  11. import { access, stat } from 'node:fs/promises'
  12. import { delimiter, extname, isAbsolute, resolve } from 'node:path'
  13. import { Context } from '@deepseek-ai/cordis'
  14. import * as nodePty from 'node-pty'
  15. import type { IPtyForkOptions } from 'node-pty'
  16. import { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess'
  17. import type {
  18. SubprocessHandle,
  19. SubprocessSpawnSpec,
  20. SubprocessTerminalHandle,
  21. SubprocessTerminalSpawnSpec,
  22. } from '@deepseek-ai/dsh-subprocess'
  23. import { childEnv, spawnSubprocess } from './spawn.ts'
  24. import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
  25. import { createProcessInspector } from './process-inspector.ts'
  26. import type { ProcessInspector } from './process-inspector.ts'
  27. import { LocalTerminalHandle } from './terminal.ts'
  28. /**
  29. * Local subprocess service: detached process trees, Node-shaped stdio
  30. * dispositions (raw pipes, inherit, bounded tail-keep collection with spill
  31. * files), credential-scrubbed environment, and tree-scoped signalling with
  32. * SIGTERM→grace→SIGKILL escalation, plus synchronous final termination during
  33. * JavaScript-observable host exit.
  34. */
  35. export class LocalSubprocessRuntime extends SubprocessRuntime {
  36. /** Live handles retained for normal disposal and synchronous host-exit finalization. */
  37. private live = new Set<LocalSubprocessHandle>()
  38. /** Live terminals retained through normal quiescence or host-exit finalization. */
  39. private terminals = new Set<LocalTerminalHandle>()
  40. /** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
  41. internals: SpawnInternals = {}
  42. /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
  43. terminalInspector: ProcessInspector | undefined
  44. constructor(ctx: Context) {
  45. super(ctx)
  46. ctx.effect(() => {
  47. const onHostExit = (): void => { this.terminateForHostExit() }
  48. process.prependListener('exit', onHostExit)
  49. return async () => {
  50. try {
  51. await this.disposeManagedProcesses()
  52. } finally {
  53. process.off('exit', onHostExit)
  54. }
  55. }
  56. }, 'local subprocess teardown')
  57. }
  58. private terminateForHostExit(): void {
  59. for (const handle of this.live) {
  60. try {
  61. handle.terminateForHostExit()
  62. } catch (_ordinaryTreeTerminationFailed) {
  63. // Host exit cannot await or report one target; continue with the rest.
  64. }
  65. }
  66. for (const terminal of this.terminals) {
  67. try {
  68. terminal.terminateForHostExit()
  69. } catch (_terminalTerminationFailed) {
  70. // One terminal must not prevent final termination of another target.
  71. }
  72. }
  73. }
  74. private async disposeManagedProcesses(): Promise<void> {
  75. // Terminate (escalating), then await WHOLE-TREE exit — not just the
  76. // direct child's settlement — so even a TERM-trapping descendant cannot
  77. // outlive the fiber. Keep both sets authoritative while these waits are
  78. // pending so a shorter process-level exit bound can still force-kill them.
  79. const pending: Promise<unknown>[] = []
  80. for (const handle of this.live) {
  81. handle.terminate()
  82. // Spawn-failure rejections already settled and left the live set.
  83. pending.push(handle.done.catch(() => {}).then(() => handle.waitForExit()))
  84. }
  85. for (const terminal of this.terminals) {
  86. pending.push(terminal.terminate())
  87. }
  88. const outcomes = await Promise.allSettled(pending)
  89. const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'
  90. ? [outcome.reason as unknown]
  91. : [])
  92. if (failures.length > 0) this.terminateForHostExit()
  93. this.live.clear()
  94. this.terminals.clear()
  95. if (failures.length === 1) throw failures[0]
  96. if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed')
  97. }
  98. async resolveExecutable(
  99. command: string,
  100. env?: Readonly<Record<string, string>>,
  101. signal?: AbortSignal,
  102. ): Promise<string> {
  103. if (command.length === 0) throw new Error('subprocess-local: executable must be non-empty')
  104. signal?.throwIfAborted()
  105. const environment = childEnv(env)
  106. const absolute = isAbsolute(command)
  107. if (!absolute && (command.includes('/') || (process.platform === 'win32' && command.includes('\\')))) {
  108. throw new Error(
  109. `subprocess-local: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
  110. )
  111. }
  112. const candidates = absolute ? [command] : this.executableCandidates(command, environment)
  113. for (const candidate of candidates) {
  114. signal?.throwIfAborted()
  115. try {
  116. const info = await stat(candidate)
  117. if (!info.isFile()) continue
  118. await access(candidate, constants.X_OK)
  119. signal?.throwIfAborted()
  120. return candidate
  121. } catch {
  122. // Try the next PATH candidate; the final miss receives one stable error.
  123. }
  124. }
  125. signal?.throwIfAborted()
  126. throw new Error(absolute
  127. ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file`
  128. : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`)
  129. }
  130. private executableCandidates(command: string, env: NodeJS.ProcessEnv): string[] {
  131. const path = environmentValue(env, 'PATH') ?? ''
  132. const extensions = process.platform === 'win32' && extname(command) === ''
  133. ? (environmentValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';')
  134. : ['']
  135. return path.split(delimiter).flatMap(directory =>
  136. extensions.map(extension => resolve(process.cwd(), directory, command + extension)))
  137. }
  138. spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
  139. const handle = spawnSubprocess(spec, this.internals)
  140. this.live.add(handle)
  141. // Release ownership only once the whole TREE is gone, not at direct-child
  142. // settlement — a TERM-trapping helper that outlives the leader must stay
  143. // owned so teardown can still escalate it. For the common no-survivor
  144. // case waitForExit resolves immediately after settlement.
  145. const release = (): Promise<void> =>
  146. handle.waitForExit().then(() => { this.live.delete(handle) })
  147. handle.done.then(release, release)
  148. return handle
  149. }
  150. // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation.
  151. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
  152. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
  153. const file = spec.argv[0]
  154. if (file === undefined || file.length === 0) {
  155. throw new Error('subprocess-local: terminal argv must contain a program')
  156. }
  157. spec.signal?.throwIfAborted()
  158. const options: IPtyForkOptions = {
  159. name: 'dumb',
  160. rows: spec.rows,
  161. cols: spec.cols,
  162. cwd: spec.cwd,
  163. env: childEnv(spec.env),
  164. }
  165. const inspector = this.terminalInspector ?? createProcessInspector()
  166. const terminal = nodePty.spawn(file, [...spec.argv.slice(1)], options)
  167. const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs)
  168. this.terminals.add(handle)
  169. const release = async (): Promise<void> => {
  170. await handle.terminate()
  171. this.terminals.delete(handle)
  172. }
  173. void handle.done.then(release, release).catch(() => {})
  174. return handle
  175. }
  176. }
  177. /** Read a Windows environment key using the platform's case-insensitive semantics. */
  178. function environmentValue(env: NodeJS.ProcessEnv, name: 'PATH' | 'PATHEXT'): string | undefined {
  179. const exact = env[name]
  180. if (exact !== undefined || process.platform !== 'win32') return exact
  181. const normalized = name.toUpperCase()
  182. return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1]
  183. }
  184. export default LocalSubprocessRuntime