"Ansible requires blocking IO on stdin/stdout/stderr" when an AI agent runs Ansible
The first time my coding agent ran Ansible, every command failed before Ansible had read a single file:
ERROR: Ansible requires blocking IO on stdin/stdout/stderr. Non-blocking file handles detected: <stdout>, <stderr>
--syntax-check too, and even ansible --version. If you see this from an agent, a CI job, or a wrapper script, Ansible isn’t broken. The process that started it handed it non-blocking file handles, and a few shell redirections replace them.
The setup #
- ansible-core 2.20 (Ansible 13.3.0 from Homebrew) on macOS
- A coding agent (Claude Code in VS Code) that runs commands in its own shell and captures their output
The check has been in every Ansible CLI entry point since ansible-core 2.14 (2022).
What went wrong #
O_NONBLOCK is a flag on the open file, not on the process, so every child that inherits the file descriptor inherits the flag too. The agent’s harness connects the shell’s stdout and stderr to non-blocking pipes, and the shell passes them on unchanged to ansible-playbook.
At startup, Ansible calls os.get_blocking() on each of the three handles and exits if any of them is non-blocking. The list at the end of the message names the handles that failed. That makes it a good diagnostic: the first failure in my session listed only <stderr>, because the command was ansible --version | head -3. The pipe to head had already given stdout a fresh, blocking handle, while stderr still pointed at the agent’s pipe.
Run this in the shell you suspect to see which handles are affected:
python3 -c 'import os; print([os.get_blocking(fd) for fd in (0, 1, 2)])'
False in any position means Ansible will refuse to start there.
Fix #
Give Ansible three handles the shell creates itself:
ansible-playbook site.yml </dev/null 2>&1 | cat
| catgives stdout a new pipe, which is blocking.2>&1sends stderr into that same pipe.| catalone fixes only stdout.</dev/nullreplaces stdin. My agent’s stdin was never flagged, but Ansible checks it too, and an agent can’t answer a prompt anyway.
The pipe has a side effect: the command’s exit status is now cat’s, which is 0. A failed playbook looks like a success unless you turn on pipefail (bash and zsh both have it):
set -o pipefail
ansible-playbook site.yml </dev/null 2>&1 | cat
To check the fix without touching your real shell, you can make all three handles non-blocking for a single command and try it:
NB='import os,sys; [os.set_blocking(fd, False) for fd in (0,1,2)]; os.execvp(sys.argv[1], sys.argv[1:])'
(python3 -c "$NB" ansible --version) 2>&1 </dev/zero | cat
# ERROR: ... Non-blocking file handles detected: <stdin>, <stdout>, <stderr>
(python3 -c "$NB" sh -c 'ansible --version </dev/null 2>&1 | cat') 2>&1 </dev/zero | cat
# ansible [core 2.20.1] ...
The outer | cat and </dev/zero keep the flag on throwaway handles, so your terminal stays blocking.
Why Ansible insists #
The pull request that added the check
explains it. On a non-blocking stdout, a large write can fail partway with [Errno 11] write could not complete without blocking, and Ansible’s output gets cut off, or the run dies with an “Unexpected Exception”. The check turns those intermittent failures into one clear error at startup. The PR names another way to end up there: ssh switches its handles to non-blocking while it runs and doesn’t always switch them back when it exits.
So the same error can turn up wherever a parent process hands down non-blocking pipes, CI runners included, or in a terminal after an ssh that didn’t clean up.
Make the agent remember it #
The agent worked this out within two minutes, and later that night it wrote the fix into the project’s skill for provisioning VMs:
Run Ansible from Claude Code with `</dev/null` and pipe its output through `cat`.
The next morning, a session about a different service ran ansible-playbook --syntax-check without the redirections and hit the same error. It never loaded the VM skill, so it never saw the line. A rule about how to run a tool belongs in the file the agent reads in every session, not in the instructions for one task.
Takeaway #
“Ansible requires blocking IO” means the process that started Ansible handed it non-blocking file handles. </dev/null 2>&1 | cat gives it blocking ones, and set -o pipefail keeps a failed run from looking like a success.
If your agent runs Ansible, put that line in the instructions file it reads every session (CLAUDE.md or AGENTS.md).