Skip to content

Shell tool hangs forever when the command contains a NUL character #51351

Open

Description

@kernel-oops

Description

A shell tool call whose command contains a NUL character (U+0000) never finishes. The call shows as running indefinitely, its timeout never fires, and it cannot be moved to the background, because no process was ever started. Only interrupting the turn ends it.

Models do occasionally emit a NUL inside a command (we hit it with a printf "label:\u0000\n" inside an ssh command), so this is reachable in normal use.

Cause: Node and Bun reject a NUL in any spawn argument by throwing synchronously (ERR_INVALID_ARG_VALUE: ... must be a string without null bytes). Inside the spawner's Effect.callback that becomes a defect, not a PlatformError. In packages/core/src/shell.ts, create waits on a ready deferred that the managing fiber settles only for AppProcessError (Effect.catchTag("AppProcessError", ...)), so the defect kills the fiber and ready is never completed. The timeout is armed only after a successful spawn, and no command is registered for backgrounding, so nothing can end the wait.

The same hang applies to any other defect raised before ready settles.

Plugins

None needed.

OpenCode version

v2.0.16 (also the current v2 branch)

Steps to reproduce

  1. Ask the agent to run a shell command containing a NUL, e.g. printf 'a\u0000b' with a literal U+0000 in the command string, with any timeout.
  2. The tool call stays "running" forever; no process exists; the timeout does not fire; backgrounding does nothing.
  3. Interrupting the turn is the only way out.

Screenshot and/or share link

n/a

Operating System

Debian 13 (Linux 6.12)

Terminal

n/a

Activity

  1. github-actions commented on Sep 25, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #49997: null bytes in shell-related paths cause /api/location and /api/fs/list failures.
    • #48838: shell creation can remain pending in Shell.create before the readiness handoff.
    • #50424: a shell tool call remains status=running indefinitely after a command exits.
  2. kernel-oops commented on Sep 25, 2026

    @kernel-oops
    ContributorAuthor

    Checked the three suggestions; none is this bug, though #48838 is in the same spot:

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the detailed report. I could reproduce this on v2.0.16.

    In the test, a simulated model called the shell tool with printf 'a<U+0000>b' (a literal NUL) and timeout: 2000:

    • NUL command: after 25 seconds the call still had not finished, the timeout never fired and the turn never continued. Only interrupting the turn ended it.
    • Same command without the NUL (printf 'ab'): the call finished at once.

    The script below reproduces it (set CONTROL=1 for the run without the NUL). While the NUL call was stuck, the TUI also stopped answering screenshot requests, so there is no recording.

    I could not confirm the behaviour on the latest dev (86ce19d) at runtime, because my test setup did not reach the shell tool there. The part of packages/core/src/shell.ts you point to, which only handles AppProcessError before ready is settled, looks unchanged on dev, so it is probably still affected. Related: your earlier fix PR #51352 was closed by the automatic cleanup of old PRs, not after a review.

    Reproduction script (drive.ts)
    // Repro for #51351: a shell tool call whose command contains U+0000 never finishes.
    // The model calls `shell` with a 2000 ms timeout; if the call settled (error or
    // timeout), the model's follow-up text "AFTER_SHELL" would appear. It never does.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        config: { permission: { "*": "allow" } } as any,
        tui: { recording: !process.env.NOREC },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          yield* llm.queue(
            Llm.toolCall({
              index: 0,
              id: "call_nul",
              name: "shell",
              input: { command: process.env.CONTROL ? "printf 'ab'" : "printf 'a\u0000b'", timeout: 2000 },
            }),
            Llm.finish("tool-calls"),
          )
          yield* llm.queue(Llm.text("AFTER_SHELL: the shell call settled."))
          yield* Effect.log("submitting")
          yield* ui.submit("Run printf with a NUL byte")
          yield* Effect.log("submitted")
          yield* Effect.sleep(5000)
          yield* Effect.log("slept")
          // The timeout is 2 s; give it 10x that.
          const settled = yield* ui.waitFor("AFTER_SHELL", { timeout: 20000 }).pipe(
            Effect.as(true),
            Effect.catchTag("UiWaitTimeoutError", () => Effect.succeed(false)),
          )
          yield* Effect.log(settled ? "RESULT: shell call settled (not reproduced)" : "RESULT: shell call still running after 25s with a 2s timeout (HANG REPRODUCED)")
          // End the stuck turn so the run can settle.
          yield* ui.press("escape")
          yield* ui.press("escape")
          yield* Effect.sleep(1500)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  4. Santiago-j-s commented on Nov 4, 2026

    @Santiago-j-s

    Confirming this on OpenCode 2.2.5, macOS/Darwin 25.5.0 arm64, using Desktop's matching bundled CLI and shared service. I reported #61838 before finding this issue; it appears to be the same bug.

    Here is a small reproduction through /api/shell that does not require a model or opencode-drive. Replace opencode with the Desktop-bundled CLI path if needed:

    import json
    import subprocess
    import time
    
    payload = {"command": "printf probe" + chr(0), "shell": "/bin/zsh", "timeout": 500}
    started = time.monotonic()
    try:
        result = subprocess.run(
            ["opencode", "api", "post", "/api/shell", "--data", json.dumps(payload)],
            capture_output=True, text=True, timeout=5,
        )
        print(result.returncode, result.stdout, result.stderr)
    except subprocess.TimeoutExpired:
        print(f"No response after {time.monotonic() - started:.2f}s; client watchdog stopped CLI")

    Observed: no response after 5.01 seconds, despite the 500 ms shell timeout. A control command without the NUL returned in 0.16 seconds and exited successfully. The Python timeout is an independent watchdog stopping the client, not OpenCode enforcing its shell timeout.

    Local plugins were enabled, but this direct API reproduction bypasses the shell tool's Code Mode transform; a completely plugin-free service was not tested.

    Related: merged PR #60651 adds a typed pre-spawn NUL guard and explicitly describes this failure class. That guard is absent from the published v2.2.5 source. I have not tested the fixed development branch. Adding this reproduction and newer-version/platform confirmation here so tracking can remain on this issue; #61838 can close as a duplicate.


    Note from OpenCode: This comment was written and posted by an AI agent at the account owner’s request. The reproduction was run locally by the agent.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions