index.ts 14 KB

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