index.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  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 {
  32. launchLinuxScope,
  33. prepareLinuxTerminalScope,
  34. probeLinuxManager,
  35. probeLinuxNative,
  36. } from './linux-scope.ts'
  37. import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'
  38. import { targetEnvironment } from './runner-launch.ts'
  39. import { createProcessInspector } from './process-inspector.ts'
  40. import type { ProcessInspector } from './process-inspector.ts'
  41. import { LocalTerminalHandle } from './terminal.ts'
  42. /**
  43. * Local subprocess service: platform-selected managed ranges, Node-shaped stdio
  44. * dispositions (raw pipes, inherit, bounded tail-keep collection with spill
  45. * files), credential-scrubbed environment, and provider-owned range signalling.
  46. * POSIX paths stage TERM before KILL; Windows paths terminate immediately.
  47. * JavaScript-observable host exit also performs synchronous final termination.
  48. */
  49. export class LocalSubprocessRuntime extends SubprocessRuntime {
  50. /** Live handles retained for normal disposal and synchronous host-exit finalization. */
  51. private live = new Set<LocalSubprocessHandle>()
  52. /** Live terminals retained through normal quiescence or host-exit finalization. */
  53. private terminals = new Set<LocalTerminalHandle>()
  54. /** Test hook: process, spill, and platform operations forwarded to spawnSubprocess. */
  55. internals: SpawnInternals = {}
  56. /** Provider-lifetime latch suppressing repeated weaker-containment warnings. */
  57. private fallbackWarningIssued = false
  58. /** Positive-only cache for the expensive Linux bootstrap and scope probe. */
  59. private linuxDeepProbePassed = false
  60. /** Test hook for platform process inspection; production resolves lazily on terminal spawn. */
  61. terminalInspector: ProcessInspector | undefined
  62. constructor(ctx: Context) {
  63. super(ctx)
  64. ctx.effect(() => {
  65. const onHostExit = (): void => { this.terminateForHostExit() }
  66. process.prependListener('exit', onHostExit)
  67. return async () => {
  68. await this.disposeManagedProcesses()
  69. process.off('exit', onHostExit)
  70. }
  71. }, 'local subprocess teardown')
  72. }
  73. private terminateForHostExit(): void {
  74. for (const handle of this.live) {
  75. try {
  76. handle.terminateForHostExit()
  77. } catch (_ordinaryRangeTerminationFailed) {
  78. // Host exit cannot await or report one target; continue with the rest.
  79. }
  80. }
  81. for (const terminal of this.terminals) {
  82. try {
  83. terminal.terminateForHostExit()
  84. } catch (_terminalTerminationFailed) {
  85. // One terminal must not prevent final termination of another target.
  86. }
  87. }
  88. }
  89. private async disposeManagedProcesses(): Promise<void> {
  90. // Request termination, then await MANAGED-RANGE exit — not just the
  91. // direct command's settlement — so even a surviving descendant cannot
  92. // outlive the fiber. Keep both sets authoritative while these waits are
  93. // pending so a shorter process-level exit bound can still force-kill them.
  94. const pending: Promise<unknown>[] = []
  95. for (const handle of this.live) {
  96. handle.terminate()
  97. // Direct result and range observation are independent. Start both so an
  98. // unreadable owner cannot hide behind a result that never settles.
  99. pending.push(Promise.all([
  100. handle.done.catch(() => {}),
  101. handle.waitForExit(),
  102. ]).then(() => { this.live.delete(handle) }))
  103. }
  104. for (const terminal of this.terminals) {
  105. pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
  106. }
  107. const outcomes = await Promise.allSettled(pending)
  108. const failures: unknown[] = []
  109. for (const outcome of outcomes) {
  110. if (outcome.status === 'rejected') failures.push(outcome.reason)
  111. }
  112. if (failures.length > 0) this.terminateForHostExit()
  113. if (failures.length === 1) throw failures[0]
  114. if (failures.length > 1) throw new AggregateError(failures, 'local subprocess teardown failed')
  115. }
  116. async resolveExecutable(
  117. command: string,
  118. env?: Readonly<Record<string, string>>,
  119. signal?: AbortSignal,
  120. ): Promise<string> {
  121. if (command.length === 0) throw new Error('subprocess-local: executable must be non-empty')
  122. signal?.throwIfAborted()
  123. const environment = childEnv(env)
  124. const absolute = isAbsolute(command)
  125. if (!absolute && (command.includes('/') || (process.platform === 'win32' && command.includes('\\')))) {
  126. throw new Error(
  127. `subprocess-local: command ${JSON.stringify(command)} is a relative path; use an absolute path or a bare PATH name`,
  128. )
  129. }
  130. const candidates = absolute ? [command] : this.executableCandidates(command, environment)
  131. for (const candidate of candidates) {
  132. signal?.throwIfAborted()
  133. try {
  134. const info = await stat(candidate)
  135. if (!info.isFile()) continue
  136. await access(candidate, constants.X_OK)
  137. signal?.throwIfAborted()
  138. return candidate
  139. } catch {
  140. // Try the next PATH candidate; the final miss receives one stable error.
  141. }
  142. }
  143. signal?.throwIfAborted()
  144. throw new Error(absolute
  145. ? `subprocess-local: command ${JSON.stringify(command)} is not an executable file`
  146. : `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`)
  147. }
  148. private executableCandidates(command: string, env: NodeJS.ProcessEnv): string[] {
  149. const path = environmentValue(env, 'PATH') ?? ''
  150. const extensions = process.platform === 'win32' && extname(command) === ''
  151. ? (environmentValue(env, 'PATHEXT') ?? '.COM;.EXE;.BAT;.CMD').split(';')
  152. : ['']
  153. return path.split(delimiter).flatMap(directory =>
  154. extensions.map(extension => resolve(process.cwd(), directory, command + extension)))
  155. }
  156. spawn(spec: SubprocessSpawnSpec): SubprocessHandle {
  157. validateSubprocessSpec(spec)
  158. const env = targetEnvironment(spec)
  159. const containmentMode = this.selectContainmentMode('ordinary')
  160. let handle: LocalSubprocessHandle
  161. if (containmentMode === 'fallback') {
  162. handle = spawnSubprocess(spec, this.internals)
  163. } else {
  164. const binding = prepareManagedProcessBinding(this.internals)
  165. const launch = containmentMode === 'linux-scope'
  166. ? launchLinuxScope(spec, env)
  167. : launchWindowsJob(spec, env)
  168. handle = bindManagedProcess(spec, launch, binding)
  169. }
  170. this.live.add(handle)
  171. // Release ownership only once the whole managed range is gone, not at direct-child
  172. // settlement — a TERM-trapping helper that outlives the leader must stay
  173. // owned so teardown can still escalate it. For the common no-survivor
  174. // case waitForExit resolves immediately after settlement.
  175. const release = (): Promise<void> =>
  176. handle.waitForExit().then(() => { this.live.delete(handle) })
  177. void handle.done.then(release, release).catch(() => {})
  178. return handle
  179. }
  180. private selectContainmentMode(
  181. kind: 'ordinary' | 'terminal',
  182. ): 'linux-scope' | 'windows-job' | 'fallback' {
  183. const platform = this.internals.platform ?? process.platform
  184. let fallbackReason: string | undefined
  185. if (platform === 'linux') {
  186. const available = this.linuxDeepProbePassed
  187. ? probeLinuxManager()
  188. : probeLinuxNative()
  189. if (available) this.linuxDeepProbePassed = true
  190. if (available) return 'linux-scope'
  191. fallbackReason = 'the current user-systemd scope or private bootstrap is unavailable'
  192. }
  193. if (kind === 'ordinary' && platform === 'win32') {
  194. const available = probeWindowsJob()
  195. if (available) return 'windows-job'
  196. }
  197. this.warnFallback(platform, kind, fallbackReason)
  198. return 'fallback'
  199. }
  200. private warnFallback(
  201. platform: NodeJS.Platform,
  202. kind: 'ordinary' | 'terminal',
  203. selectedReason?: string,
  204. ): void {
  205. if (this.fallbackWarningIssued) return
  206. this.fallbackWarningIssued = true
  207. const reason = selectedReason ?? (platform === 'darwin'
  208. ? 'macOS has no supported persistent process-range owner'
  209. : platform === 'win32'
  210. ? kind === 'terminal'
  211. ? 'Windows ConPTY remains outside Job containment'
  212. : 'the Win32 Job runner is unavailable'
  213. : `platform ${platform} has no native managed range`)
  214. this.ctx.logger.warn(
  215. `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()`,
  216. )
  217. }
  218. // Local PTY allocation is synchronous, but the provider contract permits remote asynchronous allocation.
  219. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract.
  220. async spawnTerminal(spec: SubprocessTerminalSpawnSpec): Promise<SubprocessTerminalHandle> {
  221. const file = spec.argv[0]
  222. if (file === undefined || file.length === 0) {
  223. throw new Error('subprocess-local: terminal argv must contain a program')
  224. }
  225. spec.signal?.throwIfAborted()
  226. const env = targetEnvironment(spec)
  227. const options: IPtyForkOptions = {
  228. name: 'dumb',
  229. rows: spec.rows,
  230. cols: spec.cols,
  231. cwd: spec.cwd,
  232. env,
  233. }
  234. const inspector = this.terminalInspector ?? createProcessInspector()
  235. const containmentMode = this.selectContainmentMode('terminal')
  236. const scope = containmentMode === 'linux-scope'
  237. ? prepareLinuxTerminalScope(spec, {
  238. ...env,
  239. PWD: spec.cwd,
  240. TERM: 'dumb',
  241. })
  242. : undefined
  243. if (scope !== undefined) {
  244. options.cwd = scope.cwd
  245. options.env = scope.env
  246. }
  247. let terminal: nodePty.IPty
  248. try {
  249. terminal = nodePty.spawn(
  250. scope?.command ?? file,
  251. scope?.args ?? [...spec.argv.slice(1)],
  252. options,
  253. )
  254. } catch (error) {
  255. scope?.cleanup()
  256. throw error
  257. }
  258. // oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published.
  259. let handle: LocalTerminalHandle | undefined
  260. const owner = scope?.bindOwner({
  261. running: () => handle?.running ?? true,
  262. signal: (signal) => {
  263. try { terminal.kill(signal) } catch { /* Direct process already exited. */ }
  264. },
  265. })
  266. handle = new LocalTerminalHandle(
  267. terminal,
  268. inspector,
  269. spec.graceMs,
  270. this.internals.platform ?? process.platform,
  271. owner,
  272. scope?.resolveOutcome,
  273. )
  274. this.terminals.add(handle)
  275. const release = async (): Promise<void> => {
  276. await handle.terminate()
  277. this.terminals.delete(handle)
  278. }
  279. void handle.done.then(release, release).catch(() => {})
  280. return handle
  281. }
  282. }
  283. /** Read a Windows environment key using the platform's case-insensitive semantics. */
  284. function environmentValue(env: NodeJS.ProcessEnv, name: 'PATH' | 'PATHEXT'): string | undefined {
  285. const exact = env[name]
  286. if (exact !== undefined || process.platform !== 'win32') return exact
  287. const normalized = name.toUpperCase()
  288. return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1]
  289. }
  290. export default LocalSubprocessRuntime