Skip to content

Formatter hangs indefinitely when spawned command has background child processes (stdout/stderr pipe never closes) #26032

@benceferdinandy-signifyd

Description

Bug

When using a custom formatter (e.g. pants --no-pantsd fix \$FILE) that spawns background/worker child processes, the formatter call in opencode hangs indefinitely and the edit/write tool never returns.

Root Cause

Introduced in commit 5cd54ec34 ("refactor(format): use ChildProcessSpawner instead of Process.spawn", Mar 27 2026).

Before this refactor, the formatter used Process.spawn with explicit stdout: "ignore" and stderr: "ignore" options:

// old code — before 5cd54ec34
const proc = Process.spawn(
  item.command.map((x) => x.replace("$FILE", filepath)),
  {
    cwd: Instance.directory,
    env: { ...process.env, ...item.environment },
    stdout: "ignore",  // ← pipes not created
    stderr: "ignore",  // ← pipes not created
  },
)
const exit = await proc.exited

After the refactor, these options were dropped:

// new code — missing stdout/stderr options
ChildProcess.make(replaced[0]!, replaced.slice(1), {
  cwd: dir,
  env: item.environment,
  extendEnv: true,
  // no stdout/stderr → defaults to "pipe"
})

The CrossSpawnSpawner defaults to stdio: ["pipe", "pipe", "pipe"] when no stdout/stderr options are provided. This creates OS pipes for the formatter process's stdout and stderr.

handle.exitCode in cross-spawn-spawner.ts waits on the Node.js close event (not exit). Node's close only fires after all stdio stream file descriptors are closed — including those held by any grandchild processes that inherited them.

When a formatter like pants spawns worker subprocesses (JVM workers, Pex resolvers, etc.), those grandchildren inherit the open pipe FDs. Even after pants itself exits (triggering exit), close never fires because the grandchildren still hold the pipes open. The Deferred that handle.exitCode awaits is never resolved, and the tool call hangs forever.

The formatter output is not read anywhere (handle.stdout / handle.stderr are never consumed), so the pipe buffers also fill up, compounding the issue.

Fix

Restore stdout: "ignore" and stderr: "ignore" in packages/opencode/src/format/index.ts:

ChildProcess.make(replaced[0]!, replaced.slice(1), {
  cwd: dir,
  env: item.environment,
  extendEnv: true,
  stdout: "ignore",
  stderr: "ignore",
})

With "ignore", no pipes are created for stdout/stderr, so close fires immediately when the formatter process exits regardless of any grandchild processes still running.

Reproduction

Configure a custom formatter in .opencode/opencode.jsonc that uses a tool which spawns background workers (e.g. pants --no-pantsd fix \$FILE), then have opencode edit any matching file. The edit tool call will hang indefinitely.

Impact

Any project using a custom formatter that spawns background child processes will see all edit/write operations hang permanently after the first file edit.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions