index.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. /**
  2. * Local Service Provider for the subprocess capability seam. Each spawn owns a
  3. * platform-selected managed range with the spec's per-stream stdio dispositions.
  4. * Normal disposal terminates and joins live ranges; Node's synchronous exit
  5. * phase force-stops any ranges the service still owns. It has no config: every
  6. * disposition and limit arrives on the spec, so deployment-varying choices
  7. * stay with the 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 {
  24. bindManagedProcess,
  25. childEnv,
  26. prepareManagedProcessBinding,
  27. spawnSubprocess,
  28. validateSubprocessSpec,
  29. } from './spawn.ts'
  30. import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'
  31. import { launchLinuxScope, probeLinuxScope } from './linux-scope.ts'
  32. import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
  33. import { createProcessInspector } from './process-inspector.ts'
  34. import type { ProcessInspector } from './process-inspector.ts'
  35. import { LocalTerminalHandle } from './terminal.ts'
  36. /**
  37. * Local subprocess service: platform-selected managed ranges, Node-shaped stdio
  38. * dispositions (raw pipes, inherit, bounded tail-keep collection with spill
  39. * files), credential-scrubbed environment, and provider-owned range signalling.
  40. * POSIX paths stage TERM before KILL; Windows paths terminate immediately.
  41. * JavaScript-observable host exit also performs synchronous final termination.
  42. */
  43. export class LocalSubprocessRuntime extends SubprocessRuntime {
  44. /** Live handles retained for normal disposal and synchronous host-exit finalization. */
  45. private live = new Set<LocalSubprocessHandle>()
  46. /** Live terminals retained through normal quiescence or host-exit finalization. */
  47. private terminals = new Set<LocalTerminalHandle>()
  48. /** Test hook: spill and platform knobs forwarded to spawnSubprocess. */
  49. internals: SpawnInternals = {}
  50. /** Ordinary native containment mode, selected once before its first user command. */
  51. private ordinaryMode: 'linux-scope' | 'windows-job' | 'fallback' | undefined
  52. /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
  53. terminalInspector: ProcessInspector | undefined
  54. constructor(ctx: Context) {
  55. super(ctx)
  56. ctx.effect(() => {
  57. const onHostExit = (): void => { this.terminateForHostExit() }
  58. process.prependListener('exit', onHostExit)
  59. return async () => {
  60. try {
  61. await this.disposeManagedProcesses()
  62. } finally {
  63. process.off('exit', onHostExit)
  64. }
  65. }
  66. }, 'local subprocess teardown')
  67. }
  68. private terminateForHostExit(): void {
  69. for (const handle of this.live) {
  70. try {
  71. handle.terminateForHostExit()
  72. } catch (_ordinaryRangeTerminationFailed) {
  73. // Host exit cannot await or report one target; continue with the rest.
  74. }
  75. }
  76. for (const terminal of this.terminals) {
  77. try {
  78. terminal.terminateForHostExit()
  79. } catch (_terminalTerminationFailed) {
  80. // One terminal must not prevent final termination of another target.
  81. }
  82. }
  83. }
  84. private async disposeManagedProcesses(): Promise<void> {
  85. // Request termination, then await MANAGED-RANGE exit — not just the
  86. // direct command's settlement — so even a surviving descendant cannot
  87. // outlive the fiber. Keep both sets authoritative while these waits are
  88. // pending so a shorter process-level exit bound can still force-kill them.
  89. const pending: Promise<unknown>[] = []
  90. for (const handle of this.live) {
  91. handle.terminate()
  92. // Direct result and range observation are independent. Start both now so
  93. // an unreadable owner cannot hide behind a target that termination failed
  94. // to stop; direct-result rejection itself remains non-fatal to disposal.
  95. pending.push(Promise.all([
  96. handle.done.catch(() => {}),
  97. handle.waitForExit(),
  98. ]).then(() => undefined))
  99. }
  100. for (const terminal of this.terminals) {
  101. pending.push(terminal.terminate())
  102. }
  103. const outcomes = await Promise.allSettled(pending)
  104. const failures = outcomes.flatMap<unknown>(outcome => outcome.status === 'rejected'
  105. ? [outcome.reason as unknown]
  106. : [])
  107. if (failures.length > 0) this.terminateForHostExit()
  108. this.live.clear()
  109. this.terminals.clear()
  110. if (failures.length === 1) throw failures[0]
  111. if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed')
  112. }
  113. async resolveExecutable(
  114. command: string,
  115. env?: Readonly<Record<string, string>>,
  116. signal?: AbortSignal,
  117. ): Promise<string> {
  118. if (command.length === 0) throw new Error('subprocess-local: executable must be non-empty')
  119. signal?.throwIfAborted()
  120. const environment = childEnv(env)
  121. const absolute = isAbsolute(command)
  122. if (!absolute && (command.includes('/') || (process.platform === 'win32' && command.includes('\\')))) {
  123. throw new Error(
  124. `subprocess-local: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
  125. )
  126. }
  127. const candidates = absolute ? [command] : this.executableCandidates(command, environment)
  128. for (const candidate of candidates) {
  129. signal?.throwIfAborted()
  130. try {
  131. const info = await stat(candidate)
  132. if (!info.isFile()) continue
  133. await access(candidate, constants.X_OK)
  134. signal?.throwIfAborted()
  135. return candidate
  136. } catch {
  137. // Try the next PATH candidate; the final miss receives one stable error.
  138. }
  139. }
  140. signal?.throwIfAborted()
  141. throw new Error(absolute
  142. ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file`
  143. : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`)
  144. }
  145. private executableCandidates(command: string, env: NodeJS.ProcessEnv): string[] {
  146. const path = environmentValue(env, 'PATH') ?? ''
  147. const extensions = process.platform === 'win32' && extname(command) === ''
  148. ? (environmentValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';')
  149. : ['']
  150. return path.split(delimiter).flatMap(directory =>
  151. extensions.map(extension => resolve(process.cwd(), directory, command + extension)))
  152. }
  153. spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
  154. validateSubprocessSpec(spec)
  155. const mode = this.selectOrdinaryMode()
  156. let handle: LocalSubprocessHandle
  157. if (mode === 'fallback') {
  158. handle = spawnSubprocess(spec, this.internals)
  159. } else {
  160. const binding = prepareManagedProcessBinding(this.internals)
  161. const launch = mode === 'linux-scope' ? launchLinuxScope(spec) : launchWindowsJob(spec)
  162. handle = bindManagedProcess(spec, launch, binding)
  163. }
  164. this.live.add(handle)
  165. // Release ownership only once the managed range is empty, not at spawned-
  166. // command settlement. A surviving helper remains owned until provider
  167. // termination and observation reach quiescence.
  168. const release = (): Promise<void> =>
  169. handle.waitForExit().then(() => { this.live.delete(handle) })
  170. void handle.done.then(release, release).catch(() => {})
  171. return handle
  172. }
  173. private selectOrdinaryMode(): 'linux-scope' | 'windows-job' | 'fallback' {
  174. if (this.ordinaryMode !== undefined) return this.ordinaryMode
  175. const platform = this.internals.platform ?? process.platform
  176. if (platform === 'linux' && probeLinuxScope()) this.ordinaryMode = 'linux-scope'
  177. else if (platform === 'win32' && probeWindowsJob()) this.ordinaryMode = 'windows-job'
  178. else this.ordinaryMode = 'fallback'
  179. if (this.ordinaryMode === 'fallback') this.warnFallback(platform)
  180. return this.ordinaryMode
  181. }
  182. private warnFallback(platform: NodeJS.Platform): void {
  183. const reason = platform === 'darwin'
  184. ? 'macOS has no supported persistent process-range owner'
  185. : platform === 'linux'
  186. ? 'a modern readable user-systemd scope is unavailable'
  187. : platform === 'win32'
  188. ? 'the Win32 Job runner is unavailable'
  189. : `platform ${platform} has no native managed range`
  190. this.ctx.logger.warn(
  191. `subprocess-local is using weaker process-tree containment because ${reason}; descendants that escape the process group or direct-parent tree are not guaranteed to terminate or delay waitForExit()`,
  192. )
  193. }
  194. // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation.
  195. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
  196. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
  197. const file = spec.argv[0]
  198. if (file === undefined || file.length === 0) {
  199. throw new Error('subprocess-local: terminal argv must contain a program')
  200. }
  201. spec.signal?.throwIfAborted()
  202. const options: IPtyForkOptions = {
  203. name: 'dumb',
  204. rows: spec.rows,
  205. cols: spec.cols,
  206. cwd: spec.cwd,
  207. env: childEnv(spec.env),
  208. }
  209. const inspector = this.terminalInspector ?? createProcessInspector()
  210. const terminal = nodePty.spawn(file, [...spec.argv.slice(1)], options)
  211. const handle = new LocalTerminalHandle(terminal, inspector, spec.graceMs)
  212. this.terminals.add(handle)
  213. const release = async (): Promise<void> => {
  214. await handle.terminate()
  215. this.terminals.delete(handle)
  216. }
  217. void handle.done.then(release, release).catch(() => {})
  218. return handle
  219. }
  220. }
  221. /** Read a Windows environment key using the platform's case-insensitive semantics. */
  222. function environmentValue(env: NodeJS.ProcessEnv, name: 'PATH' | 'PATHEXT'): string | undefined {
  223. const exact = env[name]
  224. if (exact !== undefined || process.platform !== 'win32') return exact
  225. const normalized = name.toUpperCase()
  226. return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1]
  227. }
  228. export default LocalSubprocessRuntime