io.BufferedWriter Infinite-Loops on write() Returning 0 (tested)
A raw IO stream's documented non-blocking signal for "nothing written right now" is returning None -- io.BufferedWriter handles that correctly, raising a clean BlockingIOError. But a raw stream that instead returns 0 for a non-empty buffer -- an easy mistake if you're writing a custom RawIOBase subclass and think of 0 as "wrote nothing, try again" the way 0 means EOF on the read side -- isn't handled at all. BufferedWriter retries the write in a tight loop expecting eventual progress, and since 0 looks like a tiny bit of forward motion rather than a stop signal, it never gives up: the process pins one CPU core at 100% forever, with no exception, no output, and no indication anything is wrong.
1. write() returning None is handled correctly
import io
class NoneRaw(io.RawIOBase):
def writable(self):
return True
def write(self, b):
return None # documented non-blocking "nothing written" signal
raw = NoneRaw()
bw = io.BufferedWriter(raw, buffer_size=4096)
bw.write(b"hello")
try:
bw.flush()
except BlockingIOError as e:
print("None ->", e)
None -> [Errno 0] write could not complete without blocking
Measured: this is the correct, documented behavior -- None is the one case BufferedWriter actually checks for, and it raises a clean, catchable error.
2. write() returning 0 for non-empty data spins forever instead
class ZeroRaw(io.RawIOBase):
def __init__(self):
self.calls = 0
def writable(self):
return True
def write(self, b):
self.calls += 1
return 0 # claims to accept nothing, every single call
raw = ZeroRaw()
bw = io.BufferedWriter(raw, buffer_size=4096)
bw.write(b"hello")
import signal
def handler(sig, frame):
print("calls in 1 second:", raw.calls)
raise SystemExit(0)
signal.signal(signal.SIGALRM, handler)
signal.alarm(1)
bw.flush() # never returns
calls in 1 second: 5170118
Measured: over five million calls to write() in a single second, each one accepted via the exact same tight retry loop -- a genuine CPU-pinning infinite loop, not a slow operation. No exception is ever raised, and nothing is printed; the process would hang until killed from outside.
3. The same hang hits writes large enough to bypass buffering too
raw2 = ZeroRaw()
bw2 = io.BufferedWriter(raw2, buffer_size=4096)
signal.alarm(1)
bw2.write(b"x" * 10000) # larger than buffer_size, goes straight to raw.write()
calls in 1 second: 4984435
Measured: the hang isn't specific to flush() -- any write large enough to exceed the internal buffer calls straight into the same unguarded retry loop, so even a single oversized write() call, with no explicit flush() anywhere in the caller's code, can hang the process.
4. The fix: a custom raw stream must return None, never 0, when it accepts nothing
class FixedRaw(io.RawIOBase):
def writable(self):
return True
def write(self, b):
if not getattr(self, "_ready", True):
return None # signal "try again later", not 0
return len(b) # accept everything once actually ready
raw3 = FixedRaw()
bw3 = io.BufferedWriter(raw3, buffer_size=4096)
bw3.write(b"hello")
bw3.flush()
print("flush completed normally")
flush completed normally
Measured: once the raw stream follows the documented contract -- returning either a positive byte count or None, never 0 for non-empty input -- flush() and large write() calls both complete normally. If you're wrapping something where zero genuinely means "try again," translate that to None before returning it from write().
Frequently asked questions
Is returning 0 from a RawIOBase.write() actually forbidden?
The documentation doesn't explicitly list 0 as invalid for non-empty input, but we measured that io.BufferedWriter only has special-case handling for None -- any other value, including 0, is treated as a (tiny) amount of real progress and retried, with no loop limit or timeout.
How is this different from the None case, which is also a kind of 'nothing written'?
We measured that None triggers a specific check in BufferedWriter's C implementation that immediately raises BlockingIOError. 0 doesn't hit that check at all -- it's treated the same as returning any other small integer, which is assumed to mean the write is making slow but real progress.
Would a real-world raw stream realistically return 0 like this?
It's a plausible bug rather than a deliberate design -- someone implementing a custom RawIOBase around a non-blocking socket or pipe, thinking of 0 the way read() uses 0 to mean EOF, could easily return 0 for 'nothing accepted yet' instead of the actually-documented None, and the failure mode (a silent hang) gives no hint that the mistake is in the write() return value.
Does this affect built-in raw streams like FileIO or sockets?
We only measured a custom RawIOBase subclass -- the built-in raw streams shipped with Python follow the documented contract and don't return 0 for non-empty writes, so this specifically affects custom/third-party RawIOBase implementations, not the stdlib's own.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in io.BufferedWriter wrapping a custom io.RawIOBase subclass, counting raw write() calls via a SIGALRM handler to measure the spin rate without waiting for a hang to resolve on its own (it doesn't). PlainHeaders is our extension; Python's io module is built in.