Skip to content

ShellTool can return before the output stream drains #30001

Closed as not planned
Closed as not planned

Description

@dstaple

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

  1. github-actions commented on Jul 30, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

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

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions