cross-spawn-spawner.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. import type * as Arr from "effect/Array"
  2. import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
  3. import * as NodePath from "@effect/platform-node/NodePath"
  4. import * as Deferred from "effect/Deferred"
  5. import * as Effect from "effect/Effect"
  6. import * as Exit from "effect/Exit"
  7. import * as FileSystem from "effect/FileSystem"
  8. import * as Layer from "effect/Layer"
  9. import * as Path from "effect/Path"
  10. import * as PlatformError from "effect/PlatformError"
  11. import * as Predicate from "effect/Predicate"
  12. import type * as Scope from "effect/Scope"
  13. import * as Sink from "effect/Sink"
  14. import * as Stream from "effect/Stream"
  15. import * as ChildProcess from "effect/unstable/process/ChildProcess"
  16. import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
  17. import {
  18. ChildProcessSpawner,
  19. ExitCode,
  20. make as makeSpawner,
  21. makeHandle,
  22. ProcessId,
  23. } from "effect/unstable/process/ChildProcessSpawner"
  24. import * as NodeChildProcess from "node:child_process"
  25. import { PassThrough } from "node:stream"
  26. import launch from "cross-spawn"
  27. const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
  28. const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
  29. switch (err.code) {
  30. case "ENOENT":
  31. return "NotFound"
  32. case "EACCES":
  33. return "PermissionDenied"
  34. case "EEXIST":
  35. return "AlreadyExists"
  36. case "EISDIR":
  37. return "BadResource"
  38. case "ENOTDIR":
  39. return "BadResource"
  40. case "EBUSY":
  41. return "Busy"
  42. case "ELOOP":
  43. return "BadResource"
  44. default:
  45. return "Unknown"
  46. }
  47. }
  48. const flatten = (command: ChildProcess.Command) => {
  49. const commands: Array<ChildProcess.StandardCommand> = []
  50. const opts: Array<ChildProcess.PipeOptions> = []
  51. const walk = (cmd: ChildProcess.Command): void => {
  52. switch (cmd._tag) {
  53. case "StandardCommand":
  54. commands.push(cmd)
  55. return
  56. case "PipedCommand":
  57. walk(cmd.left)
  58. opts.push(cmd.options)
  59. walk(cmd.right)
  60. return
  61. }
  62. }
  63. walk(command)
  64. if (commands.length === 0) throw new Error("flatten produced empty commands array")
  65. const [head, ...tail] = commands
  66. return {
  67. commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
  68. opts,
  69. }
  70. }
  71. const toPlatformError = (
  72. method: string,
  73. err: NodeJS.ErrnoException,
  74. command: ChildProcess.Command,
  75. ): PlatformError.PlatformError => {
  76. const cmd = flatten(command)
  77. .commands.map((x) => `${x.command} ${x.args.join(" ")}`)
  78. .join(" | ")
  79. return PlatformError.systemError({
  80. _tag: toTag(err),
  81. module: "ChildProcess",
  82. method,
  83. pathOrDescriptor: cmd,
  84. syscall: err.syscall,
  85. cause: err,
  86. })
  87. }
  88. type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
  89. export const make = Effect.gen(function* () {
  90. const fs = yield* FileSystem.FileSystem
  91. const path = yield* Path.Path
  92. const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) {
  93. if (Predicate.isUndefined(opts.cwd)) return undefined
  94. yield* fs.access(opts.cwd)
  95. return path.resolve(opts.cwd)
  96. })
  97. const env = (opts: ChildProcess.CommandOptions) =>
  98. opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env
  99. const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
  100. Stream.isStream(x) ? "pipe" : x
  101. const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
  102. Sink.isSink(x) ? "pipe" : x
  103. const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
  104. const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
  105. if (Predicate.isUndefined(opts.stdin)) return cfg
  106. if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin }
  107. if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin }
  108. return {
  109. stream: opts.stdin.stream,
  110. encoding: opts.stdin.encoding ?? cfg.encoding,
  111. endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone,
  112. }
  113. }
  114. const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => {
  115. const cfg = opts[key]
  116. if (Predicate.isUndefined(cfg)) return { stream: "pipe" }
  117. if (typeof cfg === "string") return { stream: cfg }
  118. if (Sink.isSink(cfg)) return { stream: cfg }
  119. return { stream: cfg.stream }
  120. }
  121. const fds = (opts: ChildProcess.CommandOptions) => {
  122. if (Predicate.isUndefined(opts.additionalFds)) return []
  123. return Object.entries(opts.additionalFds)
  124. .flatMap(([name, config]) => {
  125. const fd = ChildProcess.parseFdName(name)
  126. return Predicate.isUndefined(fd) ? [] : [{ fd, config }]
  127. })
  128. .toSorted((a, b) => a.fd - b.fd)
  129. }
  130. const stdios = (
  131. sin: ChildProcess.StdinConfig,
  132. sout: ChildProcess.StdoutConfig,
  133. serr: ChildProcess.StderrConfig,
  134. extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
  135. ): NodeChildProcess.StdioOptions => {
  136. const pipe = (x: NodeChildProcess.IOType | undefined) =>
  137. process.platform === "win32" && x === "pipe" ? "overlapped" : x
  138. const arr: Array<NodeChildProcess.IOType | undefined> = [
  139. pipe(input(sin.stream)),
  140. pipe(output(sout.stream)),
  141. pipe(output(serr.stream)),
  142. ]
  143. if (extra.length === 0) return arr as NodeChildProcess.StdioOptions
  144. const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2)
  145. for (let i = 3; i <= max; i++) arr[i] = "ignore"
  146. for (const x of extra) arr[x.fd] = pipe("pipe")
  147. return arr as NodeChildProcess.StdioOptions
  148. }
  149. const setupFds = Effect.fnUntraced(function* (
  150. command: ChildProcess.StandardCommand,
  151. proc: NodeChildProcess.ChildProcess,
  152. extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
  153. ) {
  154. if (extra.length === 0) {
  155. return {
  156. getInputFd: () => Sink.drain,
  157. getOutputFd: () => Stream.empty,
  158. }
  159. }
  160. const ins = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
  161. const outs = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
  162. for (const x of extra) {
  163. const node = proc.stdio[x.fd]
  164. switch (x.config.type) {
  165. case "input": {
  166. let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
  167. if (node && "write" in node) {
  168. sink = NodeSink.fromWritable({
  169. evaluate: () => node,
  170. onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command),
  171. endOnDone: true,
  172. })
  173. }
  174. if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink))
  175. ins.set(x.fd, sink)
  176. break
  177. }
  178. case "output": {
  179. let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
  180. if (node && "read" in node) {
  181. const tap = new PassThrough()
  182. node.on("error", (err) => tap.destroy(toError(err)))
  183. node.pipe(tap)
  184. stream = NodeStream.fromReadable({
  185. evaluate: () => tap,
  186. onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command),
  187. })
  188. }
  189. if (x.config.sink) stream = Stream.transduce(stream, x.config.sink)
  190. outs.set(x.fd, stream)
  191. break
  192. }
  193. }
  194. }
  195. return {
  196. getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain,
  197. getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty,
  198. }
  199. })
  200. const setupStdin = (
  201. command: ChildProcess.StandardCommand,
  202. proc: NodeChildProcess.ChildProcess,
  203. cfg: ChildProcess.StdinConfig,
  204. ) =>
  205. Effect.suspend(() => {
  206. let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
  207. if (Predicate.isNotNull(proc.stdin)) {
  208. sink = NodeSink.fromWritable({
  209. evaluate: () => proc.stdin!,
  210. onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command),
  211. endOnDone: cfg.endOnDone,
  212. encoding: cfg.encoding,
  213. })
  214. }
  215. if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink)
  216. return Effect.succeed(sink)
  217. })
  218. const setupOutput = (
  219. command: ChildProcess.StandardCommand,
  220. proc: NodeChildProcess.ChildProcess,
  221. out: ChildProcess.StdoutConfig,
  222. err: ChildProcess.StderrConfig,
  223. ) => {
  224. let stdout = proc.stdout
  225. ? NodeStream.fromReadable({
  226. evaluate: () => proc.stdout!,
  227. onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
  228. })
  229. : Stream.empty
  230. let stderr = proc.stderr
  231. ? NodeStream.fromReadable({
  232. evaluate: () => proc.stderr!,
  233. onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
  234. })
  235. : Stream.empty
  236. if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream)
  237. if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream)
  238. return { stdout, stderr, all: Stream.merge(stdout, stderr) }
  239. }
  240. const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
  241. Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
  242. const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
  243. const proc = launch(command.command, command.args, opts)
  244. let end = false
  245. let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
  246. proc.on("error", (err) => {
  247. resume(Effect.fail(toPlatformError("spawn", err, command)))
  248. })
  249. proc.on("exit", (...args) => {
  250. exit = args
  251. })
  252. proc.on("close", (...args) => {
  253. if (end) return
  254. end = true
  255. Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
  256. })
  257. proc.on("spawn", () => {
  258. resume(Effect.succeed([proc, signal]))
  259. })
  260. return Effect.sync(() => {
  261. proc.kill("SIGTERM")
  262. })
  263. })
  264. const killGroup = (
  265. command: ChildProcess.StandardCommand,
  266. proc: NodeChildProcess.ChildProcess,
  267. signal: NodeJS.Signals,
  268. ) => {
  269. if (globalThis.process.platform === "win32") {
  270. return Effect.callback<void, PlatformError.PlatformError>((resume) => {
  271. NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => {
  272. if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command)))
  273. resume(Effect.void)
  274. })
  275. })
  276. }
  277. return Effect.try({
  278. try: () => {
  279. globalThis.process.kill(-proc.pid!, signal)
  280. },
  281. catch: (err) => toPlatformError("kill", toError(err), command),
  282. })
  283. }
  284. const killOne = (
  285. command: ChildProcess.StandardCommand,
  286. proc: NodeChildProcess.ChildProcess,
  287. signal: NodeJS.Signals,
  288. ) =>
  289. Effect.suspend(() => {
  290. if (proc.kill(signal)) return Effect.void
  291. return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command))
  292. })
  293. const timeout =
  294. (
  295. proc: NodeChildProcess.ChildProcess,
  296. command: ChildProcess.StandardCommand,
  297. opts: ChildProcess.KillOptions | undefined,
  298. ) =>
  299. <A, E, R>(
  300. f: (
  301. command: ChildProcess.StandardCommand,
  302. proc: NodeChildProcess.ChildProcess,
  303. signal: NodeJS.Signals,
  304. ) => Effect.Effect<A, E, R>,
  305. ) => {
  306. const signal = opts?.killSignal ?? "SIGTERM"
  307. if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal)
  308. return Effect.timeoutOrElse(f(command, proc, signal), {
  309. duration: opts.forceKillAfter,
  310. orElse: () => f(command, proc, "SIGKILL"),
  311. })
  312. }
  313. const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => {
  314. const opt = from ?? "stdout"
  315. switch (opt) {
  316. case "stdout":
  317. return handle.stdout
  318. case "stderr":
  319. return handle.stderr
  320. case "all":
  321. return handle.all
  322. default: {
  323. const fd = ChildProcess.parseFdName(opt)
  324. return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout
  325. }
  326. }
  327. }
  328. const spawnCommand: (
  329. command: ChildProcess.Command,
  330. ) => Effect.Effect<ChildProcessHandle, PlatformError.PlatformError, Scope.Scope> = Effect.fnUntraced(
  331. function* (command) {
  332. switch (command._tag) {
  333. case "StandardCommand": {
  334. const sin = stdin(command.options)
  335. const sout = stdio(command.options, "stdout")
  336. const serr = stdio(command.options, "stderr")
  337. const extra = fds(command.options)
  338. const dir = yield* cwd(command.options)
  339. const [proc, signal] = yield* Effect.acquireRelease(
  340. spawn(command, {
  341. cwd: dir,
  342. env: env(command.options),
  343. stdio: stdios(sin, sout, serr, extra),
  344. detached: command.options.detached ?? process.platform !== "win32",
  345. shell: command.options.shell,
  346. windowsHide: process.platform === "win32",
  347. }),
  348. Effect.fnUntraced(function* ([proc, signal]) {
  349. const done = yield* Deferred.isDone(signal)
  350. const kill = timeout(proc, command, command.options)
  351. if (done) {
  352. const [code] = yield* Deferred.await(signal)
  353. if (process.platform === "win32") return yield* Effect.void
  354. if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
  355. return yield* Effect.void
  356. }
  357. const send = (s: NodeJS.Signals) =>
  358. Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
  359. const sig = command.options.killSignal ?? "SIGTERM"
  360. const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
  361. const escalated = command.options.forceKillAfter
  362. ? Effect.timeoutOrElse(attempt, {
  363. duration: command.options.forceKillAfter,
  364. orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
  365. })
  366. : attempt
  367. return yield* Effect.ignore(escalated)
  368. }),
  369. )
  370. const fd = yield* setupFds(command, proc, extra)
  371. const out = setupOutput(command, proc, sout, serr)
  372. let ref = true
  373. return makeHandle({
  374. pid: ProcessId(proc.pid!),
  375. stdin: yield* setupStdin(command, proc, sin),
  376. stdout: out.stdout,
  377. stderr: out.stderr,
  378. all: out.all,
  379. getInputFd: fd.getInputFd,
  380. getOutputFd: fd.getOutputFd,
  381. isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
  382. exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
  383. if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
  384. return Effect.fail(
  385. toPlatformError(
  386. "exitCode",
  387. new Error(`Process interrupted due to receipt of signal: '${signal}'`),
  388. command,
  389. ),
  390. )
  391. }),
  392. kill: (opts?: ChildProcess.KillOptions) => {
  393. const sig = opts?.killSignal ?? "SIGTERM"
  394. const send = (s: NodeJS.Signals) =>
  395. Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
  396. const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
  397. if (!opts?.forceKillAfter) return attempt
  398. return Effect.timeoutOrElse(attempt, {
  399. duration: opts.forceKillAfter,
  400. orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
  401. })
  402. },
  403. unref: Effect.sync(() => {
  404. if (ref) {
  405. proc.unref()
  406. ref = false
  407. }
  408. return Effect.sync(() => {
  409. if (!ref) {
  410. proc.ref()
  411. ref = true
  412. }
  413. })
  414. }),
  415. })
  416. }
  417. case "PipedCommand": {
  418. const flat = flatten(command)
  419. const [head, ...tail] = flat.commands
  420. let handle = spawnCommand(head)
  421. for (let i = 0; i < tail.length; i++) {
  422. const next = tail[i]
  423. const opts = flat.opts[i] ?? {}
  424. const sin = stdin(next.options)
  425. const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from)))
  426. const to = opts.to ?? "stdin"
  427. if (to === "stdin") {
  428. handle = spawnCommand(
  429. ChildProcess.make(next.command, next.args, {
  430. ...next.options,
  431. stdin: { ...sin, stream },
  432. }),
  433. )
  434. continue
  435. }
  436. const fd = ChildProcess.parseFdName(to)
  437. if (Predicate.isUndefined(fd)) {
  438. handle = spawnCommand(
  439. ChildProcess.make(next.command, next.args, {
  440. ...next.options,
  441. stdin: { ...sin, stream },
  442. }),
  443. )
  444. continue
  445. }
  446. handle = spawnCommand(
  447. ChildProcess.make(next.command, next.args, {
  448. ...next.options,
  449. additionalFds: {
  450. ...next.options.additionalFds,
  451. [ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream },
  452. },
  453. }),
  454. )
  455. }
  456. return yield* handle
  457. }
  458. }
  459. },
  460. )
  461. return makeSpawner(spawnCommand)
  462. })
  463. export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
  464. ChildProcessSpawner,
  465. make,
  466. )
  467. export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
  468. export * as CrossSpawnSpawner from "./cross-spawn-spawner"