1
/**2
* Local Service Provider for the subprocess capability seam. Each spawn owns a3
* platform-selected managed range with the spec's per-stream stdio dispositions.4
* Normal disposal terminates and joins live ranges; Node's synchronous exit5
* phase force-stops any ranges the service still owns. It has no config: every6
* disposition and limit arrives on the spec, so deployment-varying choices7
* stay with the caller's config (the bash executor's, the LSP host's, …).8
* @module @deepseek-ai/dsh-subprocess-local9
*/11
import { constants } from 'node:fs'12
import { access, stat } from 'node:fs/promises'13
import { userInfo } from 'node:os'14
import { delimiter, extname, isAbsolute, resolve } from 'node:path'15
import type { Duplex } from 'node:stream'16
import { Context } from '@deepseek-ai/cordis'17
import type * as NodePty from 'node-pty'18
import type { IPtyForkOptions } from 'node-pty'19
import { createLazyRequire } from '@deepseek-ai/dsh-lazy-require'20
import { SubprocessRuntime, SubprocessExecutableNotFoundError } from '@deepseek-ai/dsh-subprocess'21
import type {22
SubprocessHandle,23
SubprocessSpawnSpec,24
SubprocessTerminalHandle,25
SubprocessTerminalEnvironment,26
SubprocessTerminalSpawnSpec,27
} from '@deepseek-ai/dsh-subprocess'28
import {29
bindManagedProcess,30
childEnv,31
spawnSubprocess,32
validateSubprocessSpec,33
} from './spawn.ts'34
import { logSpillFailure, prepareManagedProcessBinding } from './output.ts'35
import type { LocalSubprocessHandle, SpawnInternals } from './spawn.ts'36
import {37
launchLinuxScope,38
prepareLinuxTerminalScope,39
probeLinuxManager,40
probeLinuxNative,41
signalLinuxDirectProcess,42
} from './linux-scope.ts'43
import { launchWindowsJob, probeWindowsJob } from './windows-job.ts'44
import { targetEnvironment } from './runner-launch.ts'45
import { createProcessInspector } from './process-inspector.ts'46
import type { ProcessInspector } from './process-inspector.ts'47
import { LocalTerminalHandle } from './terminal.ts'48
import { prepareShellActivity } from './shell-activity.ts'50
const requireNodePty = createLazyRequire<typeof NodePty>('node-pty', import.meta.url)52
/**53
* Local subprocess service: platform-selected managed ranges, Node-shaped stdio54
* dispositions (raw pipes, inherit, bounded tail-keep collection with spill55
* 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
*/59
export 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 = false70
/** Positive-only cache for the expensive Linux bootstrap and scope probe. */71
private linuxDeepProbePassed = false72
/** Test hook for platform process inspection; production resolves lazily on terminal spawn. */73
terminalInspector: ProcessInspector | undefined75
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
}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')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
}107
private async disposeManagedProcesses(): Promise<void> {108
// Request termination, then await MANAGED-RANGE exit — not just the109
// direct command's settlement — so even a surviving descendant cannot110
// outlive the fiber. Keep both sets authoritative while these waits are111
// 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 an116
// 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
}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()) continue160
await access(candidate, constants.X_OK)161
signal?.throwIfAborted()162
return candidate163
} catch {164
// Try the next PATH candidate; the final miss receives one stable error.165
}166
}167
signal?.throwIfAborted()168
throw new SubprocessExecutableNotFoundError(absolute169
? `subprocess-local: command ${JSON.stringify(command)} is not an executable file`170
: `subprocess-local: command ${JSON.stringify(command)} was not found on PATH`)171
}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
}182
spawn(spec: SubprocessSpawnSpec): SubprocessHandle {183
validateSubprocessSpec(spec)184
const env = targetEnvironment(spec)185
const containmentMode = this.selectContainmentMode('ordinary')186
let handle: LocalSubprocessHandle187
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.control199
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-child204
// settlement — a TERM-trapping helper that outlives the leader must stay205
// owned so teardown can still escalate it. For the common no-survivor206
// 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 handle211
}213
private selectContainmentMode(214
kind: 'ordinary' | 'terminal',215
): 'linux-scope' | 'windows-job' | 'fallback' {216
const platform = this.internals.platform ?? process.platform217
let fallbackReason: string | undefined218
if (platform === 'linux') {219
const available = this.linuxDeepProbePassed220
? probeLinuxManager()221
: probeLinuxNative()222
if (available) this.linuxDeepProbePassed = true223
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
}234
private warnFallback(235
platform: NodeJS.Platform,236
kind: 'ordinary' | 'terminal',237
selectedReason?: string,238
): void {239
if (this.fallbackWarningIssued) return240
this.fallbackWarningIssued = true241
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
}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 || undefined259
return { platform, ...defaultShell === undefined ? {} : { defaultShell } }260
}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> | undefined283
let terminal: NodePty.IPty284
try {285
scope = containmentMode === 'linux-scope'286
? prepareLinuxTerminalScope(launch, { ...activity?.env ?? env, PWD: spec.cwd, TERM: spec.terminalType })287
: undefined288
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 error298
}299
// oxlint-disable-next-line eslint/prefer-const -- The owner can query readiness before the handle is published.300
let handle: LocalTerminalHandle | undefined301
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) return324
await handle.terminate()325
this.terminals.delete(handle)326
}327
void handle.done.then(release, release).catch(() => {})328
return handle329
}330
}332
/** Read a Windows environment key using the platform's case-insensitive semantics. */333
function environmentValue(env: NodeJS.ProcessEnv, name: 'PATH' | 'PATHEXT'): string | undefined {334
const exact = env[name]335
if (exact !== undefined || process.platform !== 'win32') return exact336
const normalized = name.toUpperCase()337
return Object.entries(env).find(([key]) => key.toUpperCase() === normalized)?.[1]338
}340
export default LocalSubprocessRuntime