index.ts 7.0 KB

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