Skip to content

50 critical stdin deadlock

Part VII: Ecosystem, Runtime & Agent-Specific | Challenge §50

50. Stdin Consumption Deadlock

Severity: Critical | Frequency: Common | Detectability: Hard | Token Spend: High | Time: Critical | Context: Low

The Problem

Distinct from §10 (interactive prompts), some CLI tools silently read from stdin as a default fallback — not as a deliberate prompt but as an undocumented behavior: reading config from stdin when no config flag is provided, reading a list of IDs from stdin when no positional args are given, defaulting --password to a stdin read when the flag is omitted. In non-TTY context, this blocks indefinitely waiting for an EOF that never comes.

# Tool reads entity IDs from stdin when none are provided as args
$ my-tool delete    # agent omits the --id flag
# ← blocks forever, waiting for stdin EOF in non-TTY mode

# Password argument defaults to stdin read when omitted
$ my-tool --user admin    # agent forgets --password flag
Password:     # ← blocking read from stdin
# In non-TTY mode: hangs until timeout

# Tool falls back to reading config from stdin
$ my-tool run     # no --config flag provided
# Waiting for config JSON on stdin...

The tell: the process is running, consuming no CPU, producing no output — indistinguishable from "slow initialization" until the full timeout fires.

Impact

  • Agent's entire timeout budget burned silently with no error
  • No warning or error is emitted before blocking occurs
  • Tool may need to be killed externally, potentially leaving partial state
  • If stdin is connected to a pipe, the tool may read the pipe content as its "config", corrupting input

Solutions

Non-TTY stdin reads must fail immediately with exit 4:

{
  "ok": false,
  "error": {
    "code": "STDIN_REQUIRED",
    "message": "Argument '--ids' requires input but stdin is not a TTY and no value was provided.",
    "hint": "Pass --ids <value> or pipe data: echo '123' | my-tool delete --ids -"
  }
}

Schema must declare all stdin-reading paths:

{
  "args": [
    {
      "name": "ids",
      "stdin_fallback": true,
      "stdin_format": "newline-separated IDs",
      "non_tty_behavior": "fail_with_exit_4"
    }
  ]
}

For framework design: - All stdin reads must be declared in the command schema; undeclared stdin reads are a framework error - In non-TTY mode, the framework wraps stdin.read() calls with an immediate-fail guard that exits 4 with a structured error listing the flag to pass instead - The --schema output for every command must indicate which args accept stdin as input and what format is expected

Evaluation

Score Condition
0 Tool blocks indefinitely on stdin read in non-TTY mode; no timeout, no structured error, no hint
1 Tool eventually fails or produces an unhelpful message; no structured error; STDIN_REQUIRED code absent
2 Non-TTY stdin read exits immediately with STDIN_REQUIRED structured error and a hint showing the flag to use
3 All stdin-reading args declared in schema with stdin_fallback and non_tty_behavior fields; framework enforces immediately on non-TTY

Check: Invoke a command that reads from stdin with stdin=subprocess.DEVNULL — verify it exits within 1 second with {"code": "STDIN_REQUIRED", "hint": "..."}.


Agent Workaround

Always pass stdin=DEVNULL; if a required arg is missing, the tool should fail fast — treat 1s hangs as stdin reads:

import subprocess, json, signal

def run_no_stdin(cmd: list[str], timeout: int = 10) -> dict:
    try:
        result = subprocess.run(
            cmd,
            capture_output=True, text=True,
            stdin=subprocess.DEVNULL,   # critical: never let tool inherit stdin
            timeout=timeout,
        )
    except subprocess.TimeoutExpired as e:
        e.process.kill()
        raise RuntimeError(
            f"Command timed out after {timeout}s with DEVNULL stdin — "
            "likely blocking on undeclared stdin read. "
            "Check schema for required args that default to stdin fallback."
        )

    try:
        parsed = json.loads(result.stdout)
    except json.JSONDecodeError:
        raise RuntimeError(f"No JSON output: {result.stdout[:200]}")

    if not parsed.get("ok"):
        error = parsed.get("error", {})
        if error.get("code") == "STDIN_REQUIRED":
            hint = error.get("hint", "pass the required argument explicitly")
            raise RuntimeError(f"Tool requires stdin input: {hint}")

    return parsed

Limitation: If the tool reads from /dev/tty directly (bypassing stdin), DEVNULL does not prevent the block — use a short timeout (5–10 seconds) on every invocation as a universal guard against undeclared stdin reads