6

Backround

I am calling an executable from Python and need to pass a variable to the executable. The executable however expects a file and does not read from stdin.

I circumvented that problem previously when using the subprocess module by simply calling the executable to read from /dev/stdin along the lines of:

# with executable 'foo'
cmd = ['foo', '/dev/stdin']
input_variable = 'bar'

with subprocess.Popen(
    cmd,
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    ) as process:
    stdout, stderr = process.communicate(input_variable)

    print(f"{process.returncode}, {stdout}, {stderr}")

This worked fine so far. In order to add concurrency, I am now implementing asyncio and as such need to replace the subprocess module with the asyncio subprocess module.

Problem

Calling asyncio subprocess for a program using /dev/stdin fails. Using the following async function:

import asyncio

async def invoke_subprocess(cmd, args, input_variable):
    process = await asyncio.create_subprocess_exec(
        cmd,
        args,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        stdin=asyncio.subprocess.PIPE,
    )

    stdout, stderr = await process.communicate(input=bytes(input_variable, 'utf-8'))

    print(f"{process.returncode}, {stdout.decode()}, {stderr.decode()}")

This generally works for files, but fails for /dev/stdin:

# 'cat' can be used for 'foo' to test the behavior
asyncio.run(invoke_subprocess('foo', '/path/to/file/containing/bar', 'not used')) # works
asyncio.run(invoke_subprocess('foo', '/dev/stdin', 'bar')) # fails with "No such device or address"

How can I call asyncio.create_subprocess_exec on /dev/stdin?

Note: I have already tried and failed via asyncio.create_subprocess_shell and writing a temporary file is not an option as the file system is readonly.

Minimal example using 'cat'

Script main.py:

import subprocess
import asyncio

def invoke_subprocess(cmd, arg, input_variable):
    with subprocess.Popen(
        [cmd, arg],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        ) as process:
        stdout, stderr = process.communicate(input_variable)

        print(f"{process.returncode}, {stdout}, {stderr}")


async def invoke_async_subprocess(cmd, arg, input_variable):
    process = await asyncio.create_subprocess_exec(
        cmd,
        arg,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
        stdin=asyncio.subprocess.PIPE,
    )

    stdout, stderr = await process.communicate(input=input_variable)

    print(f"{process.returncode}, {stdout.decode()}, {stderr.decode()}")


cmd = 'cat'
arg = '/dev/stdin'
input_variable = b'hello world'

# normal subprocess
invoke_subprocess(cmd, arg, input_variable)
asyncio.run(invoke_async_subprocess(cmd, arg, input_variable))

Returns:

> python3 main.py
0, b'hello world', b''
1, , cat: /dev/stdin: No such device or address

Tested on:

  • Ubuntu 21.10, Python 3.9.7
  • Linux Mint 20.2, Python 3.8.10
  • Docker image: python:3-alpine
14
  • Are you sure you're running the exact same program in both cases? Whether the program can access /dev/stdin should bear no relation to how it was started. Commented Jan 7, 2022 at 19:21
  • @user4815162342 'foo' is the exact same program. I was also surprised and wondered whether asyncio.create_subprocess_exec somehow forbids accessing /dev/stdin or runs for some magical reason with different permissions Commented Jan 9, 2022 at 21:07
  • 1
    Thanks for providing a minimal example, I can indeed reproduce this on my machine! strace confirms that cat is invoked correctly but (in the asyncio case) gets ENXIO when opening /dev/stdin. It seems the result of an implementation detail of asyncio subprocess vs regular subprocess is interfering with this. To see the difference, change cmd, arg to something like "ls", "-l", "/self/proc/fd/0". For subprocess you'll get something like /proc/self/fd/0 -> pipe:[7344202], whereas for asyncio you'll get /proc/self/fd/0 -> socket:[7339428]. Commented Jan 11, 2022 at 15:06
  • 1
    So asyncio uses socket.socketpair() to communicate with the subprocess, whereas subprocess uses a pipe. Asyncio claims that "not all platforms support selecting read events on the write end of a pipe", naming AIX in particular. This seemingly inoccuous change breaks re-opening of /dev/stdin, which works with a pipe, but doesn't work with a socket. Bummer. Commented Jan 11, 2022 at 15:07
  • 1
    Note that this also means that programs which implement special treatment for the - filename (esp. GNU tools like cat) will be able to use stdin in this case. It will depend on the implementation of the OP's foo program whether that is a useful workaround or not. Commented Jan 11, 2022 at 18:59

1 Answer 1

2

I'll briefly wrap up the question and summarize the outcome of the discussion.

In short: The problem is related to a bug in Python's asyncio library that has been fixed by now. It should no longer occur in upcoming versions.

Bug Details: In contrast to the Python subprocess library, asyncio uses a socket.socketpair() and not a pipe to communicate with the subprocess. This was introduced in order to support the AIX platform. However, it breaks when re-opening /dev/stdin that doesn't work with a socket. It was fixed by only using sockets on AIX platform.

Sign up to request clarification or add additional context in comments.

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.