Skip to content

Fix killing a pipeline leaves its root process running - #7073

Merged
tim-smart merged 2 commits into
mainfrom
audit/repro-17f0b91a-node-shared-pipeline-kill
Aug 5, 2026
Merged

Fix killing a pipeline leaves its root process running#7073
tim-smart merged 2 commits into
mainfrom
audit/repro-17f0b91a-node-shared-pipeline-kill

Conversation

@fubhy

@fubhy fubhy commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Calling kill on a shared Node handle for a piped command terminates only the tail process and can leave earlier pipeline stages running.

Important

This PR includes the focused regression test and the implementation fix.

Killing a pipeline leaves its root process running

Module: platform-node-shared/NodeChildProcessSpawner
Audit ID: effect-4bd8d5ecaeff09f4
Severity / confidence: medium / high

What happens

Calling kill on a shared Node handle for a piped command terminates only the tail process and can leave earlier pipeline stages running.

Why it happens

The shared Node spawner stores every stage in handles and applies unref across all of them, but builds the returned pipeline handle with kill: handle.kill, where handle is only the final stage. The independent Deno adapter applies kill to all handles in reverse order, confirming the platform contract is implemented at pipeline scope there.

Expected behavior

A spawned PipedCommand yields one ChildProcessHandle, whose kill operation controls the child process represented by that handle. As with the pipeline's all-handle unref, killing that aggregate command must terminate every process started for it.

Relevant implementation

These links and excerpts are pinned to audit base 17f0b91a243ccfe4a38d27debdc983adf434e738.

View problematic code at packages/platform-node-shared/src/NodeChildProcessSpawner.ts:567-616
      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 }
                }))
              )

View exact lines on GitHub

Excerpt truncated. Open the complete packages/platform-node-shared/src/NodeChildProcessSpawner.ts:567-642 range.

Reproduction

pnpm test --run packages/platform-node-shared/test/NodeChildProcessSpawner.test.ts

Observed failure: Focused contract assertion failed against 17f0b91, demonstrating: Killing a pipeline leaves its root process running.

Implementation

The Node spawner now kills every pipeline handle in reverse order, matching the Deno adapter. The regression verifies that both the root and tail processes stop.

Audit provenance

  • Audit base: 17f0b91a243ccfe4a38d27debdc983adf434e738
  • Reproduction base: 17f0b91a243ccfe4a38d27debdc983adf434e738
  • Findings: effect-4bd8d5ecaeff09f4
  • Fix: aggregate pipeline kills with coverage for root and tail processes

Closes EFF-480

@fubhy fubhy added the audit Findings originating from the Effect runtime correctness audit label Aug 5, 2026
@changeset-bot

changeset-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 90265a7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 30 packages
Name Type
@effect/platform-node-shared Patch
effect Patch
@effect/ai-anthropic Patch
@effect/ai-openai Patch
@effect/ai-openai-compat Patch
@effect/ai-openrouter Patch
@effect/atom-react Patch
@effect/atom-solid Patch
@effect/atom-vue Patch
@effect/docgen Patch
@effect/doctest Patch
@effect/openapi-generator Patch
@effect/opentelemetry Patch
@effect/platform-browser Patch
@effect/platform-bun Patch
@effect/platform-deno Patch
@effect/platform-node Patch
@effect/sql-clickhouse Patch
@effect/sql-d1 Patch
@effect/sql-libsql Patch
@effect/sql-mssql Patch
@effect/sql-mysql2 Patch
@effect/sql-pg Patch
@effect/sql-pglite Patch
@effect/sql-sqlite-bun Patch
@effect/sql-sqlite-do Patch
@effect/sql-sqlite-node Patch
@effect/sql-sqlite-react-native Patch
@effect/sql-sqlite-wasm Patch
@effect/vitest Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important

This PR adds a focused regression test but does not include the implementation fix its title claims. packages/platform-node-shared/src/NodeChildProcessSpawner.ts still forwards kill: handle.kill for the final pipeline stage only, so the root process is left running. The fix should mirror the Deno adapter.

Reviewed changes

  • Added it.live("kills every process in a pipeline", ...) in packages/platform-node-shared/test/NodeChildProcessSpawner.test.ts. The test correctly demonstrates the bug: after handle.kill({ killSignal: "SIGKILL" }), the root heartbeat continues growing (confirmed locally: AssertionError: expected 20n to equal 10n at NodeChildProcessSpawner.test.ts:39). The test must be paired with the implementation fix before merging.

⚠️ Implementation fix is missing

Technical details
# Missing pipeline kill aggregation

## Affected sites
- `packages/platform-node-shared/src/NodeChildProcessSpawner.ts:621-642` — returns the pipeline handle with `kill: handle.kill`, which forwards `kill` to only the final stage.

## Required outcome
- Killing a `PipedCommand` handle must terminate every stage started for that pipeline, matching the existing `unref` aggregation and the Deno adapter behavior.

