返回源码地图

packages/subprocess/subprocess-local/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 10;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

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