cross-spawn-spawner.ts 18 KB

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