Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 33 additions & 33 deletions packages/core/src/cross-spawn-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,29 +263,29 @@ export const make = Effect.gen(function* () {
}

const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {
resume(Effect.fail(toPlatformError("spawn", err, command)))
})
proc.on("exit", (...args) => {
exit = args
})
proc.on("close", (...args) => {
if (end) return
end = true
Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
})
proc.on("spawn", () => {
resume(Effect.succeed([proc, signal]))
})
return Effect.sync(() => {
proc.kill("SIGTERM")
})
})
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal, ExitSignal], PlatformError.PlatformError>(
(resume) => {
const exited = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const closed = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
proc.on("error", (err) => {
resume(Effect.fail(toPlatformError("spawn", err, command)))
})
proc.on("exit", (...args) => {
Deferred.doneUnsafe(exited, Exit.succeed(args))
})
proc.on("close", (...args) => {
Deferred.doneUnsafe(closed, Exit.succeed(args))
Deferred.doneUnsafe(exited, Exit.succeed(args))
})
proc.on("spawn", () => {
resume(Effect.succeed([proc, exited, closed]))
})
return Effect.sync(() => {
proc.kill("SIGTERM")
})
},
)

const killGroup = (
command: ChildProcess.StandardCommand,
Expand Down Expand Up @@ -368,7 +368,7 @@ export const make = Effect.gen(function* () {
const extra = fds(command.options)
const dir = yield* cwd(command.options)

const [proc, signal] = yield* Effect.acquireRelease(
const [proc, exited, closed] = yield* Effect.acquireRelease(
spawn(command, {
cwd: dir,
env: env(command.options),
Expand All @@ -377,23 +377,23 @@ export const make = Effect.gen(function* () {
shell: command.options.shell,
windowsHide: process.platform === "win32",
}),
Effect.fnUntraced(function* ([proc, signal]) {
const done = yield* Deferred.isDone(signal)
Effect.fnUntraced(function* ([proc, exited, closed]) {
const done = yield* Deferred.isDone(exited)
const kill = timeout(proc, command, command.options)
if (done) {
const [code] = yield* Deferred.await(signal)
const [code] = yield* Deferred.await(exited)
if (process.platform === "win32") return yield* Effect.void
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
return yield* Effect.void
}
const send = (s: NodeJS.Signals) =>
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
const sig = command.options.killSignal ?? "SIGTERM"
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(closed)), Effect.asVoid)
const escalated = command.options.forceKillAfter
? Effect.timeoutOrElse(attempt, {
duration: command.options.forceKillAfter,
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(closed)), Effect.asVoid),
})
: attempt
return yield* Effect.ignore(escalated)
Expand All @@ -411,8 +411,8 @@ export const make = Effect.gen(function* () {
all: out.all,
getInputFd: fd.getInputFd,
getOutputFd: fd.getOutputFd,
isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
isRunning: Effect.map(Deferred.isDone(exited), (done) => !done),
exitCode: Effect.flatMap(Deferred.await(exited), ([code, signal]) => {
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
return Effect.fail(
toPlatformError(
Expand All @@ -426,11 +426,11 @@ export const make = Effect.gen(function* () {
const sig = opts?.killSignal ?? "SIGTERM"
const send = (s: NodeJS.Signals) =>
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(closed)), Effect.asVoid)
if (!opts?.forceKillAfter) return attempt
return Effect.timeoutOrElse(attempt, {
duration: opts.forceKillAfter,
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(closed)), Effect.asVoid),
})
},
unref: Effect.sync(() => {
Expand Down
17 changes: 17 additions & 0 deletions packages/core/test/effect/cross-spawn-spawner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,23 @@ describe("cross-spawn spawner", () => {
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
}),
)

fx.live(
"returns exit code when a detached child keeps stdio open",
Effect.gen(function* () {
const started = Date.now()
const handle = yield* js([
'const cp = require("node:child_process")',
'const child = cp.spawn(process.execPath, ["-e", "setTimeout(() => {}, 3000)"], { detached: true, stdio: "inherit" })',
"child.unref()",
"process.exit(0)",
].join("\n"))
const code = yield* handle.exitCode

expect(code).toBe(ChildProcessSpawner.ExitCode(0))
expect(Date.now() - started).toBeLessThan(2_000)
}),
)
})

describe("cwd option", () => {
Expand Down
Loading