diff --git a/.changeset/fix-process-group-nonzero-exit-cleanup.md b/.changeset/fix-process-group-nonzero-exit-cleanup.md new file mode 100644 index 00000000000..04ddefebad8 --- /dev/null +++ b/.changeset/fix-process-group-nonzero-exit-cleanup.md @@ -0,0 +1,5 @@ +--- +"@effect/platform-node-shared": patch +--- + +Honor `forceKillAfter` during scope release after a command leader exits with a non-zero code, so surviving descendants that ignore the termination signal are forcefully killed. diff --git a/packages/platform/node-shared/src/NodeChildProcessSpawner.ts b/packages/platform/node-shared/src/NodeChildProcessSpawner.ts index e5f7a0dd7eb..26143d53daa 100644 --- a/packages/platform/node-shared/src/NodeChildProcessSpawner.ts +++ b/packages/platform/node-shared/src/NodeChildProcessSpawner.ts @@ -547,7 +547,7 @@ const make = Effect.gen(function*() { if (exited) { const [code] = yield* Deferred.await(exitSignal) if (code !== 0 && Predicate.isNotNull(code)) { - yield* Effect.ignore(killProcessGroup(cmd, childProcess, cmd.options.killSignal ?? "SIGTERM")) + yield* Effect.ignore(terminateProcessGroup(cmd, childProcess, exitSignal, cmd.options)) } else if (isReferenced && process.platform !== "win32" && cmd.options.detached !== false) { yield* Effect.ignore(terminateProcessGroup(cmd, childProcess, exitSignal, cmd.options)) } diff --git a/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts b/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts index e0dc3d52b62..19787f6b9a5 100644 --- a/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts +++ b/packages/platform/node-shared/test/NodeChildProcessSpawner.test.ts @@ -189,6 +189,22 @@ describe.skipIf(process.platform === "win32")("process group cleanup", () => { assert.strictEqual(yield* fs.readFileString(marker), "exited") }).pipe(Effect.scoped, Effect.provide(NodeServices))) + it.live("scope release force kills descendants after the leader exits with a non-zero code", () => + Effect.gen(function*() { + const { descendantPid, handle, marker, scope } = yield* startProcessGroup("ignore-signal", { + stdin: "pipe", + forceKillAfter: "200 millis" + }) + yield* Effect.addFinalizer(() => killDescendant(descendantPid)) + + yield* Stream.run(Stream.make(new TextEncoder().encode("exit 1\n")), handle.stdin) + assert.strictEqual(yield* handle.exitCode, 1) + + yield* Scope.close(scope, Exit.void).pipe(liveTimeout(2_000)) + + yield* assertHeartbeatStopped(marker) + }).pipe(Effect.scoped, Effect.provide(NodeServices))) + it.live("scope release cleans descendants after the leader is killed externally", () => Effect.gen(function*() { const fs = yield* FileSystem.FileSystem diff --git a/packages/platform/node-shared/test/fixtures/process-group.ts b/packages/platform/node-shared/test/fixtures/process-group.ts index 7c35be6754f..3f7c66ba7a0 100644 --- a/packages/platform/node-shared/test/fixtures/process-group.ts +++ b/packages/platform/node-shared/test/fixtures/process-group.ts @@ -10,7 +10,7 @@ if (role === "leader") { spawn(process.execPath, [process.argv[1], "descendant", mode, marker], { stdio: ["ignore", "inherit", "inherit"] }) - process.stdin.once("data", () => process.exit(0)) + process.stdin.once("data", (data) => process.exit(data.toString().trim() === "exit 1" ? 1 : 0)) setInterval(() => {}, 1_000) } else { if (mode === "exit-on-signal") {