Skip to content

asyncio.streams.streamWriter used with a fifo on macOS closes after one write #145030

@neek78

Description

@neek78

Bug report

Bug description:

When hooking up an asyncio.streams.streamWriter to a fifo on macOS, the 'reader trick' seems to backfire.

The read event seems to fire immediately (well, at least as soon as it has a chance to execute), meaning _UnixWritePipeTransport goes straight to closed state. it does seem to need the write/drain to happen to trigger this. Maybe both ends of the fifo are going readable after the write?

I'm not 100% certain what conditions cause this to happen, but I can reliably reproduce this just with cat reading the other end of the fifo.

This seems loosely related to #109757, as well as maybe #63492 #63493

It possible to work around this by hacking in an is_fifo=False at unix_events.py:657, i.e. just before this code, cancelling registering the _add_reader event -

if is_socket or (is_fifo and not sys.platform.startswith("aix")):
     # only start reading when connection_made() has been called
     self._loop.call_soon(self._loop._add_reader,
                          self._fileno, self._read_ready)

Tested with Python 3.14.3 (release), installed via Homebrew. This is on the latest production macOS (26.3 (25D125)) -

% uname -a
Darwin lappy 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020 arm64

For the record, I also tested this code on Linux, and it works fine. This was with Python 3.13.12 on kernel -

Linux lappy-debian2 6.18.5+deb14-arm64 #1 SMP PREEMPT Debian 6.18.5-1 (2026-01-16) aarch64 GNU/Linux

Ultimately, like the other related issues this seems like a platform problem - though I don't have an equivalent of strace to check this at the syscall level. So I guess I'm wondering if there's a way to work around it - or if I'm doing something wrong. Is there a way to switch _UnixWritePipeTransport into "AIX mode" so it ignores the read event for fifos without modifying the code already there?

There's reproducer code and example output below -

# client.py

import asyncio
import os

async def write_msg(writer):
    print("writing ... is_closing? ", writer.is_closing())
    writer.write(bytearray("MSG\n", "utf-8"))
    print("draining ... is_closing? ", writer.is_closing())
    await writer.drain()
    print("drain done ... is_closing? ", writer.is_closing())


async def main():
    # terminology is from server's POV - ie stdout is server's stdout
    stdin_fd = open("fifo_in", mode="w")
    print("fifo_in open fd=%d" % stdin_fd.fileno())

    os.set_blocking(stdin_fd.fileno(), False)

    loop = asyncio.events.get_running_loop()

    # explosion at unix_events.py:658
    writer_transport, writer_protocol = await loop.connect_write_pipe(
        lambda: asyncio.streams.FlowControlMixin(loop=loop), stdin_fd
    )

    writer = asyncio.streams.StreamWriter(
        writer_transport, writer_protocol, None, loop=loop
    )

    while True:
        await asyncio.create_task(write_msg(writer))
        print("task done... is_closing? ", writer.is_closing())

asyncio.run(main())

Running and output -

% mkfifo fifo_in

% python3 client.py
fifo_in open fd=6
writing ... is_closing?  False
draining ... is_closing?  False
drain done ... is_closing?  False
task done... is_closing?  False
writing ... is_closing?  True
draining ... is_closing?  True
Traceback (most recent call last):
  File "/Volumes/devel/src/py_fifos/client.py", line 36, in <module>
    asyncio.run(main())
    ~~~~~~~~~~~^^^^^^^^
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/asyncio/runners.py", line 204, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/asyncio/runners.py", line 127, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/asyncio/base_events.py", line 719, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "/Volumes/devel/src/py_fifos/client.py", line 32, in main
    await asyncio.create_task(write_msg(writer))
  File "/Volumes/devel/src/py_fifos/client.py", line 9, in write_msg
    await writer.drain()
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/asyncio/streams.py", line 386, in drain
    await self._protocol._drain_helper()
  File "/opt/homebrew/Cellar/python@3.14/3.14.3_1/Frameworks/Python.framework/Versions/3.14/lib/python3.14/asyncio/streams.py", line 166, in _drain_helper
    raise ConnectionResetError('Connection lost')
ConnectionResetError: Connection lost

The other end of the pipe is just cat reading in a different terminal window...

% cat <fifo_in
MSG

It doesn't matter whether client.py or cat is invoked first - the behaviour is the same..

CPython versions tested on:

3.14, 3.13

Operating systems tested on:

macOS, Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions