plistlib.load() Crashes on a Non-Blocking None (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 plistlib.load(), which needs a seekable stream to detect whether the plist is XML or binary format, crashes on the transient None with AttributeError: 'NoneType' object has no attribute 'startswith' -- an internal-looking error that gives no hint the real cause is a retryable non-blocking signal rather than a malformed or unreadable plist.
1. A real, valid plist behind one transient None
import io, plistlib
class SeekableFlakyRaw(io.RawIOBase):
def __init__(self, payload):
self.calls = 0
self.payload = payload
self.pos = 0
def readable(self):
return True
def seekable(self):
return True
def seek(self, offset, whence=0):
if whence == 0: self.pos = offset
elif whence == 1: self.pos += offset
elif whence == 2: self.pos = len(self.payload) + offset
return self.pos
def tell(self):
return self.pos
def readinto(self, b):
self.calls += 1
if self.calls == 1:
return None # transient: no data ready yet
n = min(len(b), len(self.payload) - self.pos)
if n <= 0:
return 0
b[:n] = self.payload[self.pos:self.pos + n]
self.pos += n
return n
payload = plistlib.dumps({"key": "value"})
raw = SeekableFlakyRaw(payload)
try:
data = plistlib.load(io.BufferedReader(raw))
print("data:", data)
except Exception as e:
print("CRASHED:", type(e).__name__, e)
print("raw calls made:", raw.calls)
CRASHED: AttributeError 'NoneType' object has no attribute 'startswith'
raw calls made: 1
Measured: the plist is valid and complete, one read call away. plistlib.load()'s format-detection step peeks at the start of the stream to decide XML vs. binary format, calling .startswith() on whatever it read -- when that read returns None, the .startswith() call itself raises AttributeError, an error that looks like an internal library bug rather than anything related to stream readiness.
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 = SeekableFlakyRaw(payload)
safe_buf = drain_retrying_on_none(raw2)
print("safe data:", plistlib.load(safe_buf))
safe data: {'key': 'value'}
Measured: resolving the None/EOF distinction before the bytes reach plistlib.load() parses the plist correctly.
Frequently asked questions
Is this documented behavior?
The plistlib documentation for load() does not mention non-blocking streams or a None read result. We measured that a None-returning source produces an internal-looking AttributeError instead of a clear diagnosis.
Why startswith specifically?
We measured that plistlib's format auto-detection reads a small chunk from the start of the stream and calls .startswith() on it to check for the XML plist header; a None result has no such method, so the AttributeError fires there, before any actual plist parsing begins.
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(), and email.message_from_binary_file(): a correctly-returned non-blocking None gets mishandled one layer above io itself, each stdlib module failing in its own way.
Written with AI assistance. Measured on 2026-10-09 with Python 3.13.5's built-in plistlib.load(), reading a genuinely valid plist through a seekable io.BufferedReader over a custom non-blocking RawIOBase whose readinto() returns None once before returning the real plist bytes. PlainHeaders is our extension; Python's plistlib and io modules are built in.