poplib._getline() Mistakes None for EOF (tested)
A non-blocking raw stream signals "no data ready yet" by returning None from readinto() -- distinct from the empty read that means real end-of-stream. We measured that poplib.POP3._getline() reads the server's response line with self.file.readline(), and io.BufferedReader.readline() itself collapses that transient None into b'' -- indistinguishable from a socket that closed before sending anything. _getline() sees the empty line and raises poplib.error_proto: -ERR EOF, even though a complete, valid +OK reply is sitting one read call away and the connection was never actually closed.
1. A real, complete POP3 reply behind one transient None
import io, poplib
class FlakyRaw(io.RawIOBase):
def __init__(self, payload):
self.calls = 0
self.sent = False
self.payload = payload
def readable(self):
return True
def readinto(self, b):
self.calls += 1
if self.calls == 1:
return None # transient: no data ready yet
if not self.sent:
self.sent = True
n = len(self.payload)
b[:n] = self.payload
return n # the real, complete reply
return 0 # real EOF after that
class FakeSocket:
def __init__(self, payload):
self.raw = FlakyRaw(payload)
self.br = io.BufferedReader(self.raw)
def makefile(self, mode, *a, **kw):
return self.br
def close(self):
pass
payload = b"+OK POP3 ready\r\n"
sock = FakeSocket(payload)
pop = poplib.POP3.__new__(poplib.POP3)
pop.sock = sock
pop.file = sock.br
pop._debugging = 0
try:
line, octets = pop._getline()
print("line:", line)
except Exception as e:
print("CRASHED:", type(e).__name__, e)
print("raw calls made:", sock.raw.calls)
CRASHED: error_proto -ERR EOF
raw calls made: 1
Measured: the reply is valid and complete, one read call away. _getline() made exactly one read call, got back an empty line, and concluded the server closed the connection -- a conclusion that looks like a real network failure but is actually just an unhandled non-blocking retry signal.
2. The fix: drain into a real buffer first, retrying on None
def drain_retrying_on_none(raw_stream, chunk_size=65536):
out = io.BytesIO()
br = io.BufferedReader(raw_stream)
while True:
chunk = br.read(chunk_size)
if chunk is None:
continue # retry: not ready yet, not EOF
if not chunk:
break # real EOF
out.write(chunk)
out.seek(0)
return out
raw2 = FlakyRaw(payload)
safe_buf = drain_retrying_on_none(raw2)
pop2 = poplib.POP3.__new__(poplib.POP3)
pop2.sock = None
pop2.file = safe_buf
pop2._debugging = 0
line2, octets2 = pop2._getline()
print("line:", line2)
line: b'+OK POP3 ready'
Measured: resolving the None/EOF distinction before the bytes reach _getline() returns the real reply correctly, with no spurious disconnection.
Frequently asked questions
Is this documented behavior?
The poplib module's documentation does not mention non-blocking sockets or a None read result; error_proto('-ERR EOF') is meant to signal that the server actually closed the connection. We measured that a None-returning source triggers the identical exception with no actual disconnection involved.
Why does this look like a real network problem?
Because error_proto('-ERR EOF') is also the genuine exception for an actually-closed connection, there is no way to distinguish the two causes from the exception alone. If you see this on a socket you know is still open (e.g. a non-blocking socket you're polling), suspect a transient None read instead of a real disconnection.
Is this the same root cause as the http.client and smtplib findings?
Yes -- we traced it to the identical mechanism: io.BufferedReader.readline() collapses a transient None into b'', and http.client.HTTPResponse.begin(), smtplib.SMTP.getreply(), and poplib.POP3._getline() all treat an empty readline() result as proof the connection closed. This is the third confirmed stdlib network client built the same way around a makefile('rb').readline() call.
Is this related to the other None/EOF findings?
Yes -- same family already confirmed in shutil.copyfileobj, gzip/bz2/lzma, tarfile/zipfile, TextIOWrapper.readline(), ElementTree.parse(), tomllib.load(), email.message_from_binary_file(), plistlib.load(), wave.open(), http.client.HTTPResponse, and smtplib.getreply(): a correctly-returned non-blocking None gets mishandled one layer above io itself.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in poplib.POP3._getline(), reading a genuinely complete, valid POP3 reply through a fake file object -- an io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once before returning the real reply bytes. PlainHeaders is our extension; Python's poplib and io modules are built in.