logging: capture each record once when propagate changes during a test (#15064) - #15075
DawnofGenX wants to merge 1 commit into
Conversation
7667a88 to
f8cbf03
Compare
Records from a logger which was non-propagating at capture start were handled twice once the test enables Logger.propagate: pytest's capture handler ran directly on that logger and again via the root logger. Instead of attaching the real capture handler to initially non-propagating loggers, attach a lightweight proxy bound to each such logger and its ancestors which forwards to the real handler only while that logger currently has propagate disabled. The proxy reads the live propagate value per record, so a False -> True transition stops the direct handling as soon as the record can reach root, and a True -> False transition on those loggers is now captured as well. Fixes pytest-dev#15064
f8cbf03 to
e502c27
Compare
RonnyPfannschmidt
left a comment
There was a problem hiding this comment.
Except for the nitpick where I need to read up on logging this looks good thanks
| # through the real handler instead. | ||
| pass | ||
|
|
||
| def emit(self, record: logging.LogRecord) -> None: |
There was a problem hiding this comment.
I believe this should be handle ?
iamibi
left a comment
There was a problem hiding this comment.
Thanks for working on this. I checked the current head (e502c27d) against its merge base (6a9ba0f0). The basic
False -> True case works, the logging suite passes, all current CI checks are green, and the new code remains below
McCabe complexity 10.
I found several cases that I think need to be addressed before merge.
Blocking correctness issues
-
The proxy can deadlock with the real handler across threads.
Handler.handle()holds the proxy lock while_BoundProxyHandler.emit()callsreal_handler.handle(). A thread can
therefore acquireproxy lock -> target lock, while another thread that holds the target lock and logs through the
owner acquirestarget lock -> proxy lock. A deterministic two-thread test deadlocks on this branch and completes on
main.The proxy must not hold its own handler lock while invoking the target.
-
Some valid handler topologies still capture a record twice.
I reproduced duplicates in all of these cases:
caplog.handleris also attached directly to the non-propagating logger;- two loggers share the same
handlerslist; - a non-root logger shares
root.handlers.
For the direct-target case,
mainrecords['direct'], while this branch records['direct', 'direct']. The proxy
needs an identity check for a directly attached target, and shared/root-aliased handler lists need explicit handling. -
Equality-based attachment and cleanup can remove a user handler.
Logger.addHandler()andLogger.removeHandler()use list membership/removal semantics. With a custom handler that
compares equal to_BoundProxyHandler, insertion of the proxy is suppressed, capture is lost, and exit removes the
user's handler instead. Attachment ownership and cleanup need to be verified and performed by identity. -
The per-context proxy lifecycle has a substantial cost.
I benchmarked hash-verified base and PR source snapshots in alternating fresh CPython 3.13 processes, with plugin
autoload disabled and source order alternated by round. The implementation exceeds the performance gates discussed
on the issue:Scenario Median PR/base ratio 5,000 tests, one qualifying logger 1.065x 5,000 tests, ten qualifying loggers 1.285x Stable-false emission, one target 1.74x Affected propagating sibling route 1.66x-2.11x Deep ancestor-barrier route up to 14.05x 1,000-owner context lifecycle about 17x 1,000-owner exit alone about 29.6x The one-logger workload was slower in 14 of 15 paired runs. Ten loggers were slower in all 15 runs. Fully disjoint
routes stayed near 1.00x, and the logging handler registry returned to baseline, so this is not a retained registry
leak. The cost comes from constructing and destroying realHandlerproxies for every owner, ancestor, target, and
capture context. In particular, cleanup is repeated for pytest phases rather than being a one-time final cost.
Other behavioral issues
proxy.setLevel()silently discards the write. Filters and formatters installed throughlogger.handlersalso stop
affecting capture once the logger propagates.- On Python 3.12+, replacement-record filters have transition-dependent behavior because the logger checks the original
record against the delegated target level before the proxy filter can replace it. - Nested contexts using the same target create multiple proxies and duplicate records during their overlap.
- If a later local or ancestor handler raises after a propagating proxy skips delivery, root is never reached.
main
captures that record before the failure; this branch does not. - Removing a proxy from the logger does not close or disable it. Code retaining the proxy can keep the target and logger
alive and can still forward records after the capture context has ended. - An unhashable custom
Loggerfails while buildingproxy_targets. Because root was already modified, the failed
__enter__leaves the capture handler attached. Entry needs transactional rollback.
Coverage and publication details
- The mixed child/parent barrier test already passes on pristine
main; only the directFalse -> Truetest is a new
failing regression there. - The changelog mentions caplog, failure reports, live CLI logging, and file output, but permanent tests currently cover
only caplog and failure reports. - The contributor is not yet listed in
AUTHORS, whichCONTRIBUTING.rstrequests for non-trivial changes.
The minimum additional coverage should include direct target attachment, shared handler lists, identity-hostile
handlers, lock ordering, filters/levels/formatters, Python 3.12 replacement records, nested contexts, failed-entry
rollback, proxy collection, and live/file output.
The patch is much smaller than the earlier designs and fixes the primary happy path, but the locking, identity,
lifecycle, and scaling issues above make it unsafe to merge as written. Reusing proxies across the outer capture scope
and making registrations identity-based/refcounted would address several of these problems together.
Problem
Fixes #15064
Since #14375, pytest attaches its capture handlers to every logger which is non-propagating when capture starts (so their records don't get lost, #3697). If the test then sets
logger.propagate = True, a singleLogger.callHandlerswalk invokes the same handler object twice - once on the logger and once on root - producing duplicate entries incaplog.messages, failure-report "Captured log" sections,--log-cli-leveloutput, and--log-fileoutput:Approach
Per the direction in the thread (@RonnyPfannschmidt: "we need a bound proxy handler for each logger that hinges on the current value of propagate"),
catching_logsnow attaches a small_BoundProxyHandler- instead of the real capture handler - to each logger which is non-propagating at capture setup, and to its ancestors up to (not including) root. The proxy:propagatevalue inemit(), forwarding to the real handler (viahandle(), so the real handler's own filters/level/lock/handleErrorapply) only while propagation is currently off;levelto the real handler sosetLevel()on the capture handler keeps working unchanged;When a record does propagate to root, every proxy on its path below root evaluates
logger.propagatetruthy and stays silent; the root-attached real handler does the work exactly once. When the barrier holds, the nearest proxy forwards it once. This fixes the reportedFalse -> Truecase, the mixed child/parent transition, and also captures the previously-missedTrue -> Falsetransition on the initially-affected loggers and their ancestors (the narrower owner set from the discussion; the cost/performance objections to proxies on every logger in #15064's comment thread do not apply here, since unrelated propagating routes get no proxies and pay nothing).Cost: per-record work on affected routes is one
Handler.handle()+ onepropagateattribute read on the proxy - the proxy only sits on loggers that were already getting a direct handler on main, so propagating routes through unaffected loggers are untouched. DirectHandler.handle()calls, custom filters on the capture handler, andlogger.handlerscontents during capture behave as before, except the extra handler inlogger.handlersis now the proxy rather than the capture handler itself.Known remaining limitation (unchanged, documented in a code comment): a logger which becomes non-propagating during the test outside those initial ancestor chains is still missed - same as main.
Evidence
main, reproducing the duplicate; pass with the fix):testing/logging/test_fixture.py::test_capture_once_when_propagation_enabled_during_test-caplog.messagessurface, incl. a child logger.testing/logging/test_fixture.py::test_capture_once_when_propagation_barrier_moves_to_ancestor- mixed child/parent transition (barrier moves to an ancestor mid-test).testing/logging/test_reporting.py::test_log_propagation_enabled_during_test_captured_once- failure-reportCaptured log callsection surface.testing/logging/: 90 passed (87 on main + the 3 new tests).testing/suite: 4584 passed, 87 skipped, 12 xfailed, 7 xpassed. Full CI matrix green (36/36 checks, macOS/Ubuntu/Windows, py3.10-3.15 + Pypy).ruff check src testingclean,ruff formatclean,mypy src/_pytest/logging.pyclean.AI assistance disclosure
This contribution was prepared with assistance from an AI agent (Hermes Agent by Nous Research, Qwen model) under human review and ownership; the human author has reviewed the change, understands the mechanism above, and will respond to review feedback.