## Suggested approach
- Build an aggregate `kill` in the `"PipedCommand"` branch:
  ```ts
  const kill = (options?: ChildProcess.KillOptions | undefined) =>
    Effect.forEach([...handles].reverse(), (handle) => Effect.ignore(handle.kill(options)), { discard: true })
  ```
- Pass `kill` to `makeHandle` instead of `handle.kill`, analogous to `packages/platform-deno/src/DenoChildProcessSpawner.ts:365-366`.

ℹ️ Nitpicks

  • The test name promises "kills every process", but only asserts the root heartbeat stops. Consider also asserting the child heartbeat stops, matching the Deno test at packages/platform-deno/test/DenoChildProcessSpawner.test.ts:138-139.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | Fix it ➔View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

This run reviewed the delta since the prior Pullfrog review (d427a584), which adds the implementation fix and completes the regression test coverage.

  • Added aggregate pipeline kill in NodeChildProcessSpawner.ts. The "PipedCommand" branch now builds a kill that iterates over every pipeline handle in reverse order, matching the Deno adapter.
  • Wired the aggregate kill into the returned handle. The pipeline handle no longer forwards kill to only the final stage.
  • Completed the regression test assertions. The test now verifies both the root and child heartbeats stop after handle.kill, matching the test name "kills every process in a pipeline".
  • Added a changeset describing the runtime behavior fix.

Pullfrog  | ⚠️ this action is pinned to a commit SHA, which freezes the cleanup step — switch to @v0 or keep the SHA fresh with Dependabot | View workflow run | Using Kimi K2 (free via Pullfrog for OSS) | 𝕏

@tim-smart
tim-smart enabled auto-merge (squash) August 5, 2026 23:45
@tim-smart
tim-smart merged commit e2ec131 into main Aug 5, 2026
20 checks passed
@tim-smart
tim-smart deleted the audit/repro-17f0b91a-node-shared-pipeline-kill branch August 5, 2026 23:47
@github-actions

github-actions Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Bundle Size Analysis

Generated from PR build output; treat the content below as untrusted.

File Name Current Size Previous Size Difference
basic.ts 7.06 KB 7.06 KB 0.00 KB (0.00%)
batching.ts 9.86 KB 9.86 KB 0.00 KB (0.00%)
brand.ts 6.34 KB 6.34 KB 0.00 KB (0.00%)
cache.ts 10.71 KB 10.71 KB 0.00 KB (0.00%)
config.ts 20.73 KB 20.73 KB 0.00 KB (0.00%)
differ.ts 20.31 KB 20.31 KB 0.00 KB (0.00%)
http-client.ts 21.53 KB 21.53 KB 0.00 KB (0.00%)
logger.ts 10.84 KB 10.84 KB 0.00 KB (0.00%)
metric.ts 8.98 KB 8.98 KB 0.00 KB (0.00%)
optic.ts 7.18 KB 7.18 KB 0.00 KB (0.00%)
pubsub.ts 14.99 KB 14.99 KB 0.00 KB (0.00%)
queue.ts 11.66 KB 11.66 KB 0.00 KB (0.00%)
schedule.ts 10.83 KB 10.83 KB 0.00 KB (0.00%)
schema-class.ts 19.27 KB 19.27 KB 0.00 KB (0.00%)
schema-fromJsonSchemaDocument.ts 29.09 KB 29.09 KB 0.00 KB (0.00%)
schema-representation-roundtrip.ts 25.40 KB 25.40 KB 0.00 KB (0.00%)
schema-string-transformation.ts 13.42 KB 13.42 KB 0.00 KB (0.00%)
schema-string.ts 10.95 KB 10.95 KB 0.00 KB (0.00%)
schema-template-literal.ts 15.21 KB 15.21 KB 0.00 KB (0.00%)
schema-toArbitraryLazy.ts 22.02 KB 22.02 KB 0.00 KB (0.00%)
schema-toCodeDocument.ts 24.45 KB 24.45 KB 0.00 KB (0.00%)
schema-toCodecJson.ts 19.28 KB 19.28 KB 0.00 KB (0.00%)
schema-toEquivalence.ts 19.11 KB 19.11 KB 0.00 KB (0.00%)
schema-toFormatter.ts 18.97 KB 18.97 KB 0.00 KB (0.00%)
schema-toJsonSchemaDocument.ts 22.69 KB 22.69 KB 0.00 KB (0.00%)
schema-toRepresentation.ts 19.60 KB 19.60 KB 0.00 KB (0.00%)
schema.ts 18.52 KB 18.52 KB 0.00 KB (0.00%)
stm.ts 12.63 KB 12.63 KB 0.00 KB (0.00%)
stream.ts 9.80 KB 9.80 KB 0.00 KB (0.00%)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

audit Findings originating from the Effect runtime correctness audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants