Skip to content

Instantly share code, notes, and snippets.

@devdanzin
Created July 15, 2026 18:30
Show Gist options
  • Select an option

  • Save devdanzin/3198710e3c0128fda5e0a7b4e0768e5f to your computer and use it in GitHub Desktop.

Select an option

Save devdanzin/3198710e3c0128fda5e0a7b4e0768e5f to your computer and use it in GitHub Desktop.
CPython Lib/ Bug Catalog — verified, unfiled issues for new contributors

CPython Lib/ Bug Catalog — Verified, Unfiled Issues for New Contributors

About this catalog

In March 2026, an AI-assisted audit swept roughly 94,000 lines of CPython's pure-Python standard library (asyncio, email, xml, http, multiprocessing, concurrent.futures, importlib, logging, typing, argparse, inspect, subprocess, dataclasses, pickle, tarfile, zipfile, shutil, tempfile, pdb, contextlib, configparser) and produced 68 candidate findings — mostly overly-broad except clauses, silently swallowed errors, and a handful of outright crashes. None of it was ever independently re-verified or filed as GitHub issues.

In July 2026, every one of those 68 candidates was re-verified from scratch: re-read against current CPython main (commit b4662e8e3b60, 2026-07-15), reproduced where practical with an actually-executed script, and checked against GitHub's issue/PR history to see whether it was already known. 38 candidates survived as genuine, unfiled bugs — the rest turned out to be already fixed, already tracked, or (most commonly) intentional/documented behavior the original audit misread. That ~44% false-positive rate is itself worth internalizing: audit output is a candidate list, not a findings list, and this catalog only contains what independently cleared both bars.

This document collects those 38 confirmed, unfiled, reproducible issues, organized by module, each with the current file:line, a plain-language description, a working reproducer (actually run — output included), and a suggested fix direction. Three appendices cover what didn't make the cut, for transparency and because the "why not" is itself instructive.

Intended use: a seed list of "good first issue" candidates for people learning to contribute to CPython — small, self-contained, well-scoped bugs in pure-Python code, each with enough detail that a newcomer can go straight from this document to a PR without re-deriving the analysis.

Before you file or fix any of these

  • Re-check the tracker. This catalog is a snapshot as of 2026-07-15. Do one more gh search issues --repo python/cpython "<keywords>" before starting work — someone may have filed or fixed it since.
  • Read the surrounding code yourself. Line numbers will drift as main moves; use them as a starting point, not gospel.
  • Discuss before large changes. Anything touching public behavior (not just an internal except clause) may warrant a quick issue discussion before a PR, per the devguide.
  • Some of these are one-line fixes; some are "figure out the right design" fixes. They're flagged as such below — pick according to the contributor's experience level.

Issues by module

typing.py

1. _tp_cache swallows TypeError raised by the wrapped function itself

  • Location: Lib/typing.py:413-418 (function _tp_cache)
  • Problem: _tp_cache wraps a function with functools.lru_cache and falls back to an uncached call when arguments are unhashable — detected via except TypeError. But that same except also catches a TypeError the wrapped function legitimately raises for unrelated reasons, silently calling the function a second time (with side effects) before finally letting the error propagate.
  • Reproducer (verified):
    import typing
    calls = []
    @typing._tp_cache
    def f(x):
        calls.append(x)
        if x == "bad":
            raise TypeError("real bug inside f, unrelated to hashability")
        return x * 2
    f("bad")
    print(calls)  # ['bad', 'bad'] — f() ran twice
  • Suggested fix: Narrow the fallback trigger to unhashable-argument errors specifically (e.g. inspect e.args for "unhashable type", or pre-check hashability before the cached call) so a genuine TypeError from the wrapped function isn't treated as a cache-miss signal.
  • Difficulty: Small, self-contained.

2. _get_protocol_attrs — broad except Exception around __annotations__ access

  • Location: Lib/typing.py:1875 (function _get_protocol_attrs)
  • Problem: Collects a Protocol class's declared members by reading base.__annotations__ for each class in the MRO, guarded by except Exception. Meant to fall back gracefully when a class lacks the attribute, but also silently swallows genuine bugs (e.g. a broken metaclass raising something other than AttributeError), producing an incomplete/wrong attribute set with no error.
  • Reproducer (verified):
    import typing
    class BrokenMeta(type):
        def __getattribute__(cls, name):
            if name == '__annotations__':
                raise RuntimeError("simulated broken metaclass")
            return super().__getattribute__(name)
    class Base(metaclass=BrokenMeta): pass
    print(typing._get_protocol_attrs(Base))  # set() — RuntimeError silently swallowed
  • Suggested fix: Narrow to except AttributeError.
  • Difficulty: Small, self-contained.

3. _BaseGenericAlias.__call__ — broad except Exception for __orig_class__ assignment

  • Location: Lib/typing.py:1265-1272
  • Problem: After instantiating via a generic alias (e.g. List[int]()), tries to stamp result.__orig_class__ = self for introspection, wrapped in except Exception: pass to tolerate __slots__-style rejection. Also hides genuine bugs in a custom __setattr__.
  • Reproducer (verified):
    import typing
    class Broken:
        def __setattr__(self, name, value):
            if name == '__orig_class__':
                raise RuntimeError("simulated bug, unrelated to 'cannot set attribute'")
            object.__setattr__(self, name, value)
    alias = typing._GenericAlias(Broken, (int,))
    result = alias()  # succeeds silently despite the RuntimeError
  • Suggested fix: Narrow to except (AttributeError, TypeError) (the documented cases per the adjacent comment).
  • Difficulty: Small, self-contained.

4. _proto_hook — the same broad except Exception pattern, a third site

  • Location: Lib/typing.py:2122-2126 (function _proto_hook, installed as __subclasshook__ for runtime-checkable protocols)
  • Problem: Structurally identical to #2 but in the code path used by issubclass()/isinstance() checks against protocols. A broken metaclass/descriptor here gets silently absorbed during a subclass check instead of propagating.
  • Reproducer (verified): calling MyProto.__subclasshook__(OtherProto) where OtherProto's metaclass raises RuntimeError on __annotations__ access returns NotImplemented with no error, instead of surfacing the RuntimeError.
  • Suggested fix: Narrow to except AttributeError, same as #2.
  • Difficulty: Small, self-contained. Note: fixing #2, #3, and #4 together (they're the same anti-pattern in three places) makes a reasonable single PR.

5. Literal[...] deduplication silently skips when values are unhashable

  • Location: Lib/typing.py:754, 778-781 (function Literal)
  • Problem: Literal[...] deduplicates its arguments via a helper that needs hashability; wrapped in except TypeError: pass. If any argument is unhashable (e.g. a dict), dedup is abandoned for the entire Literal, so duplicate unhashable values survive while duplicate hashable values elsewhere are always removed — inconsistent behavior depending purely on hashability.
  • Reproducer (verified):
    import typing
    print(typing.Literal[1, 1, 2].__args__)   # (1, 2) — deduplicated
    d = {"a": 1}
    print(typing.Literal[d, d].__args__)      # ({'a': 1}, {'a': 1}) — NOT deduplicated
  • Suggested fix: Call the existing _deduplicate(..., unhashable_fallback=True) (already used elsewhere, e.g. for Annotated) instead of the bare call.
  • Difficulty: Small, self-contained — the fix is already implemented elsewhere in the same file.

argparse.py

6. _print_message swallows write failures

  • Location: Lib/argparse.py:2887-2893
  • Problem: except (AttributeError, OSError): pass around the write call used by print_help()/print_usage()/error(). Passing a non-file-like object (or a broken stream) as file= causes the expected output to vanish with zero error, making the bug very hard to notice.
  • Reproducer (verified):
    import argparse
    p = argparse.ArgumentParser()
    result = p.print_help(file="not_a_file")
    print("print_help returned:", result)  # None — no help text, no error
  • Suggested fix: Remove AttributeError from the catch (wrong type entirely shouldn't be silently accepted); narrow OSError handling to closed-stream cases specifically if that was the original intent.
  • Difficulty: Small, self-contained.

inspect.py

7. _wait_kqueue — see subprocess.py entry below (cross-listed)

(No standalone valid finding for inspect.py in this catalog — two candidates from the original audit were disputed as intentional/documented; see Appendix C.)

subprocess.py

8. _internal_poll silently swallows unexpected OSError

  • Location: Lib/subprocess.py:2165-2194 (POSIX Popen._internal_poll)
  • Problem: Polls child status via os.waitpid; special-cases ECHILD and the __del__-triggered case, but any other unexpected OSError is caught and discarded — self.returncode stays None forever, silently masking a real failure instead of surfacing it.
  • Reproducer (verified):
    import subprocess, errno
    from unittest import mock
    p = subprocess.Popen(["sleep", "0.2"])
    with mock.patch("os.waitpid", lambda pid, flags: (_ for _ in ()).throw(OSError(errno.EINVAL, "simulated"))):
        print(p.poll())  # None — unexpected OSError silently absorbed
  • Suggested fix: Re-raise (or at least log) when e.errno != ECHILD and _deadstate is None.
  • Difficulty: Small, self-contained.

9. CalledProcessError.__str__ crashes when returncode is None

  • Location: Lib/subprocess.py:145-155
  • Problem: if self.returncode and self.returncode < 0: treats None as falsy, routing to a %d-formatting branch that can't format None — so str(err) itself raises TypeError, a particularly bad failure mode for an exception's own string representation.
  • Reproducer (verified):
    import subprocess
    err = subprocess.CalledProcessError(None, "cmd")
    str(err)  # TypeError: %d format: a real number is required, not NoneType
  • Suggested fix: Handle returncode is None explicitly before the %d branch.
  • Difficulty: Small, self-contained. Note: a recent, unrelated PR (#151486 / gh-151485, merged 2026-06-24) touched this same __str__ method for a different bug (command quoting) — worth reading for context, but it doesn't fix this.

10. _wait_kqueue — broad except OSError masks unexpected errors (macOS/BSD only)

  • Location: Lib/subprocess.py:2238-2263
  • Problem: Uses kqueue() as a fast-path child-exit detector; if kq.control() raises any OSError, the method just returns False (falls back to normal polling — no state corruption, but a genuinely unexpected error like EBADF is treated identically to "no event yet," with no visibility for debugging).
  • Reproducer: verified against a local dev build with kqueue mocked (kqueue is unavailable on Linux) — an injected EBADF was silently swallowed and treated as a normal "no event" case.
  • Suggested fix: At minimum, add a comment explaining why any OSError is considered safe here (mirroring an existing comment a few lines above for a related catch).
  • Difficulty: Small, low-severity polish.

Archive / filesystem modules (tarfile.py, shutil.py, pickle.py)

11. tarfile: PAX header numeric fields silently coerced to zero

  • Location: Lib/tarfile.py:1680-1685 (TarInfo._apply_pax_info)
  • Problem: An invalid (non-numeric) size/uid/gid/mtime value in a PAX extended header is silently replaced with 0 on ValueError. size=0 truncates the extracted file to nothing; uid=0 means root ownership. Security-relevant for archives from untrusted sources.
  • Reproducer (verified): built a tar with a real 100-byte member but a PAX header size of "not-a-number"; on extraction, member.size == 0 and the extracted content is empty — the 100 bytes are silently discarded.
  • Suggested fix: Raise InvalidHeaderError (consistent with other malformed-PAX-header handling elsewhere in the same file) instead of defaulting to 0.
  • Difficulty: Small, self-contained, but touches security-sensitive code — read carefully before submitting.

12. shutil.move: partial copy left at destination if unlink(src) fails after a successful cross-device copy

  • Location: Lib/shutil.py:930-951
  • Problem: Cross-filesystem move() copies then deletes the source. If the copy succeeds but os.unlink(src) fails (e.g. permission denied), the function raises but leaves the file at both locations, with no cleanup of the destination copy attempted.
  • Reproducer (verified): monkeypatched os.rename to raise (simulating EXDEV) and os.unlink to raise PermissionError only for the source path; after the "failed" move(), both src and dst exist with the file's content.
  • Suggested fix: Wrap os.unlink(src) in its own try/except and, on failure, remove the just-created destination copy before re-raising (or clearly document the caveat).
  • Difficulty: Small-medium — needs care around partial-failure semantics.

13. shutil.move: overly broad except OSError triggers the copy-then-delete fallback for non-EXDEV errors

  • Location: Lib/shutil.py:930-931
  • Problem: The fallback is meant to trigger only for cross-device moves (errno.EXDEV), but catches bare OSError, which also matches same-filesystem permission errors and other unrelated failures — masking what should be a direct, clear error.
  • Reproducer (verified): mocked os.rename to raise PermissionError(errno.EACCES, ...) (explicitly not EXDEV); the copy-then-delete fallback still ran.
  • Suggested fix: Check err.errno == errno.EXDEV before falling back; re-raise directly for other OSError subtypes.
  • Difficulty: Small, self-contained. Pairs naturally with #12 (same function, same PR is reasonable).

14. shutil.copytree: Windows metadata copy failures silently dropped, not just tolerated

  • Location: Lib/shutil.py:609-614
  • Problem: After copying directory contents, copystat(src, dst) failures are dropped entirely (not even appended to the errors list) if the exception has a winerror attribute — a guard broad enough to swallow any Windows API error under copystat, not just the one specific "access time on directory" limitation the adjacent comment describes.
  • Reproducer: Windows-specific; not reproducible on Linux — verified via source reading only.
  • Suggested fix: Narrow the guard to the specific Windows error code(s) actually expected, rather than any winerror-bearing exception.
  • Difficulty: Small, but needs a Windows environment to verify — good for a contributor with Windows access.

15. pickle._instantiate: broken exception chaining passes a traceback object as a constructor argument

  • Location: Lib/pickle.py:1623-1630 (_Unpickler._instantiate)
  • Problem: When klass(*args) raises TypeError during old-protocol unpickling, the code does raise TypeError(msg, err.__traceback__) — passing the traceback object as a second positional constructor argument, not attaching it via .with_traceback() or raise ... from err. The result: e.args literally contains a traceback object, polluting str(exception), with no real exception chaining.
  • Reproducer (verified):
    # via pickle._Unpickler()._instantiate(Bad, (1,2,3)) where Bad.__init__ raises TypeError
    # e.args == ('in constructor for Bad: original failure', <traceback object at 0x...>)
    # e.__cause__ is None — no proper chaining occurred
  • Suggested fix: raise TypeError("in constructor for %s: %s" % (klass.__name__, str(err))) from err.
  • Difficulty: Small, self-contained.

http (cookiejar.py, client.py)

16. http.cookiejar: a single malformed cookie drops the entire batch

  • Location: Lib/http/cookiejar.py:1620-1634 (CookieJar.make_cookies)
  • Problem: All Set-Cookie/Set-Cookie2 values from one response are parsed together; if any single cookie trips an exception not already handled by the (fairly extensive) per-attribute parsing, the surrounding except Exception discards the whole batch — including cookies that parsed fine — and returns []. A UserWarning is emitted (so it's not fully invisible), but a legitimate session/auth cookie can vanish just because it was batched alongside one malformed one.
  • Reproducer (verified): a response with two Set-Cookie headers — a valid session_token=SECRET123; Path=/ and a malformed bogus=1; Max-Age=<400-digit number> (triggers OverflowError deep inside cookie construction) — resulted in jar.make_cookies(...) returning [], dropping the valid session cookie too.
  • Suggested fix: Catch exceptions per-cookie inside the parsing loop (skip just the bad one) instead of wrapping the whole batch.
  • Difficulty: Medium — touches cookie-parsing control flow, worth care with existing tests.

17. http.client: malformed Content-Length header silently accepted

  • Location: Lib/http/client.py:396-399 (HTTPResponse.begin)
  • Problem: except ValueError: self.length = None around int(length) — a non-numeric Content-Length is silently treated as "unknown length" (falls back to read-until-close framing) with no warning, even though RFC 7230 §3.3.3 calls a malformed Content-Length an unrecoverable framing error.
  • Reproducer (verified): local HTTP server sending Content-Length: not-a-number with Connection: close; client silently proceeds with self.length = None, no warning or exception.
  • Suggested fix: Emit a warnings.warn() when this happens, or document the leniency explicitly.
  • Difficulty: Small, self-contained.

multiprocessing

18. resource_tracker: warning about leaked resources can itself be silently swallowed

  • Location: Lib/multiprocessing/resource_tracker.py:480-481
  • Problem: When leaked semaphores/shared-memory are detected at shutdown, the descriptive warnings.warn(...) call is itself wrapped in except Exception: pass. Under -W error/PYTHONWARNINGS=error (which converts the warning into a raised exception), the message about what leaked is completely swallowed — only the numeric exit_code=1 survives.
  • Reproducer (verified): under python3 -W error, the warning-to-exception conversion is silently caught, leaving only exit_code: 1 with no printed message.
  • Suggested fix: Don't blanket-catch around the warnings.warn() call itself; if protection is needed, fall back to sys.stderr.write on failure.
  • Difficulty: Small, self-contained.

19. managers._decref logs failures at debug level, invisible by default

  • Location: Lib/multiprocessing/managers.py:885-890
  • Problem: When a proxy's finalizer fails to tell the manager to decrement a referent's refcount (e.g. connection error), the failure is only logged via util.debug(...), which is a complete no-op unless multiprocessing's internal logger has been explicitly enabled. By default: nothing is printed, and the manager-side object leaks invisibly.
  • Suggested fix: Escalate to util.info (still gated but a step up), or add an optional failure callback.
  • Difficulty: Small, self-contained.

20. multiprocessing.util._run_after_forkers: exceptions are completely invisible by default, not just low-priority

  • Location: Lib/multiprocessing/util.py:237-240
  • Problem: After-fork callback failures (which can mean child-process state — locks, RNG state — was never properly reinitialized) go through util.info(...), which — like util.debug above — is a no-op unless the internal logger is enabled. By default, a corrupted-child-state scenario produces zero output anywhere, and the child continues running as if nothing happened.
  • Reproducer (verified): registered an after-fork hook raising RuntimeError; with default settings, no output at all and the child exits 0; with multiprocessing.util.log_to_stderr(logging.INFO) enabled, the message appears — confirming both the mechanism and its default invisibility.
  • Suggested fix: Use sys.stderr.write/warnings.warn as a fallback when the internal logger is unset.
  • Difficulty: Small-medium — changes default-visible behavior, worth discussing scope first.

21. forkserver: child exit code hardcoded to 1 for every exception type

  • Location: Lib/multiprocessing/forkserver.py:409-425
  • Problem: The forkserver's per-request child process sets code = 1 before running the request handler; on any exception, the traceback is printed but code is never adjusted, so os._exit(code) always reports exit status 1 regardless of what actually failed — all failure types become indistinguishable via exit code alone.
  • Suggested fix: Set distinct exit codes for different exception categories, or otherwise encode something about the failure type (as resource_tracker.py already does with codes 1/2/3).
  • Difficulty: Small, low severity — the real traceback is still printed to stderr, only the exit-code granularity is lost.

asyncio

(A note on scope: this module got the deepest cross-referencing against GitHub history of any bucket — several of the following patterns have very recent sibling fixes at closely related call sites, which is strong evidence maintainers consider this bug class real and worth fixing. Those precedents are cited inline; read the linked PRs before starting work, they're the best model for the expected fix shape.)

22. _accept_connection2 silently swallows all accept-time errors unless debug=True

  • Location: Lib/asyncio/selector_events.py:220-266
  • Problem: If protocol_factory() or transport creation raises while accepting an incoming connection, the exception is only reported via call_exception_handler when self._debug is true. In production (the default), the connection is dropped with zero notification anywhere.
  • Reproducer (verified): a server with a protocol_factory that raises RuntimeError, a real client connecting: with debug=False, exception_handler calls = 0; with debug=True, 1 call with a clear message. (No fd leak either way — the accepted socket is refcounted away via __del__.)
  • Suggested fix: Call self.call_exception_handler(context) unconditionally, dropping the if self._debug: guard.
  • Precedent: gh-153133 / PR #153134 (merged 2026-07-08, one week before this catalog's verification date) fixed the closely related client-side connect path (asyncio.create_connection() leaking a socket if transport creation fails) with the same "wrap in try/except, clean up, re-raise/report" shape. The accept-side sibling described here has not been filed.
  • Difficulty: Small, self-contained, well-precedented.

23. shutdown_asyncgens checks Exception, not BaseException — drops custom BaseException subclasses from gather()

  • Location: Lib/asyncio/base_events.py:576-593
  • Problem: Uses tasks.gather(..., return_exceptions=True), then only reports a result via call_exception_handler if isinstance(result, Exception). But asyncio.Task genuinely can and does return raw BaseException instances from gather(return_exceptions=True) (e.g. a custom BaseException subclass raised from an async generator's finally, like trio.Cancelled-style signals). Those are silently discarded — no logging at all, unconditionally (not even debug-gated).
  • Reproducer (verified): an async generator whose finally raises a custom class MyFatalSignal(BaseException); after await loop.shutdown_asyncgens(), exception_handler calls = 0.
  • Suggested fix: Change isinstance(result, Exception) to isinstance(result, BaseException).
  • Difficulty: Small, self-contained, one-character-class fix — probably the cleanest/safest pick in this whole catalog.

24. _do_shutdown silently drops executor-shutdown failures if the loop is already closed

  • Location: Lib/asyncio/base_events.py:625-633
  • Problem: Runs in a background thread; if self._default_executor.shutdown(wait=True) raises and the loop happens to already be closed at that moment, the exception is discarded entirely — no logging, and the associated future is left permanently unresolved (not even cancelled).
  • Reproducer (verified): replaced loop._default_executor with a fake object whose shutdown() raises, and loop.is_closed to return True; after loop._do_shutdown(future), exception_handler calls = 0, future has no exception set and isn't done.
  • Suggested fix: Call logger.error(...) directly (bypassing the loop, which may be closed) when the exception can't be delivered via the future.
  • Difficulty: Small — narrow race condition, but reproducible and clearly unhandled.

25. _call_connection_lost's finally block can shadow the original connection_lost() exception (6 call sites, 4 files)

  • Locations:
    • Lib/asyncio/selector_events.py:909-919 (_SelectorTransport)
    • Lib/asyncio/selector_events.py:1209-1220 (_SelectorDatagramTransport)
    • Lib/asyncio/unix_events.py:620-625 (_UnixReadPipeTransport)
    • Lib/asyncio/unix_events.py:825-830 (_UnixWritePipeTransport)
    • Lib/asyncio/proactor_events.py:155-172 (_ProactorBasePipeTransport — the riskiest of the six, since it calls sock.shutdown(), not just close())
    • Lib/asyncio/base_subprocess.py:275-284 (BaseSubprocessTransport)
  • Problem: Each site does try: protocol.connection_lost(exc) finally: <cleanup>. If connection_lost() raises and cleanup also raises, Python's own semantics mean the cleanup exception is what propagates, with the original attached only as __context__ — demoted, not lost outright, but easy to miss in a busy log.
  • Reproducer (verified): with connection_lost() raising ValueError("ORIGINAL") and cleanup (sock.close()) also raising, the exception that reaches call_exception_handler is the cleanup exception; e.__context__ correctly holds the original — confirmed by direct inspection.
  • Important context: this is not fully silent even today — _call_connection_lost runs via loop.call_soon(...), and events.Handle._run reports any propagating exception via call_exception_handler unconditionally, regardless of debug. The bug is specifically that a cleanup-time failure can replace the original in that report, not that nothing is reported at all.
  • Suggested fix: Wrap cleanup steps so a failure there doesn't discard/replace an in-flight exception — e.g. try/except around cleanup with logger.exception(...) on failure, then explicit re-raise of the original.
  • Precedent: gh-145541 / PR #145554 (merged 2026-03-09/10, backported to 3.13/3.14/3.15) fixed a narrower symptom at the base_subprocess.py site specifically (a double-set_result InvalidStateError) — confirms this exact method is an actively maintained hotspot, but that fix didn't address the general shadowing hazard. gh-149388 / PR #149518 (merged 2026-05-09, backported) fixed the Windows-only half of the proactor_events.py site (made PipeHandle.close() idempotent) — the PR author's own words confirm "the first, legitimate close still propagates the OSError," i.e. the general hazard (including the POSIX sock.shutdown() call this catalog entry describes) remains open.
  • Difficulty: Medium — six sites, one shared pattern; a good "understand the pattern, fix it everywhere" issue for a contributor ready to touch multiple files in one PR (or split into six small ones).

26. StreamReaderProtocol.connection_made: a synchronous callback that raises leaves the transport open forever

  • Location: Lib/asyncio/streams.py:180-242
  • Problem: If client_connected_cb passed to start_server() is a plain (non-coroutine) function and it raises synchronously, the exception is reported via call_exception_handler (through Handle._run, unconditionally) — but unlike the coroutine-callback branch a few lines below, the transport is never closed. The server-side connection is left open/hanging indefinitely.
  • Reproducer (verified): asyncio.start_server(sync_cb, ...) where sync_cb raises RuntimeError, debug=False: exception_handler fires once with a clear message, but transport.is_closing() is False afterward — the connection stays open.
  • Suggested fix: Wrap the synchronous call the same way the coroutine branch already is: on failure, call call_exception_handler (already happening via the generic handler) and transport.close().
  • Precedent: gh-110894 / PR #111601 (merged Nov 2023) fixed exactly this for the coroutine branch, explicitly scoped as if coroutines.iscoroutine(res): — the plain-function else branch was left with no equivalent cleanup. This entry is the direct, well-precedented completion of that fix.
  • Difficulty: Small — extend an existing, already-merged fix pattern to one more branch of the same function.

importlib / logging

27. importlib._bootstrap._set_lazy_attributes: downgrades any error, including SystemError/MemoryError, to a filtered-by-default ImportWarning

  • Location: Lib/importlib/_bootstrap.py:1311-1316
  • Problem: Part of the new PEP 810 explicit-lazy-imports machinery (added 2026, via gh-142349). After loading any module, _imp._set_lazy_attributes(module, name) is called inside try/except Exception, converting any failure into an ImportWarning. Since ImportWarning is filtered out by Python's default warning configuration, this makes even a genuinely serious internal error (a bug in the brand-new lazy-attributes code, MemoryError, etc.) invisible on every single import, by default.
  • Reproducer (verified): on a dev build with PEP 810, monkeypatched _imp._set_lazy_attributes to raise SystemError, with the standard default warning filters installed: importing a module produced zero output. With warnings.simplefilter("always"), the same error surfaced as an ImportWarning — confirming it's real but normally invisible.
  • Suggested fix: Only downgrade genuinely expected/benign failures to ImportWarning; let serious exceptions (MemoryError, SystemError) propagate or at minimum log visibly.
  • Difficulty: Small-medium — new code (2026), no prior discussion found, good candidate to raise with whoever's actively working on PEP 810.

28. SysLogHandler leaves a closed socket in a truthy state after a failed connection, defeating the reconnect check

  • Location: Lib/logging/handlers.py:906-928 (_connect_unixsocket)
  • Problem: On connection failure, self.socket.close() is called but self.socket is never reset to None. emit()'s reconnect check is if not self.socket: self.createSocket() — a closed-but-non-None socket object is still truthy, so the check wrongly concludes the socket is fine and skips reconnecting.
  • Reproducer (verified): SysLogHandler(address="/tmp/nonexistent_syslog_socket_xyz") — after the failed connect, self.socket is a closed socket object (fileno() == -1, still truthy), and a subsequent .send() raises OSError: Bad file descriptor.
  • Suggested fix: Set self.socket = None in both except OSError: blocks in _connect_unixsocket().
  • Difficulty: Small, self-contained.

29. logging.config.fileConfig's eval() calls give no context about which config section failed

  • Location: Lib/logging/config.py:133, 155, 159, 161
  • Problem: INI-based handler/formatter setup eval()s the raw args=/kwargs=/defaults= strings from the config file with no surrounding try/except in fileConfig() itself (unlike DictConfigurator, whose methods wrap failures with a clear ValueError('Unable to configure handler %r' % name) from e). A malformed value surfaces as a bare SyntaxError pointing at <string>, with no mention of which handler/formatter section is the culprit.
  • Reproducer (verified): an INI config with an unclosed paren in a handler's args=; fileConfig() raises SyntaxError: '(' was never closed through eval(), with no indication which section caused it.
  • Suggested fix: Wrap the eval() calls with a try/except that re-raises including the section/option name, mirroring the pattern DictConfigurator already uses.
  • Precedent: gh-103606 (filed and fixed by logging maintainer vsajip himself) improved a different unhelpful-fileConfig-error scenario (missing file), confirming this general category of improvement is welcomed upstream.
  • Difficulty: Small-medium.

30. FileFinder._find_children can loop forever on a persistently failing directory scan

  • Location: Lib/importlib/_bootstrap_external.py:1469 (inside a while True: loop, backing the new PathEntryFinder.discover() API, added 2026 via gh-139899)
  • Problem: Scans a directory via os.scandir(); any OSError from next() or from an entry's is_dir()/is_file() is caught and ignored without breaking the loop. For a transient single-entry failure this is reasonable (skip and continue). But if the underlying condition is persistent — e.g. an NFS "stale file handle," a disconnected removable volume — every subsequent next() raises the same error forever, and the loop never terminates: no break, no retry limit.
  • Reproducer (verified): patched os.scandir to return an iterator whose __next__ unconditionally raises OSError forever; finder._find_children() under a 10-second timeout was killed (exit 124) — confirmed genuine hang. A companion test with a single transient injected error correctly skipped it and returned all real entries, confirming the "skip one bad entry" case works — it's specifically the persistent-failure case that hangs.
  • Suggested fix: Add a break (or a bounded retry counter) in the except OSError: branch.
  • Difficulty: Small, but a real hang bug (not just silent data loss) — worth flagging prominently. New code (2026), no existing discussion found.

31. HTTPHandler.emit() never checks the HTTP response status — a rejected log is indistinguishable from a delivered one

  • Location: Lib/logging/handlers.py:1357 (inside emit(), Lib/logging/handlers.py:1318)
  • Problem: Sends the log record over HTTP and calls h.getresponse() (necessary to keep the connection state clean) but never inspects response.status. A 4xx/5xx rejection from the receiving server looks identical to success; handleError() is never invoked.
  • Reproducer (verified): a local http.server.HTTPServer that always returns HTTP 500, pointed to by an HTTPHandler; logging a record produced empty stderr despite the explicit rejection.
  • Suggested fix: Check response.status and route non-2xx responses through self.handleError(record).
  • Difficulty: Small, self-contained.

32. SMTPHandler.emit() leaks the SMTP connection on any failure after connect

  • Location: Lib/logging/handlers.py:1118-1147
  • Problem: Opens an SMTP connection; smtp.quit() is only called as the last statement in the try block, after ehlo()/starttls()/login()/send_message(). If any of those steps raises (e.g. SMTPAuthenticationError), execution jumps to except Exception: self.handleError(record) and smtp.quit()/smtp.close() is never called — the connection leaks.
  • Reproducer (verified): mocked smtplib.SMTP to raise SMTPRecipientsRefused from send_message(); confirmed quit()/close() were never called (the connection was left open, even though the failure itself was visibly reported via handleError()).
  • Suggested fix: try/finally: smtp.quit() (or .close() on failure) around the post-connect logic.
  • Difficulty: Small, self-contained.

email / xml

33. xml.sax.expatreader: bare except: during external-entity resolution discards the real error — has its own FIXME comment

  • Location: Lib/xml/sax/expatreader.py:426-428 (ExpatParser.external_entity_ref)
  • Problem: Any exception during external-entity processing — including one raised inside a user's own SAX content-handler callback — is caught by a bare except: return 0. Expat then reports a generic SAXParseException: "error in processing external entity reference" to the caller; the original exception's type, message, and traceback are gone. The code has a literal comment acknowledging this: # FIXME: save error info here?
  • Reproducer (verified): a content handler that raises KeyboardInterrupt while processing an element sourced from an external entity results in a generic SAXParseException, not the original KeyboardInterrupt.
  • Suggested fix: Catch a narrower exception set (or at minimum re-raise BaseException subclasses), and/or preserve sys.exc_info() / use raise ... from err so the real cause is surfaced.
  • Difficulty: Small-medium — the fix itself is small, but improving error propagation through the Expat C-extension boundary needs care.

34. email._header_value_parser: one bare except: pass, an outlier against 31 other call sites in the same file

  • Location: Lib/email/_header_value_parser.py:2538-2541 (inside get_parameter, guarding get_extended_attrtext)
  • Problem: Every other call site in this file (31 of them) catches the specific errors.HeaderParseError; this one bare except: pass catches everything. Currently benign (the guarded call only raises HeaderParseError in practice) but silently swallows anything a future refactor might raise here instead.
  • Reproducer (verified): Parser(policy=policy.default).parsestr('Content-Type: application/x-stuff; title*0*="us-ascii\'en\'This%20is%20"\n\n') exercises this exact branch and produces the expected InvalidHeaderDefect.
  • Suggested fix: except errors.HeaderParseError: to match the other 31 sites.
  • Difficulty: Trivial — smallest fix in this whole catalog.

34b. Bonus, discovered during verification of #34's neighbor: headerregistry.UnstructuredHeader.parse never propagates defects recorded on the underlying parse tree

  • Location: Lib/email/headerregistry.py — UnstructuredHeader.parse (compare AddressHeader.parse, MIMEVersionHeader.parse, ContentTransferEncodingHeader.parse, which all copy parse_tree.all_defects into kwds['defects'])
  • Problem: Not part of the original audit — found while verifying the base64-decode-defect finding below (#35). Parsing a header like Subject: =?utf-8?b?AAAAA?= (base64 payload with an unrecoverable length error) records an InvalidBase64LengthDefect on the inner EncodedWord parse-tree node — but msg['Subject'].defects comes back () (empty), even though tok.all_defects on the parse tree shows the defect really was recorded. UnstructuredHeader.parse simply never copies it up, unlike its sibling *Header.parse implementations.
  • Suggested fix: Have UnstructuredHeader.parse copy parse_tree.all_defects into kwds['defects'], matching the other header-type parsers.
  • Difficulty: Small-medium — needs to check whether Unstructured headers were deliberately excluded from defect-copying for a reason, or whether this is a plain oversight.

35. email._encoded_words.decode_b: caller decodes still-base64-encoded bytes as if they were real content (only the "no timeout" half is a bug — see below)

  • Location: Lib/email/_encoded_words.py:130-136
  • (Verification note: this was flagged DISPUTED by the review — the behavior is a deliberate, discussed design decision from bpo-27397/#71584/PR #7583 (2018), where a maintainer explicitly chose "return the undecoded payload" over raising, per the email module's stated philosophy of never raising on parse and always recording a defect instead. Not included as an actionable item — listed here only because it led directly to the real, distinct bug #34b above. See Appendix C for the full reasoning.)

36. xml.sax.saxutils.prepare_input_source: opened file/URL handle has no guaranteed close

  • Location: Lib/xml/sax/saxutils.py:338-368
  • Problem: A public utility function that opens a local file or URL and stores the handle on the returned InputSource, with nothing about the return value (no context manager, no owning close) guaranteeing the handle gets closed if the caller errors out before consuming it.
  • Reproducer (verified): obtained an InputSource from a real temp file via prepare_input_source, then deld it and forced gc.collect() — the file was still open (ResourceWarning: unclosed file), only cleaned up via GC finalization, not deterministically.
  • Context: expatreader.py's own top-level parse() was separately hardened against this (bpo-30264, "ExpatParser closes the source on error") — but that fix lives in the caller, not in prepare_input_source itself, so any other caller of this public function remains exposed.
  • Suggested fix: Document that callers must close the returned stream, and/or apply the same guard expatreader.parse() uses to other stdlib callers (e.g. xml.dom.xmlbuilder.DOMBuilder.parse).
  • Difficulty: Medium — public API, resource-lifecycle change needs care.

37. xml.dom.pulldom.parse(): no way to close the underlying file if iteration is abandoned early

  • Location: Lib/xml/dom/pulldom.py:318-326 (parse()), 204-278 (DOMEventStream class)
  • Problem: The file is only closed automatically when the caller reads all the way to EOF via getEvent(). A very common pulldom usage pattern — call expandNode() on the first interesting node, then discard the stream — never triggers that close. DOMEventStream has no close(), no __del__, and no __enter__/__exit__.
  • Reproducer (verified): events = pulldom.parse(path); next(events) (one event, stream not exhausted), then del events; gc.collect() — file remained open with a ResourceWarning; confirmed no close/__del__/__exit__ exist on the class at all.
  • Suggested fix: Add close() (closing self.stream/self.parser) and context-manager support to DOMEventStream, matching the pattern ElementTree.iterparse() already has (close() added in gh-69893, a ResourceWarning added in gh-140601).
  • Difficulty: Small-medium, well-precedented by the iterparse() fix.

38. xml.dom.xmlbuilder: entity/URL fetches have no timeout

  • Location: Lib/xml/dom/xmlbuilder.py:212-245 (DOMEntityResolver.resolveEntity/_get_opener), 190-194 (DOMBuilder.parse)
  • Problem: Both places fetch a URL (from potentially attacker-supplied XML content) via urlopen-family calls with no timeout= anywhere in the file. A slow or hanging remote server hangs the parse indefinitely.
  • Suggested fix: Add a timeout= parameter (with a sane default) to the urlopen/opener calls.
  • Note: the broader "should external entities auto-fetch URLs at all" question is a longstanding, documented, module-wide XML tradeoff (see CPython's xml-security docs section, which recommends defusedxml for untrusted input) — not something this entry is claiming is novel. Only the missing timeout specifically is unaddressed anywhere.
  • Difficulty: Small, self-contained — scope it to just the timeout, not a redesign of entity resolution.

39. xml.etree.ElementTree.XMLPullParser.feed(): parse errors are queued silently and easy to lose entirely

  • Location: Lib/xml/etree/ElementTree.py:1316-1324
  • Problem: feed() catches SyntaxError from the underlying parser and appends it to an internal events queue instead of raising; feed() itself always returns None. If the caller only calls feed() in a loop (a legitimate, common streaming pattern) and never calls read_events() — or stops consuming before reaching the queued error — the parse failure is invisible with no other observable effect.
  • Reproducer (verified): parser.feed(b"<root>&bogus_undefined_entity;</root>") returns normally with no exception; the ParseError sits in the internal queue, invisible unless later drained. Also observed: a subsequent close() can raise a different ParseError (different line/column) than the one actually queued first — the original error's info can be lost entirely even by callers who do eventually call close().
  • Suggested fix: Document the queuing behavior prominently (currently undocumented for feed()), and/or expose a cheap "has an error been queued" check that doesn't require draining read_events().
  • Difficulty: Medium — behavioral/API question in a widely-used module, worth discussing scope before implementing.

40. xml.dom.domreg.getDOMImplementation: fallback discovery masks genuine bugs, not just "not installed"

  • Location: Lib/xml/dom/domreg.py:73-76
  • Problem: When called with no explicit implementation name, tries each well-known implementation (minidom, 4DOM) in turn under except Exception: continue (comment: "typically ImportError, or AttributeError"). A genuine bug in a candidate's getDOMImplementation() factory (e.g. a real AttributeError from a typo) is caught identically to "this implementation isn't available" and silently skipped.
  • Reproducer (verified): registered a fake well-known implementation whose getDOMImplementation() deliberately raises AttributeError to simulate a real bug (not a missing-module ImportError); the fallback loop silently moved on to minidom with no trace of the failure.
  • Suggested fix: Catch (ImportError, AttributeError) specifically, matching what the comment already says is "typical."
  • Difficulty: Trivial. Note: low real-world impact since well_known_implementations is a tiny, fixed, mostly-dead-code dict — good for a first-time contributor precisely because it's low-stakes.

Appendix A: Already tracked upstream

Original finding Existing issue/PR
multiprocessing/queues.py: feeder thread silently drops unpicklable objects on put() python/cpython#84376 (open since 2020-04-05, migrated from bpo-40195)

Appendix B: Already fixed since the original audit

Original finding Fix
dataclasses.py: _update_func_cell_for__class__ crashes on an empty closure cell gh-148947 / PR #148948, merged 2026-04-25

Appendix C: Disputed — not actually bugs (kept for transparency)

These looked plausible in the original AI-generated audit but turned out, on direct verification, to be either (a) code that already handles the case correctly (often via a bare raise/re-raise the audit missed), (b) explicitly documented, intended behavior, or (c) a previously-litigated design decision a CPython maintainer already reviewed and declined to change. Roughly 30 of the original 68 candidates fell into this bucket — a useful reminder that "an AI found this" is a starting point for investigation, not a finding.

  • tarfile.py bare except: (×2) — both end in an unconditional raise; nothing is swallowed.
  • configparser.read() silently skips unreadable files — explicitly documented in configparser.rst: "designed so you can specify an iterable of potential configuration file locations."
  • tarfile.next()'s raise e — raise e does not lose the traceback in Python 3 (verified empirically); one branch structurally requires raise e over bare raise.
  • zipfile.ZipExtFile's except AttributeError: pass around seekable() — deliberately added in bpo-22908 to support duck-typed file objects without .seekable().
  • tarfile.gettarinfo's uid/gid-name fallback to '' — only the cosmetic name field is affected; numeric uid/gid (what actually matters for extraction) is unaffected. Matches GNU tar behavior.
  • zipfile.testzip() returning a filename instead of raising — the documented API contract, unchanged since introduction.
  • tarfile extraction errors invisible at debug=0 — matches documented debug/errorlevel semantics exactly.
  • multiprocessing/managers.py shutdown "leaves zombie processes" — false: gh-91231 added a terminate/kill fallback that reliably reaps the process regardless.
  • multiprocessing/resource_tracker.py bare except: "causing leaks to go undetected" — doesn't match current code; the only bare-except sites either re-raise or don't gate leak detection, which runs unconditionally in a finally block.
  • multiprocessing/managers.py serve_client "hangs forever on dead connection" — false: the actual handler explicitly closes the connection and exits the serving thread.
  • multiprocessing/forkserver.py bare except: — re-raises; the original report itself flagged this as likely a non-issue, confirmed.
  • multiprocessing/pool.py result/task handlers "silently drop" failures — false in both cases: failures are explicitly forwarded to the caller via the AsyncResult mechanism; the only except KeyError: pass present guards an already-completed-job case, not real data loss.
  • multiprocessing/managers.py proxy __str__ fallback — the method's own docstring documents the fallback-on-failure contract explicitly.
  • http/server.py's suppress(Exception) — narrowly scoped to one setsockopt call with an explanatory comment for expected IPv4-only failure.
  • multiprocessing/popen_forkserver.py's hardcoded 255 — a defensible "abnormal termination" sentinel, only used when the real exit code is structurally unavailable.
  • asyncio._fatal_error discarding OSError unless debug=True — independently reported and closed as working-as-intended twice (gh-109215, gh-68765); the application-facing connection_lost(exc) callback still receives the real exception regardless of debug mode.
  • asyncio's two raise OSError(...) from None sites — the identical idiom exists deliberately in Lib/socket.py; no diagnostic information is actually lost since the original error is embedded in the new message.
  • logging.handlers.SocketHandler "silently drops" records — explicitly documented in both the code and the official docs; extensively discussed and declined by the logging maintainer in bpo-40352/gh-84532.
  • logging.config listener's dictConfig→fileConfig cascading fallback — the core mechanism was reviewed and kept by the maintainer in gh-127567; practical impact is also overstated (FileHandler self-heals, StreamHandler isn't even touched in testing).
  • importlib._bootstrap_external.py pyc validation swallowing ImportError/EOFError — this is the bytecode-cache invalidation mechanism, working exactly as designed; the sourceless-loader variant (no fallback available) correctly does not swallow the same errors, confirming the scoping is deliberate.
  • importlib._bootstrap_external.py _fix_up_module's except Exception: pass — explicitly cross-referenced in its own comment to two sibling call sites with the same documented policy.
  • logging.LogRecord's except Exception: pass for processName/taskName — carries an explicit in-code justification dating to a 2010-era bug (issue 8200) about multiprocessing import-order races; this is on the hottest possible code path (every log call).
  • importlib._bootstrap.py _lock_unlock_module's deadlock tolerance — the standard, decades-old, well-known "accept a partially-initialized module rather than deadlock" circular-import policy, cross-referenced elsewhere in the same file.
  • inspect._findclass raising on nested qualnames — real quirk, but its only caller (getdoc()) has wrapped it in a guard since 2020; no crash actually reaches any public API.
  • inspect.classify_class_attrs's broad except Exception — explicitly documented in the function's own docstring, with a comment referencing a specific ~20-year-old bug (bug #1785) that motivated the deliberately broad catch.
  • typing.overload's except AttributeError: # Not a normal function; ignore. — deliberately added alongside get_overloads(), per its own comment.
  • xml.dom.expatbuilder's bare except: — ends in an unconditional raise; KeyboardInterrupt propagates correctly (verified by injection).
  • email._encoded_words.decode_b's "inconsistent return type" — a deliberate, discussed design choice from bpo-27397 (2018): the email module's philosophy is to never raise on parse, only record defects. (This investigation did surface a real, different, unfiled bug as a side effect — see #34b above.)

Appendix D: Uncertain — needs further human judgment

  • inspect.getblock's possibly-unbound _token after certain SyntaxErrors (Lib/inspect.py:1138-1155). The code pattern (_, *_token_info = _token using a for loop variable that might never have been bound) would genuinely crash with UnboundLocalError if reached — but extensive fuzzing against the current C-accelerated tokenizer could not find any input that actually reaches this branch: most malformed-syntax inputs raise tokenize.TokenError (which getblock's except SyntaxError doesn't even catch), and the one SyntaxError subtype that can occur (IndentationError) is intercepted earlier. May be dead defensive code from a 2023 fix (gh-105013) that's no longer reachable given later tokenizer changes — or there may be a triggering input that wasn't tried. Someone with deeper tokenizer expertise should take a second look before filing.
  • email.message.get_content_charset() — the original audit's claim ("returns failobj on an encoding-lookup error") does not match actual behavior: verified that on a bad RFC 2231 charset*= name, the function instead returns the raw, still-undecoded payload string as if it were a valid charset name (a LookupError at Lib/email/message.py:945-946, not the failobj branch at 950-951, which is a separate and clearly-intentional "must be pure ASCII" guard). There may be a real bug here, but it needs to be re-described accurately around the actual mechanism before it's actionable.
@lovit

lovit commented Aug 17, 2026

Copy link
Copy Markdown

Thanks for the great catalog -- I used #2/#4 (except Exception in _get_protocol_attrs/_proto_hook) for my first real contribution attempt.

I filed an issue and PR, and got feedback from a typing module maintainer (JelleZijlstra) that this is intentional. Annotations can execute arbitrary code, so __annotations__ access can legitimately raise more than just AttributeError/NameError, and the FORWARDREF format is the safe fallback for those cases.

I confirmed this myself: a function call inside an annotation raising RuntimeError/TypeError still falls back cleanly through FORWARDREF, since it never evaluates the expression. Narrowing to except (AttributeError, NameError) breaks that.

Probably worth moving #2 and #4 to Appendix C. Linking the issue/PR for reference:

@danielKim614

danielKim614 commented Aug 22, 2026 •

Copy link
Copy Markdown

Thanks for the catalog! It was the starting point for my PyCon KR sprint contributions. Sharing verification results for five entries:

Confirmed and fixed

  • #22 (asyncio _accept_connection2): confirmed, the accepted socket also leaks until GC (the ResourceWarning socket's laddr is the server side). Filed python/cpython#155934, fix in python/cpython#155936 (in review).
  • #26 (StreamReaderProtocol.connection_made with a plain-function callback): confirmed, the leaked connection makes Server.wait_closed() hang forever. Filed python/cpython#155941, fixed by python/cpython#155942 (merged).

Probably appendix material

  • #9 (CalledProcessError.__str__ on None): already fixed on main by python/cpython#153971 (gh-153970). The else branch is an f-string now. → Appendix B
  • #10 (_wait_kqueue broad masking): looks intentional. Returning False doesn't mean "no event". It makes _wait() fall back to the waitpid polling path, and the function is an opportunistic fast path throughout (the EMFILE comment says as much). → Appendix C

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment