Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* Shared Node.js implementation of the child process spawner service.
|
||||
*
|
||||
* This module adapts `node:child_process.spawn` to the Effect
|
||||
* `ChildProcessSpawner` service. Provide {@link layer} to run `ChildProcess`
|
||||
* commands in Node-compatible runtimes: commands get scoped process handles
|
||||
* with stdin sinks, stdout and stderr streams, exit-code waiting,
|
||||
* interruption-time cleanup, process killing, and custom file-descriptor pipes.
|
||||
*
|
||||
* The implementation sits below the command-building API. It validates and
|
||||
* resolves `cwd` through the Effect `FileSystem` and `Path` services,
|
||||
* translates Node errno failures to `PlatformError`, and uses scopes to
|
||||
* terminate referenced children when the owning effect is interrupted or
|
||||
* finalized. Pipelines are flattened by {@link flattenCommand} and spawned one
|
||||
* process at a time, wiring the selected source stream (`stdout`, `stderr`,
|
||||
* `all`, or `fdN`) to the destination `stdin` or `fdN`.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
import type * as Arr from "effect/Array"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
import * as FileSystem from "effect/FileSystem"
|
||||
import * as Layer from "effect/Layer"
|
||||
import * as Path from "effect/Path"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import * as Predicate from "effect/Predicate"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import * as Sink from "effect/Sink"
|
||||
import * as Stream from "effect/Stream"
|
||||
import * as ChildProcess from "effect/unstable/process/ChildProcess"
|
||||
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import {
|
||||
ChildProcessSpawner,
|
||||
ExitCode,
|
||||
make as makeSpawner,
|
||||
makeHandle,
|
||||
ProcessId
|
||||
} from "effect/unstable/process/ChildProcessSpawner"
|
||||
import * as NodeChildProcess from "node:child_process"
|
||||
import { PassThrough } from "node:stream"
|
||||
import { handleErrnoException } from "./internal/utils.ts"
|
||||
import * as NodeSink from "./NodeSink.ts"
|
||||
import * as NodeStream from "./NodeStream.ts"
|
||||
|
||||
const toError = (error: unknown): Error =>
|
||||
error instanceof globalThis.Error
|
||||
? error
|
||||
: new globalThis.Error(String(error))
|
||||
|
||||
const toPlatformError = (
|
||||
method: string,
|
||||
error: NodeJS.ErrnoException,
|
||||
command: ChildProcess.Command
|
||||
): PlatformError.PlatformError => {
|
||||
const { commands } = flattenCommand(command)
|
||||
const commandStr = commands.reduce((acc, curr) => {
|
||||
const cmd = `${curr.command} ${curr.args.join(" ")}`
|
||||
return acc.length === 0 ? cmd : `${acc} | ${cmd}`
|
||||
}, "")
|
||||
return handleErrnoException("ChildProcess", method)(error, [commandStr])
|
||||
}
|
||||
|
||||
type ExitCodeWithSignal = readonly [code: number | null, signal: NodeJS.Signals | null]
|
||||
type ExitSignal = Deferred.Deferred<ExitCodeWithSignal>
|
||||
|
||||
const make = Effect.gen(function*() {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const path = yield* Path.Path
|
||||
|
||||
const resolveWorkingDirectory = Effect.fnUntraced(
|
||||
function*(options: ChildProcess.CommandOptions) {
|
||||
if (Predicate.isUndefined(options.cwd)) return undefined
|
||||
// Validate that the specified directory is accessible
|
||||
yield* fs.access(options.cwd)
|
||||
return path.resolve(options.cwd)
|
||||
}
|
||||
)
|
||||
|
||||
const resolveEnvironment = (options: ChildProcess.CommandOptions) => {
|
||||
return options.extendEnv
|
||||
? { ...globalThis.process.env, ...options.env }
|
||||
: options.env
|
||||
}
|
||||
|
||||
const inputToStdioOption = (input: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
|
||||
Stream.isStream(input) ? "pipe" : input
|
||||
|
||||
const outputToStdioOption = (input: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
|
||||
Sink.isSink(input) ? "pipe" : input
|
||||
|
||||
const resolveStdinOption = (options: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
|
||||
const defaultConfig: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
|
||||
if (Predicate.isUndefined(options.stdin)) {
|
||||
return defaultConfig
|
||||
}
|
||||
if (typeof options.stdin === "string") {
|
||||
return { ...defaultConfig, stream: options.stdin }
|
||||
}
|
||||
if (Stream.isStream(options.stdin)) {
|
||||
return { ...defaultConfig, stream: options.stdin }
|
||||
}
|
||||
return {
|
||||
stream: options.stdin.stream,
|
||||
encoding: options.stdin.encoding ?? defaultConfig.encoding,
|
||||
endOnDone: options.stdin.endOnDone ?? defaultConfig.endOnDone
|
||||
}
|
||||
}
|
||||
|
||||
const resolveOutputOption = (
|
||||
options: ChildProcess.CommandOptions,
|
||||
streamName: "stdout" | "stderr"
|
||||
): ChildProcess.StdoutConfig => {
|
||||
const option = options[streamName]
|
||||
if (Predicate.isUndefined(option)) {
|
||||
return { stream: "pipe" }
|
||||
}
|
||||
if (typeof option === "string") {
|
||||
return { stream: option }
|
||||
}
|
||||
if (Sink.isSink(option)) {
|
||||
return { stream: option }
|
||||
}
|
||||
return { stream: option.stream }
|
||||
}
|
||||
|
||||
interface ResolvedAdditionalFd {
|
||||
readonly fd: number
|
||||
readonly config: ChildProcess.AdditionalFdConfig
|
||||
}
|
||||
|
||||
const resolveAdditionalFds = (
|
||||
options: ChildProcess.CommandOptions
|
||||
): ReadonlyArray<ResolvedAdditionalFd> => {
|
||||
if (Predicate.isUndefined(options.additionalFds)) {
|
||||
return []
|
||||
}
|
||||
const result: Array<ResolvedAdditionalFd> = []
|
||||
for (const [name, config] of Object.entries(options.additionalFds)) {
|
||||
const fd = ChildProcess.parseFdName(name)
|
||||
if (Predicate.isNotUndefined(fd)) {
|
||||
result.push({ fd, config })
|
||||
}
|
||||
}
|
||||
// Sort by fd number to ensure correct ordering
|
||||
return result.sort((a, b) => a.fd - b.fd)
|
||||
}
|
||||
|
||||
const buildStdioArray = (
|
||||
stdinConfig: ChildProcess.StdinConfig,
|
||||
stdoutConfig: ChildProcess.StdoutConfig,
|
||||
stderrConfig: ChildProcess.StderrConfig,
|
||||
additionalFds: ReadonlyArray<ResolvedAdditionalFd>
|
||||
): NodeChildProcess.StdioOptions => {
|
||||
const stdio: Array<NodeChildProcess.IOType | undefined> = [
|
||||
inputToStdioOption(stdinConfig.stream),
|
||||
outputToStdioOption(stdoutConfig.stream),
|
||||
outputToStdioOption(stderrConfig.stream)
|
||||
]
|
||||
|
||||
if (additionalFds.length === 0) {
|
||||
return stdio as NodeChildProcess.StdioOptions
|
||||
}
|
||||
|
||||
// Find the maximum fd number to size the array correctly
|
||||
const maxFd = additionalFds.reduce((max, { fd }) => Math.max(max, fd), 2)
|
||||
|
||||
// Fill gaps with "ignore"
|
||||
for (let i = 3; i <= maxFd; i++) {
|
||||
stdio[i] = "ignore"
|
||||
}
|
||||
|
||||
// Set up additional fds as "pipe"
|
||||
for (const { fd } of additionalFds) {
|
||||
stdio[fd] = "pipe"
|
||||
}
|
||||
|
||||
return stdio as NodeChildProcess.StdioOptions
|
||||
}
|
||||
|
||||
const setupAdditionalFds = Effect.fnUntraced(function*(
|
||||
command: ChildProcess.StandardCommand,
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
additionalFds: ReadonlyArray<ResolvedAdditionalFd>
|
||||
) {
|
||||
if (additionalFds.length === 0) {
|
||||
return {
|
||||
getInputFd: () => Sink.drain,
|
||||
getOutputFd: () => Stream.empty
|
||||
}
|
||||
}
|
||||
|
||||
const inputSinks = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
|
||||
const outputStreams = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
|
||||
|
||||
for (const { config, fd } of additionalFds) {
|
||||
const nodeStream = childProcess.stdio[fd]
|
||||
|
||||
switch (config.type) {
|
||||
case "input": {
|
||||
// Create a sink to write to for input file descriptors
|
||||
let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
|
||||
if (nodeStream && "write" in nodeStream) {
|
||||
sink = NodeSink.fromWritable({
|
||||
evaluate: () => nodeStream,
|
||||
onError: (error) => toPlatformError(`fromWritable(fd${fd})`, toError(error), command)
|
||||
})
|
||||
}
|
||||
|
||||
// If user provided a stream, pipe it into the sink
|
||||
if (config.stream) {
|
||||
yield* Effect.forkScoped(Stream.run(config.stream, sink))
|
||||
}
|
||||
|
||||
inputSinks.set(fd, sink)
|
||||
|
||||
break
|
||||
}
|
||||
case "output": {
|
||||
// Create a stream to read from for output file descriptors
|
||||
let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
|
||||
if (nodeStream && "read" in nodeStream) {
|
||||
const passThrough = new PassThrough()
|
||||
nodeStream.on("error", (error) => passThrough.destroy(error))
|
||||
nodeStream.pipe(passThrough)
|
||||
stream = NodeStream.fromReadable({
|
||||
evaluate: () => passThrough,
|
||||
onError: (error) => toPlatformError(`fromReadable(fd${fd})`, toError(error), command)
|
||||
})
|
||||
}
|
||||
|
||||
// If user provided a sink, transduce the stream through it
|
||||
if (config.sink) {
|
||||
stream = Stream.transduce(stream, config.sink)
|
||||
}
|
||||
|
||||
outputStreams.set(fd, stream)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getInputFd: (fd: number) => inputSinks.get(fd) ?? Sink.drain,
|
||||
getOutputFd: (fd: number) => outputStreams.get(fd) ?? Stream.empty
|
||||
}
|
||||
})
|
||||
|
||||
const setupChildStdin = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
config: ChildProcess.StdinConfig
|
||||
) =>
|
||||
Effect.suspend(() => {
|
||||
// If the child process has a standard input stream, connect it to the
|
||||
// sink that will attached to the process handle
|
||||
let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
|
||||
if (Predicate.isNotNull(childProcess.stdin)) {
|
||||
sink = NodeSink.fromWritable({
|
||||
evaluate: () => childProcess.stdin!,
|
||||
onError: (error) => toPlatformError("fromWritable(stdin)", toError(error), command),
|
||||
endOnDone: config.endOnDone,
|
||||
encoding: config.encoding
|
||||
})
|
||||
}
|
||||
|
||||
// If the user provided a `Stream`, run it into the stdin sink
|
||||
if (Stream.isStream(config.stream)) {
|
||||
return Effect.as(Effect.forkScoped(Stream.run(config.stream, sink)), sink)
|
||||
}
|
||||
|
||||
return Effect.succeed(sink)
|
||||
})
|
||||
|
||||
const setupChildOutputStreams = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
stdoutConfig: ChildProcess.StdoutConfig,
|
||||
stderrConfig: ChildProcess.StderrConfig
|
||||
): {
|
||||
stdout: Stream.Stream<Uint8Array, PlatformError.PlatformError>
|
||||
stderr: Stream.Stream<Uint8Array, PlatformError.PlatformError>
|
||||
all: Stream.Stream<Uint8Array, PlatformError.PlatformError>
|
||||
} => {
|
||||
let stdout = childProcess.stdout ?
|
||||
(() => {
|
||||
const passThrough = new PassThrough()
|
||||
childProcess.stdout!.on("error", (error) => passThrough.destroy(error))
|
||||
childProcess.stdout!.pipe(passThrough)
|
||||
return NodeStream.fromReadable({
|
||||
evaluate: () => passThrough,
|
||||
onError: (error) => toPlatformError("fromReadable(stdout)", toError(error), command)
|
||||
})
|
||||
})() :
|
||||
Stream.empty
|
||||
let stderr = childProcess.stderr ?
|
||||
(() => {
|
||||
const passThrough = new PassThrough()
|
||||
childProcess.stderr!.on("error", (error) => passThrough.destroy(error))
|
||||
childProcess.stderr!.pipe(passThrough)
|
||||
return NodeStream.fromReadable({
|
||||
evaluate: () => passThrough,
|
||||
onError: (error) => toPlatformError("fromReadable(stderr)", toError(error), command)
|
||||
})
|
||||
})() :
|
||||
Stream.empty
|
||||
|
||||
if (Sink.isSink(stdoutConfig.stream)) {
|
||||
stdout = Stream.transduce(stdout, stdoutConfig.stream)
|
||||
}
|
||||
if (Sink.isSink(stderrConfig.stream)) {
|
||||
stderr = Stream.transduce(stderr, stderrConfig.stream)
|
||||
}
|
||||
|
||||
const all = Stream.merge(stdout, stderr)
|
||||
|
||||
return { stdout, stderr, all }
|
||||
}
|
||||
|
||||
const spawn = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
spawnOptions: NodeChildProcess.SpawnOptions
|
||||
) =>
|
||||
Effect.callback<
|
||||
readonly [NodeChildProcess.ChildProcess, ExitSignal],
|
||||
PlatformError.PlatformError
|
||||
>((resume) => {
|
||||
const deferred = Deferred.makeUnsafe<ExitCodeWithSignal>()
|
||||
const handle = NodeChildProcess.spawn(
|
||||
command.command,
|
||||
command.args,
|
||||
spawnOptions
|
||||
)
|
||||
handle.on("error", (error) => {
|
||||
resume(Effect.fail(toPlatformError("spawn", error, command)))
|
||||
})
|
||||
handle.on("exit", (...args) => {
|
||||
Deferred.doneUnsafe(deferred, Exit.succeed(args))
|
||||
})
|
||||
handle.on("spawn", () => {
|
||||
resume(Effect.succeed([handle, deferred]))
|
||||
})
|
||||
return Effect.sync(() => {
|
||||
handle.kill("SIGTERM")
|
||||
})
|
||||
})
|
||||
|
||||
const killProcessGroup = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals
|
||||
) => {
|
||||
if (globalThis.process.platform === "win32") {
|
||||
return Effect.callback<void, PlatformError.PlatformError>((resume) => {
|
||||
NodeChildProcess.exec(`taskkill /pid ${childProcess.pid} /T /F`, (error) => {
|
||||
if (error) {
|
||||
resume(Effect.fail(toPlatformError("kill", toError(error), command)))
|
||||
} else {
|
||||
resume(Effect.void)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
return Effect.try({
|
||||
try: () => {
|
||||
globalThis.process.kill(-childProcess.pid!, signal)
|
||||
},
|
||||
catch: (error) => toPlatformError("kill", toError(error), command)
|
||||
})
|
||||
}
|
||||
|
||||
const killProcessGroupOnExit = (
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals
|
||||
): void => {
|
||||
if (globalThis.process.platform === "win32") {
|
||||
NodeChildProcess.exec(`taskkill /pid ${childProcess.pid} /T /F`, () => {
|
||||
// ignore errors during best-effort cleanup
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
globalThis.process.kill(-childProcess.pid!, signal)
|
||||
} catch {
|
||||
// ignore errors during best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
const killProcess = (
|
||||
command: ChildProcess.StandardCommand,
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals
|
||||
) =>
|
||||
Effect.suspend(() => {
|
||||
const killed = childProcess.kill(signal)
|
||||
if (!killed) {
|
||||
const error = new globalThis.Error("Failed to kill child process")
|
||||
return Effect.fail(toPlatformError("kill", error, command))
|
||||
}
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
const withTimeout = (
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
command: ChildProcess.StandardCommand,
|
||||
options: ChildProcess.KillOptions | undefined
|
||||
) =>
|
||||
<A, E, R>(
|
||||
kill: (
|
||||
command: ChildProcess.StandardCommand,
|
||||
childProcess: NodeChildProcess.ChildProcess,
|
||||
signal: NodeJS.Signals
|
||||
) => Effect.Effect<A, E, R>
|
||||
) => {
|
||||
const killSignal = options?.killSignal ?? "SIGTERM"
|
||||
return Predicate.isUndefined(options?.forceKillAfter)
|
||||
? kill(command, childProcess, killSignal)
|
||||
: Effect.timeoutOrElse(kill(command, childProcess, killSignal), {
|
||||
duration: options.forceKillAfter,
|
||||
orElse: () => kill(command, childProcess, "SIGKILL")
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate source stream from a process handle based on the
|
||||
* `from` pipe option.
|
||||
*/
|
||||
const getSourceStream = (
|
||||
handle: ChildProcessHandle,
|
||||
from: ChildProcess.PipeFromOption | undefined
|
||||
): Stream.Stream<Uint8Array, PlatformError.PlatformError> => {
|
||||
const fromOption = from ?? "stdout"
|
||||
switch (fromOption) {
|
||||
case "stdout":
|
||||
return handle.stdout
|
||||
case "stderr":
|
||||
return handle.stderr
|
||||
case "all":
|
||||
return handle.all
|
||||
default: {
|
||||
// Handle fd3, fd4, etc.
|
||||
const fd = ChildProcess.parseFdName(fromOption)
|
||||
if (Predicate.isNotUndefined(fd)) {
|
||||
return handle.getOutputFd(fd)
|
||||
}
|
||||
// Fallback to stdout for invalid fd names
|
||||
return handle.stdout
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const spawnCommand: (
|
||||
command: ChildProcess.Command
|
||||
) => Effect.Effect<
|
||||
ChildProcessHandle,
|
||||
PlatformError.PlatformError,
|
||||
Scope.Scope
|
||||
> = Effect.fnUntraced(function*(cmd) {
|
||||
switch (cmd._tag) {
|
||||
case "StandardCommand": {
|
||||
const stdinConfig = resolveStdinOption(cmd.options)
|
||||
const stdoutConfig = resolveOutputOption(cmd.options, "stdout")
|
||||
const stderrConfig = resolveOutputOption(cmd.options, "stderr")
|
||||
const resolvedAdditionalFds = resolveAdditionalFds(cmd.options)
|
||||
let isReferenced = true
|
||||
|
||||
const cwd = yield* resolveWorkingDirectory(cmd.options)
|
||||
const env = resolveEnvironment(cmd.options)
|
||||
const stdio = buildStdioArray(stdinConfig, stdoutConfig, stderrConfig, resolvedAdditionalFds)
|
||||
|
||||
const [childProcess, exitSignal] = yield* Effect.acquireRelease(
|
||||
spawn(cmd, {
|
||||
cwd,
|
||||
env,
|
||||
stdio,
|
||||
detached: cmd.options.detached ?? process.platform !== "win32",
|
||||
shell: cmd.options.shell
|
||||
}),
|
||||
Effect.fnUntraced(function*([childProcess, exitSignal]) {
|
||||
const exited = yield* Deferred.isDone(exitSignal)
|
||||
const killWithTimeout = withTimeout(childProcess, cmd, cmd.options)
|
||||
if (exited) {
|
||||
// Process already exited, check if children need cleanup
|
||||
const [code] = yield* Deferred.await(exitSignal)
|
||||
if (code !== 0 && Predicate.isNotNull(code)) {
|
||||
// Non-zero exit code ,attempt to clean up process group
|
||||
return yield* Effect.ignore(killWithTimeout(killProcessGroup))
|
||||
}
|
||||
return yield* Effect.void
|
||||
}
|
||||
if (!isReferenced) {
|
||||
return yield* Effect.void
|
||||
}
|
||||
// Process is still running, kill it
|
||||
return yield* killWithTimeout((command, childProcess, signal) =>
|
||||
killProcessGroup(command, childProcess, signal).pipe(
|
||||
Effect.catch(() => killProcess(command, childProcess, signal)),
|
||||
Effect.andThen(Deferred.await(exitSignal))
|
||||
)
|
||||
).pipe(
|
||||
Effect.ignore
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
const pid = ProcessId(childProcess.pid!)
|
||||
childProcess.on("exit", (code) => {
|
||||
if (code !== 0 && Predicate.isNotNull(code)) {
|
||||
killProcessGroupOnExit(childProcess, cmd.options.killSignal ?? "SIGTERM")
|
||||
}
|
||||
})
|
||||
const reref = Effect.sync(() => {
|
||||
if (!isReferenced) {
|
||||
childProcess.ref()
|
||||
isReferenced = true
|
||||
}
|
||||
})
|
||||
const unref = Effect.sync(() => {
|
||||
if (isReferenced) {
|
||||
childProcess.unref()
|
||||
isReferenced = false
|
||||
}
|
||||
return reref
|
||||
})
|
||||
const stdin = yield* setupChildStdin(cmd, childProcess, stdinConfig)
|
||||
const { all, stderr, stdout } = setupChildOutputStreams(cmd, childProcess, stdoutConfig, stderrConfig)
|
||||
const { getInputFd, getOutputFd } = yield* setupAdditionalFds(cmd, childProcess, resolvedAdditionalFds)
|
||||
const isRunning = Effect.map(Deferred.isDone(exitSignal), (done) => !done)
|
||||
const exitCode = Effect.flatMap(Deferred.await(exitSignal), ([code, signal]) => {
|
||||
if (Predicate.isNotNull(code)) {
|
||||
return Effect.succeed(ExitCode(code))
|
||||
}
|
||||
// If code is `null`, then `signal` must be defined. See the NodeJS
|
||||
// documentation for the `"exit"` event on a `child_process`.
|
||||
// https://nodejs.org/api/child_process.html#child_process_event_exit
|
||||
const error = new globalThis.Error(`Process interrupted due to receipt of signal: '${signal}'`)
|
||||
return Effect.fail(toPlatformError("exitCode", error, cmd))
|
||||
})
|
||||
const kill = (options?: ChildProcess.KillOptions | undefined) => {
|
||||
const killWithTimeout = withTimeout(childProcess, cmd, options)
|
||||
return killWithTimeout((command, childProcess, signal) =>
|
||||
killProcessGroup(command, childProcess, signal).pipe(
|
||||
Effect.catch(() => killProcess(command, childProcess, signal)),
|
||||
Effect.andThen(Deferred.await(exitSignal))
|
||||
)
|
||||
).pipe(
|
||||
Effect.asVoid
|
||||
)
|
||||
}
|
||||
|
||||
return makeHandle({
|
||||
pid,
|
||||
exitCode,
|
||||
isRunning,
|
||||
kill,
|
||||
stdin,
|
||||
stdout,
|
||||
stderr,
|
||||
all,
|
||||
getInputFd,
|
||||
getOutputFd,
|
||||
unref
|
||||
})
|
||||
}
|
||||
case "PipedCommand": {
|
||||
const { commands, pipeOptions } = flattenCommand(cmd)
|
||||
const [root, ...pipeline] = commands
|
||||
|
||||
const handles = [yield* spawnCommand(root)]
|
||||
|
||||
for (let i = 0; i < pipeline.length; i++) {
|
||||
const command = pipeline[i]
|
||||
const options = pipeOptions[i] ?? {}
|
||||
const stdinConfig = resolveStdinOption(command.options)
|
||||
|
||||
// Get the appropriate stream from the source based on `from` option
|
||||
const sourceStream = Stream.unwrap(
|
||||
Effect.succeed(getSourceStream(handles[handles.length - 1], options.from))
|
||||
)
|
||||
|
||||
// Determine where to pipe: stdin or custom fd
|
||||
const toOption = options.to ?? "stdin"
|
||||
|
||||
if (toOption === "stdin") {
|
||||
// Pipe to stdin (default behavior)
|
||||
handles.push(
|
||||
yield* spawnCommand(ChildProcess.make(command.command, command.args, {
|
||||
...command.options,
|
||||
stdin: { ...stdinConfig, stream: sourceStream }
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
// Pipe to custom fd (fd3, fd4, etc.)
|
||||
const fd = ChildProcess.parseFdName(toOption)
|
||||
if (Predicate.isNotUndefined(fd)) {
|
||||
const fdName = ChildProcess.fdName(fd) as `fd${number}`
|
||||
const existingFds = command.options.additionalFds ?? {}
|
||||
handles.push(
|
||||
yield* spawnCommand(ChildProcess.make(command.command, command.args, {
|
||||
...command.options,
|
||||
additionalFds: {
|
||||
...existingFds,
|
||||
[fdName]: { type: "input" as const, stream: sourceStream }
|
||||
}
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
// Invalid fd name, fall back to stdin
|
||||
handles.push(
|
||||
yield* spawnCommand(ChildProcess.make(command.command, command.args, {
|
||||
...command.options,
|
||||
stdin: { ...stdinConfig, stream: sourceStream }
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handle = handles[handles.length - 1]
|
||||
const unref = Effect.gen(function*() {
|
||||
const rerefs: Array<Effect.Effect<void, PlatformError.PlatformError>> = []
|
||||
for (const handle of handles) {
|
||||
rerefs.push(yield* handle.unref)
|
||||
}
|
||||
return Effect.forEach([...rerefs].reverse(), (reref) => reref, { discard: true })
|
||||
})
|
||||
|
||||
return makeHandle({
|
||||
pid: handle.pid,
|
||||
exitCode: handle.exitCode,
|
||||
isRunning: handle.isRunning,
|
||||
kill: handle.kill,
|
||||
stdin: handle.stdin,
|
||||
stdout: handle.stdout,
|
||||
stderr: handle.stderr,
|
||||
all: handle.all,
|
||||
getInputFd: handle.getInputFd,
|
||||
getOutputFd: handle.getOutputFd,
|
||||
unref
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return makeSpawner(spawnCommand)
|
||||
})
|
||||
|
||||
/**
|
||||
* Layer that provides the `NodeChildProcessSpawner` implementation.
|
||||
*
|
||||
* @category layers
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const layer: Layer.Layer<
|
||||
ChildProcessSpawner,
|
||||
never,
|
||||
FileSystem.FileSystem | Path.Path
|
||||
> = Layer.effect(ChildProcessSpawner, make)
|
||||
|
||||
// =============================================================================
|
||||
// Internal Helpers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result of flattening a pipeline of commands.
|
||||
*
|
||||
* @category models
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export interface FlattenedPipeline {
|
||||
readonly commands: Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>
|
||||
readonly pipeOptions: ReadonlyArray<ChildProcess.PipeOptions>
|
||||
}
|
||||
|
||||
/**
|
||||
* Flattens a `Command` into an array of `StandardCommand`s along with pipe
|
||||
* options for each connection.
|
||||
*
|
||||
* @category transforming
|
||||
* @since 4.0.0
|
||||
*/
|
||||
export const flattenCommand = (
|
||||
command: ChildProcess.Command
|
||||
): FlattenedPipeline => {
|
||||
const commands: Array<ChildProcess.StandardCommand> = []
|
||||
const pipeOptions: Array<ChildProcess.PipeOptions> = []
|
||||
|
||||
const flatten = (cmd: ChildProcess.Command): void => {
|
||||
switch (cmd._tag) {
|
||||
case "StandardCommand": {
|
||||
commands.push(cmd)
|
||||
break
|
||||
}
|
||||
case "PipedCommand": {
|
||||
// Recursively flatten left side first
|
||||
flatten(cmd.left)
|
||||
// Store the pipe options for this connection
|
||||
pipeOptions.push(cmd.options)
|
||||
// Then flatten right side
|
||||
flatten(cmd.right)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flatten(command)
|
||||
|
||||
// The commands array is guaranteed to be non-empty since we always have at
|
||||
// least one command in the input. We validate this at runtime and return a
|
||||
// properly typed tuple.
|
||||
if (commands.length === 0) {
|
||||
// This should never happen given a valid Command input
|
||||
throw new Error("flattenCommand produced empty commands array")
|
||||
}
|
||||
|
||||
const [first, ...rest] = commands
|
||||
const nonEmptyCommands: Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand> = [first, ...rest]
|
||||
|
||||
return { commands: nonEmptyCommands, pipeOptions }
|
||||
}
|
||||
Reference in New Issue
Block a user