Repository navigation
ShellTool can return before the output stream drains #30001
Description
Description
Shell command output can be missing or truncated when the process produced output.
The source of the issue is a race condition in packages/opencode/src/tool/shell.ts where ShellTool computes its final result from state mutated by a forked fiber. The main fiber can continue after process completion without synchronizing with that forked fiber. As a result, the final result can observe (no output) or missing tail output.
The fix is to join the output-reader fiber before computing the final shell output. I'm planning to open a PR for the fix shortly.
Plugins
None
OpenCode version
dev as of 2026-05-30 / ac8e686
Steps to reproduce
I originally encountered this issue organically while working with the opencode source. I'm finding it's really hard to repro organically, so I wrote a test that reliably reproduces the issue and am including that here instead.
For easy repro I placed this test in packages/opencode/test/tool/shell-output-drain.test.ts which can then be run via bun test test/tool/shell-output-drain.test.ts
This test fails on dev but passes on my branch:
import { expect } from "bun:test"
import { Effect, Layer, Sink, Stream } from "effect"
import path from "path"
import { Agent } from "@/agent/agent"
import { Config } from "@/config/config"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { MessageID, SessionID } from "@/session/schema"
import { ShellTool } from "@/tool/shell"
import { Truncate } from "@/tool/truncate"
import { ChildProcessSpawner } from "effect/unstable/process"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Plugin } from "../../src/plugin"
import { provideInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const marker = "OPENCODE_SHELL_OUTPUT_DRAIN_MARKER"
const projectRoot = path.join(__dirname, "../..")
const outputDrainLayer = Layer.mergeAll(
Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
ChildProcessSpawner.make(() =>
Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(0),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
stdin: Sink.drain,
stdout: Stream.empty,
stderr: Stream.empty,
all: Stream.fromEffect(Effect.sleep("10 millis")).pipe(
Stream.flatMap(() => Stream.make(new TextEncoder().encode(marker))),
),
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
unref: Effect.succeed(Effect.void),
}),
),
),
),
AppFileSystem.defaultLayer,
Plugin.defaultLayer,
Truncate.defaultLayer,
Config.defaultLayer,
Agent.defaultLayer,
RuntimeFlags.defaultLayer,
)
const it = testEffect(outputDrainLayer)
const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
}
it.live("waits for output stream before returning", () =>
Effect.gen(function* () {
const tool = yield* ShellTool
const shell = yield* tool.init()
const result = yield* shell.execute(
{
command: "echo test",
description: "Output drain test",
timeout: 5000,
},
ctx,
)
expect(result.output).toContain(marker)
expect(result.metadata.output).toContain(marker)
}).pipe(provideInstance(projectRoot)),
)
Screenshot and/or share link
No response
Operating System
macOS 26.5
Terminal
iTerm2
Activity
github-actions commentedon Jul 30, 2026
To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.