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.
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.spawnwith explicitstdout: "ignore"andstderr: "ignore"options:After the refactor, these options were dropped:
The
CrossSpawnSpawnerdefaults tostdio: ["pipe", "pipe", "pipe"]when no stdout/stderr options are provided. This creates OS pipes for the formatter process's stdout and stderr.handle.exitCodeincross-spawn-spawner.tswaits on the Node.jscloseevent (notexit). Node'scloseonly fires after all stdio stream file descriptors are closed — including those held by any grandchild processes that inherited them.When a formatter like
pantsspawns worker subprocesses (JVM workers, Pex resolvers, etc.), those grandchildren inherit the open pipe FDs. Even afterpantsitself exits (triggeringexit),closenever fires because the grandchildren still hold the pipes open. TheDeferredthathandle.exitCodeawaits is never resolved, and the tool call hangs forever.The formatter output is not read anywhere (
handle.stdout/handle.stderrare never consumed), so the pipe buffers also fill up, compounding the issue.Fix
Restore
stdout: "ignore"andstderr: "ignore"inpackages/opencode/src/format/index.ts:With
"ignore", no pipes are created for stdout/stderr, soclosefires immediately when the formatter process exits regardless of any grandchild processes still running.Reproduction
Configure a custom formatter in
.opencode/opencode.jsoncthat 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.