Skip to content

Rehabilitating #431 (1/2?): threading fix + patching infrastructure - #442

Open
TTsangSC wants to merge 12 commits into
pyutils:mainfrom
TTsangSC:threading-fix-and-patches
Open

TTsangSC wants to merge 12 commits into
pyutils:mainfrom
TTsangSC:threading-fix-and-patches

Conversation

@TTsangSC

@TTsangSC TTsangSC commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

This PR is the first in a series where code changes in #431 (plus review feedback) are broken down into more review-friendly chunks.

Main changes (relative to main)

  • Fixed handling of sys.monitoring callbacks: now the callbacks themselves are process-global (see bac55fe), and the set[LineProfiler] passed to the C++-level inner tracing callback is thread-local, allowing for behavior that is (1) to-spec and (2) consistent across backends
  • Refactored kernprof to make heavier use of context managers for setup and teardown
  • Updated line_profiler.LineStats to allow for subtraction of a "baseline" value, to be used later when we handle profiling data from forked processes
  • New code:
    • line_profiler._threading_patches:
      New module for threading patches so that profiling continues seamlessly into child threads
    • line_profiler.cleanup, line_profiler.curated_profiling:
      New modules for context-based management of profiling-session setup and teardown

Changes since last round of #431 review

Code changes

  • line_profiler._line_profiler.pyx:
    • Incorporated code from bac55fe (see Fable review of profile child process TTsangSC/line_profiler#5 by @Erotemic), fixing the previous assumption that sys.monitoring callbacks are thread-local (they aren't); but see the following for change on top thereof.
    • The callbacks passed to sys.monitoring.register_callback() are now instance methods on _SysMonitoringState instead of _LineProfilerManager, and _SysMonitoringState.active_instances are now thread-local, allowing for thread-local LineProfiler.enable_count to be handled consistently.
    • Updated _LineProfilerManager._handle_enable_event() and ._handle_disable_event() so that calls to _SysMonitoringState.register() and .deregister() are made in consideration of the process-global states, not just the thread-local .active_instances.
  • line_profiler/_threading_patches.py:
    • Superseded make_thread_init_wrapper() with wrap_thread_start() to prevent issues with delayed start.
    • Instead of using make_syncing_wrapper() to directly wrap Thread._target, we now wrap Thread._bootstrap() and .run() so that the patch is effective on Thread subclasses with an overridden .run() method.
    • The thread is now applied regardless of ${LINE_PROFILER_CORE} used; the previous behavior where it seemed to be gratuitous when using sys.monitoring was only an artifact of how the callbacks were erroneously handled (see above).
    • As such, SHOULD_PATCH_THREADING is removed.
  • line_profiler/c_trace_callbacks.c:
    Integrated changes from a5001ca (see Fable review of profile child process TTsangSC/line_profiler#5 by @Erotemic), fixing the leaking of frame-local legacy trace callbacks.
  • line_profiler/line_profiler.py:
    • get_code_block():
      Now making use of the new clone_single_module() to create a private copy of inspect with the patched facilities, instead of monkey-patching the global module.
    • LineStats.from_files():
      Fixed error handling.
  • line_profiler/line_profiler_utils.py::clone_single_module():
    New helper function for constructing a copy of a module.

Test changes

  • line_profiler/*.py:
    Added/extended doctests in the aforementioned modules for coverage.

  • tests/test_threading.py:
    New test module.

    • test_disable_on_one_thread_keeps_other_threads_profiling():
      Migrated and refactored from tests/test_cross_thread_profiling.py (bac55fe), making sure that disabling a profiler on a thread does not affect profiling on another thread by either the same profiler or a different one
    • Tests for various (edge) cases proposed in the review:
    • test_child_thread_profiling_toggle_by_count():
      Where the .enable_count has been altered between thread creation and starting.
    • test_child_thread_profiling_subclassed():
      Where a Thread subclass with an overridden .run() is used.
    • test_child_thread_profiling_separate_creation_and_consumption():
      Where a Thread object is instantiated and used on different threads.
    • test_child_thread_profiling_in_kernprof():
      End-to-end test with kernprof.
  • tests/test_line_profiler.py:

    • test_load_stats_files_backward_compatiblity():
      Renamed from test_load_stats_files().
    • test_load_problematic_stats_file():
      New test proposed in the review for the protocols ('ignore', 'warn', or 'error') of handing missing, empty, corrupted, and incompatible stats files.
  • tests/test_sys_trace.py::test_trace_wrappers_are_not_leaked():
    Migrated and refactored from tests/test_trace_callback_leaks.py (a5001ca), making sure that the "disabling wrappers" created in line_profiler._line_profiler are not leaked

Misc. fixes

  • kernprof.py::_pre_profile():
    Fixed bug where sys.argv is replaced with a different list[str] object, thus preventing line_profiler.line_profiler_utils.restore from restoring its content.
  • line_profiler/toml_config.py::ConfigSource.get_subconfig():
    Fixed bug where copy=True was not respected; also extended doctest for the case.
  • tests/test_docs_conf.py:
    Fixed bug where a file object was created without being properly closed, resulting in noisy warnings as pytest exited.

TODO

In the following PRs:

  • Incorporate the remaining changes from FEAT: extend profiling to child processes #431:
    • The entirety of line_profiler/_child_process_profiling
    • The hook _line_profiler_hooks.py
    • Config/CI changes in .github/workflows/tests.yml, setup.py, and line_profiler/rc/line_profiler.toml
    • The entirety of tests/test_child_procs
    • The behavior toggles and hooks to line_profiler._child_process_profiling in kernprof.py
  • Review and either fix (where appropriate) or document the edge cases named in the review:
    • Race conditions between the Pool._result_handler and ._worker_handler threads.
    • Handling of the multiprocessing server processes:
      • Instead of setting up and tearing down in the .resource_tracker process, try to refactor the setup so that is not run there to begin with.
      • Defer setup (esp. the handling of eager pre-imports) of child processes served by the .forkserver to the children themselves instead of the server.
    • Failures in line_profiler components propagating to user-space and affecting the normal execution of the profiled code.
    • Interaction with kernprof --output-interval
  • Add documentation for the new feature.

TTsangSC and others added 9 commits September 17, 2026 05:27
We're in the process of breaking up the gargantuan pyutils#431 into more
review-able PRs. This PR contains:
- The fix for `threading` when using the legacy trace system
- The architectural changes in `kernprof`, making use of context
  managers to handle setup and teardown
- The `~.cleanup` infrastructure for context-based cleanups

kernprof.py
    main()
        Internal refactoring for tempfile scrubbing
    _touch_tempfile()
        Superseded by
        `line_profiler.line_profiler_utils.make_tempfile()`
    _gather_preimport_targets()
        Superseded by `line_profiler.curated_profiling
        .ClassifiedPreimportTargets.from_targets()`
    _write_preimports()
        Updated return type from `None` to `Path | None`
    _dump_filtered_stats()
        - Added optional argument
          `extra_line_stats: LineStats | None = None` for handling
          additional stats (e.g. from child processes)
        - Refactored the `LineStats`-filtering part out into its own
          function `_dump_filtered_line_stats()`
    _manage_profiler
        New context manager for managing setup (e.g. preimports,
        profiler preparation) and teardown (e.g. tempfile deletion)
    _pre_profile()
        - Refactored into `_prepare_profiler()` and
          `_prepare_exec_script()`
        - Fixed bug where `sys.argv` is replaced with another list, and
          thus is not restored by the
          `@line_profiler.line_profiler_utils.restore.sequence`
          decorator
        - Offloaded some of the setup to `CuratedProfilerContext`
    _main_profile()
        Now using `_manage_profiler` to manage setup and teardown
    _post_profile()
        - Added optional argument
          `extra_line_stats: LineStats | None = None` for handling
          additional stats (e.g. from child processes)
        - Offloaded some of the teardown to `CuratedProfilerContext`

line_profiler/_threading_patches.py
    New module for fixing the bug where profiling doesn't extend into
    new threads when profiling is already enabled in the parent thread

    TODO:
    - Defer the fix from `.__init__()` time to `.start()` time to guard
      against deferred starts (see GPT-5.6 review in pyutils#431 comments)
    - Write small tests independent of `multiprocessing` verifying the
      behavior (pyutils#431 has `multiprocessing.dummy` tests in the test
      matrix, but it is (1) hard to refactor them out and (2) better to
      have standalone tests for this component)

line_profiler/cleanup.py::Cleanup
    New context-manager class for maintaining a stack of cleanup
    callbacks to be executed on `.__exit__()`

line_profiler/curated_profiling.py
    New module for common tasks related to profiling session setup and
    teardown, to be used by `kernprof` and by child-process-profiling
    code

    ClassifiedPreimportTargets
        New helper object for taking `--prof-mod` and constructing an
        eager-preimports module
    CuratedProfilerContext
        New context manager for handling:
        - Interpolation of `@profile` into the builtin namespace
        - Installation of the profiler instance to
          `line_profiler.profile`
        - By-count disabling of the profiler after the session

line_profiler/line_profiler_utils.py
    CallbackRepr
        New `reprlib.Repr` subclass extended for handling the following:
        - `os.environ` and `os.environb`
        - Bound methods
        - `functools.partial` objects
        The method `.format_call()` can be used format the received
        arguments like with `inspect.BoundArguments`.
    block_indent()
        New function for indenting a text block given the first-line
        prefix (e.g. a bullet point)
    make_tempfile()
        New function for constructing tempfiles

line_profiler/toml_config.py::ConfigSource.get_subconfig()
    Fixed bug where the `copy` param is not respected and extended
    doctest therefor
line_profiler/_threading_patches.py
    make_thread_start_wrapper()
        - Supersedes `make_thread_init_wrapper()`, so that
          `prof.enable_count` is retrieved when the "physical" thread is
          spun up, not when the `Thread` wrapper object is created
        - Instead of creating a wrapper around `target` (i.e.
          `self._target`), wrap `self._bootstrap` to take are of edge
          cases where the thread object is constructed without the
          former
    apply()
        Now patching `Thread.start()` instead of `Thread.__init__()`

tests/test_threading.py
    New tests for the above:

    test_child_thread_profiling_toggle_by_count()
        Test various `prof.enable_count` manipulation patterns
    test_child_thread_profiling_subclassed()
        Test a `Thread` subclass overrridding `.run()` and not setting
        `._target`
    test_child_thread_profiling_separate_creation_and_consumption()
        Test creating a child thread in one thread and using it in
        another
    test_child_thread_profiling_in_kernprof()
        Test using `kernprof` to profile multi-threaded code
line_profiler/line_profiler.py
    get_code_block()
        Instead of monkey-patching the global `inspect` module on each
        call, now using a patched clone thereof local to this module
    LineStats
        <General>
            Fixed semantics in doctests: the `.timings` keys are in the
            order `(filename, lineno, funcname)` instead of the other
            way round
        .__sub__(), .__isub__()
            New methods allowing for subtraction between instances
        .get_empty_instance()
            New convenience class method for creating an instance
            without profiling data
        .from_files()
            Added new optional arguments `on_empty` and `on_error` to
            allow for non-fatal failures, skipping over files from which
            line-profiling data cannot be read

line_profiler/line_profiler_utils.py::clone_single_module()
    New function for creating a clone of a module

tests/test_line_profiler.py
    test_load_stats_files_backward_compatibility()
        Renamed from `test_load_stats_files()`
    test_load_problematic_stats_file()
        New test for testing the behavior of `LineStats.from_files()`
        with various problematic cases (missing, empty, corrupted,
        incompatible files)
…R-1)

The sys.monitoring backend kept enable/disable state in per-thread
_LineProfilerManager objects while set_events()/free_tool_id() act
process-globally. As soon as the first-registering thread's manager ran
out of active profilers it tore down the global events and freed the
tool, silently ending data collection for every other thread and making
their later disable() calls raise 'ValueError: tool 2 is not in use' --
which surfaces inside user code, since @Profile wrappers disable in a
finally block. 3.12+ defaults to this backend. (Evidence: dev/planning/
fable-review-fullrepo-2026-07-05.md, finding F1.)

Fix: one _SysMonitoringState is now shared per tool id
(_get_shared_mon_state()), and in sysmon mode every manager's
active_instances aliases the shared state's set, so the existing
enable/disable logic becomes globally correct: the global callbacks
are registered when the first profiler anywhere enables and torn down
when the last one anywhere disables, regardless of which thread does
either. A bare process-wide refcount (the originally-planned fix)
would not have sufficed: attribution consults the handling manager's
active_instances, so a profiler enabled on another thread would have
recorded nothing.

Also: deregister() is now idempotent (no-op unless registered) and
tolerant of an externally-freed tool; disable() clears the in-progress
line bookkeeping for all threads under sysmon (still recreating the
caller's empty entry, which c_last_time expects). The legacy trace
core keeps its strictly per-thread state and semantics.

Regression test: tests/test_cross_thread_profiling.py runs two
profilers on two threads with the first enabler bowing out mid-flight,
under both cores in subprocesses. Before this fix the sysmon variant
lost thread B's remaining hits and raised ValueError; both variants
now assert the exact 2xN hit count. Full test suite passes (429
passed, 1 skipped, 1 xfailed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This builds upon the fix for the `sys.monitoring` interface in bac55fe,
which while recognizing the process-globality of `sys.monitoring`
callbacks fell short of reconciling it with the thread-locality inherent
to the rest of our internal plumbing. The refactoring allows for out
profiling and bookkeeping to stay thread-local while the
`sys.monitoring` callbacks are to-spec and process-global.

line_profiler/_line_profiler.pyi::LineProfiler.__init__()
    Added initializer stub to stop the type checker's compliants about
    valid invocations

line_profiler/_line_profiler.pyx
    _SysMonitoringState
        .active_instances
            Now a THREAD-LOCAL property to restore per-thread toggling
            of profiling; the same profiler instance can now be
            separately en-/disabled on different threads even when using
            `LINE_PROFILER_CORE=sysmon`, as with
            `LINE_PROFILER_CORE=legacy`
        .wrap_trace
            New property and init arg mirroring the eponymous property
            on `_LineProfilerManager`, because the callbacks have been
            migrated to this class
        .__init__()
            Updated signature to take an extra argument `wrap_trace`
            (see above)
        .register()
            No longer taking any arguments since the `.handle_*_event()`
            methods have been migrated to the class, and can be directly
            accessed as instance methods
        .handle_{line,return,yield,raise,reraise}_event()
            Migrated from the eponymous methods on
            `_LineProfilerManager`; it makes better sense for them to be
            here, since they are solely intended for use by
            `sys.monitoring.register_callback()`; plus, while the
            callbacks are process-global, the `.active_instances` to be
            fed to the underlying mechnism are thread-local and
            dynamically resolved by the `_SysMonitoringState` instance,
            so methods accessing the attribute should be put closer
        ._has_active_instances()
            New C-only helper method for detecting whether the callbacks
            should be `.register()`-ed, checking the `.active_instances`
            over all threads
    _get_shared_mon_state()
        Updated call signature
    _LineProfilerManager
        ._handle_enable_event(), ._handle_disable_event()
            Updated checks to use `._has_active_instances()`
        ._has_active_instances()
            (See `_SysMonitoringState._has_active_instances()`)
        .wrap_trace.__set__()
            Now setting the eponymous property on `.mon_state`
    LineProfiler.disable()
        No longer clearing `._c_last_time` when using `sys.monitoring`
        because `.disable()`-ing is now thread-local in all cases

line_profiler/_threading_patches.py
    SHOULD_PATCH_THREADING
        Removed
    make_syncing_wrapper()
        Simplified
    apply()
        No longer a no-op when using `sys.monitoring`; owing to the
        fixed handling of profiler `.enable_count` manipulation,
        enabling/disabling events are now handled consistently between
        the two "cores", and thus the same patch is required

tests/test_cross_thread_profiling.py
::test_disable_on_one_thread_keeps_other_threads_profiling()
    - Added type hints
    - Refactored implementation to be more modular
    - Added test cases where the same `LineProfiler` instance is used
      across both threads, which failed in the previous implementation
Both call_callback() and set_local_trace() stored the wrapper objects
they create (via disable_line_events() / wrap_local_f_trace()) on
frame.f_trace with PyObject_SetAttrString(), which takes its own
reference, while never releasing the creation reference. One wrapper
leaked per wrapped trace event -- unbounded growth on long profiled
runs alongside a debugger or coverage tool. The in-code comment
claiming nothing else holds a reference was wrong once SetAttr
succeeds. (Evidence: dev/planning/fable-review-fullrepo-2026-07-05.md,
finding F8; empirically confirmed -- the wrappers survived deletion of
every Python reference.)

Also fixed in set_local_trace():
- a NULL call result was passed to PyObject_SetAttrString, turning it
  into an attribute delete executed with a live exception set (C-API
  misuse);
- failures now report via PyErr_WriteUnraisable instead of leaving an
  exception set that the void/no-except Cython call site never checks;
- PyUnicode_FromString result is NULL-checked;
- the direct f_trace assignment uses Py_XSETREF (no longer overwrites
  a possible Py_None without release).

Removed the dead mod/dle locals in call_callback().

Regression test: capture frame.f_trace wrappers via weakref from
inside a profiled function under a foreign trace callback (including
one that disables f_trace_lines), then assert they die with their
frames. Runs under LINE_PROFILER_CORE=legacy in a subprocess; fails
against the previous .so, passes after this fix. Note the wrappers
masquerade as the functions they wrap (@wraps copies __qualname__), so
weakref-death is the only reliable observable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/test_sys_trace.py
    <General>
        - Updated docstring format to comply with the rest of the
          codebase
        - Typing fixes and Python 3.10+ updates
    @isolate_test_in_subproc
        Added new argument `core` for optionally setting
        `${LINE_PROFILER_CORE}`, instead of always unsetting it
    test_trace_wrappers_are_not_leaked()
        Migrated from `tests/test_trace_callback_leaks.py` with slight
        refactoring

tests/test_trace_callback_leaks.py
    Folded into `tests/test_sys_trace.py`
tests/test_threading.py
::test_disable_on_one_thread_keeps_other_threads_profiling()
    Migrated and slightly refactored from
    `tests/test_cross_thread_profiling.py`

tests/test_cross_thread_profiling.py
    Folded into `tests/test_threading.py`
tests/test_docs_conf.py
    CONF_FPATH
        Now a `pathlib.Path`
    parse_version
        New fixture refactored from `_load_parse_version()`, removing
        the problematic unmanaged `open()` call which leaks a file
        object
    test_parse_version()
        Now using the above fixture
@TTsangSC TTsangSC changed the title Rehabilitating #431: threading fix + patching infrastructure Rehabilitating #431 (1/2?): threading fix + patching infrastructure Sep 21, 2026
@codecov

codecov Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.05618% with 22 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.48%. Comparing base (b5ca752) to head (71efcea).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
line_profiler/line_profiler.py 90.41% 4 Missing and 3 partials ⚠️
line_profiler/cleanup.py 96.24% 2 Missing and 3 partials ⚠️
line_profiler/curated_profiling.py 94.56% 2 Missing and 3 partials ⚠️
line_profiler/line_profiler_utils.py 96.87% 2 Missing and 1 partial ⚠️
line_profiler/_threading_patches.py 95.74% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #442      +/-   ##
==========================================
+ Coverage   84.95%   86.48%   +1.53%     
==========================================
  Files          21       24       +3     
  Lines        2412     2841     +429     
  Branches      376      429      +53     
==========================================
+ Hits         2049     2457     +408     
- Misses        263      273      +10     
- Partials      100      111      +11     
Files with missing lines Coverage Δ
line_profiler/toml_config.py 92.64% <100.00%> (+0.16%) ⬆️
line_profiler/_threading_patches.py 95.74% <95.74%> (ø)
line_profiler/line_profiler_utils.py 96.44% <96.87%> (+0.39%) ⬆️
line_profiler/cleanup.py 96.24% <96.24%> (ø)
line_profiler/curated_profiling.py 94.56% <94.56%> (ø)
line_profiler/line_profiler.py 95.00% <90.41%> (-0.78%) ⬇️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 984ad72...71efcea. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@TTsangSC

TTsangSC commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Got struck by works-on-my-machine-itis again, dunno why the threading stuff is not working on 3.10 and 3.11 in CI. More curiously is how the build jobs passed but the test jobs failed. May have to do with venv/uv weirdness or the lack thereof...

EDIT of course it's coverage. Let's see what exactly went wrong...

EDIT 2 fixed

make_syncing_wrapper()
    - Now also taking a bound method and handling the (un-)wrapping
    - Updated docstring

wrap_thread_start()
    - Renamed from `make_thread_start_wrapper()`
    - Instead of directly using `make_syncing_wrapper()` to wrap
      `Thread._bootstrap()`, use `wrap_thread_bootstrap()` for another
      layer so that profiler enabling can be delayed, preventing the
      `threading.settrace()`-ed callable from overwriting our
      legacy-tracing callback

wrap_thread_bootstrap()
    New wrapper around `Thread._bootstrap()` that dynamically
    monkey-patches `Thread.run()`, which enables the profiler where
    appropriate (see above)

apply()
    Updated docstring
line_profiler/_threading_patches.py::make_syncing_wrapper()
line_profiler/cleanup.py::Cleanup._cleanup(), .patch()
    Added/extended doctests for coverage

line_profiler/curated_profiling.py::ClassifiedPreimportTargets
    .write_preimport_module()
        Added doctest
    .from_targets()
        - Extended doctest to cover more cases (invalid and excluded
          paths)
        - Replaced dead check with a valid one (`modpath_to_modname()`
          can never return `None`, but it can return a string which
          isn't a valid dotted path)
@TTsangSC
TTsangSC requested a review from Erotemic September 25, 2026 16:47

@Erotemic Erotemic left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧑‍🦱: I went through and did a non-LLM read through to highlight some improvements that could be made wrt to making it easier for humans to read and understand the code. I think having module level docstrings more clearly introduce the mental model for the underlying code would make a big difference.

I then did a GPT 5.6 review, and discussed with it, and pasted relevant points near the code they belong to. I agree with it that we want to be thinking about state management transactionally. It also caught bugs I would have had a very difficult time catching myself.

🤖 The GPT 5.6 summary is:

Overall assessment

The central threading work has improved substantially. In particular, the patch now:

  • captures profiler state at Thread.start() rather than at thread construction;
  • handles a Thread subclass that overrides run();
  • handles thread construction and starting on different threads;
  • fixes the process-global lifetime of the sys.monitoring registration;
  • contains a targeted regression for one thread disabling while another remains active;
  • incorporates the C-level frame-trace reference leak fix;
  • has broad green CI on Python 3.10–3.14, Linux/macOS/Windows, including ARM.

So the important problems with the earlier threading implementation are genuinely addressed.

Things I checked that look good

The new _SysMonitoringState structure is a substantial improvement over the previous version. Keeping registration/callback ownership process-global while selecting active_instances from a thread-specific set matches the actual sys.monitoring model much better. The new cross-thread regression with both one shared profiler and two separate profilers is particularly useful.

The C reference-management changes in c_trace_callbacks.c also look right: the newly created wrapper owns its temporary reference, assignment to f_trace creates/maintains the frame's reference, and the temporary is then decref'd. The weakref regression test is an appropriate test for the original leak.

The Thread.start() timing change and wrapping of run() instead of _target directly address the earlier delayed-start, overridden-run, and separate creator/starter cases. Those are no longer concerns I would repeat from the previous review.

The LineStats.from_files() error-handling additions look coherent, and the tests cover empty, nonexistent, corrupt, and incompatible inputs under the ignore/warn/error modes. I did not find a merge-blocking problem there.

Likewise, the LineStats baseline subtraction is reasonable groundwork for the later fork aggregation work. I would expect its full semantics to be exercised when the child-process half lands rather than expanding this PR further.

Suggested priority

I would fix these before approval:

  1. descriptor-safe Cleanup.patch() restoration / Thread.run corruption;
  2. exact restoration of GlobalProfiler state;
  3. actual nested-context reentrancy;
  4. define and correctly implement ownership/composition for the process-global threading patch.

I would also add the explicit threading.settrace() regression.

Transactional failure containment can reasonably remain partially deferred given the PR's stated split, but CuratedProfilerContext itself should ideally establish the invariant now that if installation fails, everything it already changed is reverted.

After those fixes, I think this first half of the #431 rehabilitation is structurally much closer to something I would want to build the child-process work on.

@@ -0,0 +1,329 @@
"""
Tools for setting up profiling in a curated environment (e.g. with
the use of :py:mod:`kernprof`).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this module level docstring is sufficient for a new developer trying to reason how all the pieces in this repo fit together.

Comment thread line_profiler/cleanup.py
mapping: MutableMapping[K, V],
updates: Mapping[K, V],
*,
_format_debug_msg: Callable[[Mapping[K, V], K, str], str] = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would much rather define this default as a private named method somewhere than have a large lambda in a function signature.

Comment thread line_profiler/cleanup.py
self, *,
delete: bool = True,
priority: float = 0,
_format_debug_msg: Callable[[Path], str] = (

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how important this pattern is for your work, but whenever we can avoid passing functions as arguments, that makes the code MUCH easier to reason about. I get that its only for debug formatting, but these are the sort of highly dynamic things python lets you do that makes sense when you're writing code, but makes it harder for someone reading it. With LLMs it might not matter all that much, but its my preference.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC I did the formatting-function thing so that the cache object in #431 can format debug-log messages in a way that makes sense there (can be converted to and from log-entry objects) but not here.

But I probably (1) messed up the signature either way since it should probably have taken a str instead of a path, and (2) can pre-format the message in a wrapper/overridden method before calling this. Will think a bit harder about the refactoring.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never mind, it was supposed to take a Path because the method generates a new Path. But yeah as with .update_mapping() I will extract the argument into a private method.

Args:
targets (Collection[str])
Collection of dotted paths and filenames to profile.
exclude (Collection[str])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type in the docstring doesn't match the type in the signature. I think given that we have embraced inline typing, it's no longer necessary to even specify the type in the doc-string. However, I do think there should be a colon after the variable name. So the pattern would be:

<varname> [(<type>)]: <description>, where brackets around (type) indicates it is optional. (Not sure if this is accepted by sphinx, or if there is an existing parser for google style docstrings without typing, but it probably wouldn't be a huge lift to patch it in).

Comment thread line_profiler/cleanup.py
...


class Cleanup:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd suggest naming this CleanupStack to make it more clear what it does where it is used. It also looks like it doesn't always need to be used as a context manager, so maybe make the mental model for how someone needs to think about this object more clear in its docstring.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking at this more, and the semantics read weird. Say you have:

cleanup = Cleanup()

and then you call cleanup.patch, you are mutating an object, but the idea is that its done in a revertible way. But the name "Cleanup" doesn't really communicate that not only is this able to revert or clean up temporary state, it is also able to create that state and register it. I think something like RegisteredReversibleState is a bit too verbose. I discussed the issue with GPT and it suggested several names:

Details

Names that communicate both halves better:

  • ReversibleState — probably my favorite. It says the object manages state that it is allowed to change, and those changes are reversible.
  • StateTransaction — very strong if you want database-like semantics: mutate within a transaction, then roll back/restore at the end. This may actually be the clearest conceptual model.
  • MutationScope — clearly says “mutations happen here,” with scope implying bounded lifetime. It does not quite communicate automatic restoration by itself.
  • ReversibleMutation / ReversibleMutations — extremely explicit, though slightly awkward as a class holding many operations.
  • MutationRegistry — communicates recording mutations, but not restoration strongly enough.
  • StatePatch / StatePatcher — makes state.patch(...) feel natural, but underplays arbitrary cleanup callbacks.
  • ScopedState — good lifecycle meaning, weaker on active mutation.
  • StateOverride / StateOverrides — good if most operations are temporary replacements; weaker for arbitrary cleanup.
  • RevertibleState — same idea as ReversibleState; I'd prefer “reversible” stylistically.

Of which I kind of like StateTransaction. That seems conceptually like what you're building here, unless I'm misunderstanding.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking a little bit harder about this I'm not so sure about the new suggested semantics. The Cleanup class is meant to be a holder of stacks of callbacks, which are executed in (reversed) order at "the end", whether it be an explicit call to .cleanup() or a context exit... so CleanupStack is indeed correct.

The helper methods .patch(), .update_mapping(), and .make_tempfile() implemented on top of that can be considered to be effectively logging transactions (changes + how to reverse them), but that seem to be one level of abstraction above callbacks. Still, maybe it makes sense to redesign the whole thing around transactions...

Comment thread line_profiler/cleanup.py
priority: float = 0,
) -> None:
"""
Patch an attribute on an object.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is from GPT 5.6: 🤖

1. Cleanup.patch() corrupts Thread.run after a thread exits

This is the clearest correctness bug I found.

wrap_thread_bootstrap() does:

with Cleanup() as cleanup:
    run = make_syncing_wrapper(self.run, prof, enable_count)
    cleanup.patch(self, 'run', run)
    vanilla_impl(self, *args, **kwargs)

But Cleanup.patch() defaults to static=True and saves the old value with inspect.getattr_static().

For a normal Thread instance, run is inherited from its class. Therefore:

inspect.getattr_static(thread, 'run')

returns the unbound function descriptor, not a bound method and not an instance attribute.

Cleanup later does essentially:

setattr(thread, 'run', old_function)

instead of deleting the temporary instance attribute. The result is that after the thread finishes, the thread object has a new run entry in its instance dictionary containing an unbound function.

I reproduced the underlying behavior on Python 3.13:

before-own-run False function
after-own-run True function
TypeError T.run() missing 1 required positional argument: 'self'

So profiling a thread permanently changes that thread object's method resolution, and calling thread.run() afterward fails.

This also exposes a flaw in the generic Cleanup.patch() abstraction: restoration needs to preserve where the attribute originally lived, not merely the value returned by getattr_static().

A robust implementation should distinguish at least:

  • attribute originally present directly on the target object → restore that exact direct value;
  • attribute originally inherited / absent from the target namespace → delattr() on cleanup, revealing the inherited attribute again.

For the threading code specifically, a regression test should assert after join() that an originally inherited run remains inherited:

assert 'run' not in vars(thread)

and that calling/binding it still works normally.

for _ in range(getattr(prof, 'enable_count', 0)):
prof.disable_by_count()

def install(self) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 GPT 5.6 says:

2. CuratedProfilerContext leaves line_profiler.profile in an invalid state

CuratedProfilerContext.install() temporarily installs its profiler with:

self._global_install(self.prof)

and registers cleanup as:

self.add_cleanup(self._global_install, None)

But _global_install() delegates to GlobalProfiler._kernprof_overwrite(), whose implementation is:

def _kernprof_overwrite(self, profile):
    self._profile = profile
    self.enabled = True

Therefore teardown does:

_global_profiler._profile = None
_global_profiler.enabled = True

That is not a restoration. It is an internally inconsistent GlobalProfiler.

The next use of the decorator can reach:

if not self.enabled:
    return func
assert self._profile is not None

and fail the assertion.

This behavior existed as part of the old kernprof end-of-process cleanup, where it was largely hidden because the CLI was about to terminate. Moving it into a reusable context-management abstraction makes it observable in a long-lived process.

The context should snapshot and restore the previous _profile and enabled state exactly, preferably through an explicit temporary-override API on GlobalProfiler rather than by treating _kernprof_overwrite(None) as an uninstall operation.

A regression test should look roughly like:

before = (profile._profile, profile.enabled)

with CuratedProfilerContext(prof):
    ...

assert (profile._profile, profile.enabled) == before

It would also be useful to actually decorate/call a function afterward.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also related from 🤖 GPT 5.6:

5. CuratedProfilerContext.install() still lacks transactional rollback

This is somewhat different because the PR explicitly lists broader failure-containment work as deferred, so I would distinguish it from the definite bugs above.

However, the new abstraction currently performs multiple global mutations:

upgrade profiler
replace global @profile
patch threading
possibly patch builtins
register teardown

without an install()-local rollback guard.

_manage_profiler.__enter__() does attempt cleanup if _prepare_exec_script() fails, but its try starts after:

self._ctx.install()

So an exception partway through install() itself can still leave process-global state partially modified.

Since CuratedProfilerContext is specifically becoming the authority for session setup/teardown, I would make the operation transactional now even if broader child-process failure containment stays in the next PR:

try:
    ...
except BaseException:
    self.cleanup(reason='failed profiler context installation')
    raise

There should be a fault-injection test that forces failure after at least one global patch and verifies complete restoration.

signatures.

- In contrast to the base class (:py:class:`Cleanup`), while
this context manager is still reentrant, reentering in nested

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 GPT 5.6 says:

3. The documented reentrancy of CuratedProfilerContext is broken

Its docstring says nested reentry of the same context is supported and that the nested entry is a no-op.

The implementation is:

def install(self):
    if self._installed:
        return
    ...
    self.patch(self, '_installed', True)

def __enter__(self):
    self.install()
    return self

def __exit__(self, *_, **__):
    self.uninstall()

Thus:

with ctx:
    with ctx:
        pass
    # ctx has already been uninstalled here

The inner __enter__() is a no-op, but the inner __exit__() is not a no-op. It removes all of the outer context's patches.

This should either have an acquisition depth, where only the outermost exit performs teardown, or reentrancy should be explicitly rejected and the documentation changed. Given that the base Cleanup already has stack concepts, depth-aware ownership seems preferable.

This deserves a direct nested-context test; the current doctests do not exercise it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I shouldn't have directly inherited from Cleanup as you mentioned earlier. That way we also don't have to be reentrant ;)

return wrapper


def apply(cleanup: Cleanup, prof: LineProfiler) -> None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 GPT 5.6 says:

4. The process-global threading patch silently belongs to whichever profiler installed it first

_threading_patches.apply() is:

if getattr(threading, _PATCHED_MARKER, False):
    return

start_wrapper = wrap_thread_start(prof, threading.Thread.start)
cleanup.patch(threading.Thread, 'start', start_wrapper)
cleanup.patch(threading, _PATCHED_MARKER, True)

The global Thread.start wrapper closes over exactly one prof.

If two distinct curated profiling contexts overlap:

  1. context A patches Thread.start, capturing profiler A;
  2. context B sees _PATCHED_MARKER and does nothing;
  3. threads started while both are active only inherit profiler A;
  4. if context A exits first, it restores Thread.start even though B remains active.

This is a global-resource ownership problem analogous to the sys.monitoring problem that this PR correctly fixed at the Cython layer.

I think the cleaner architecture is for the global thread hook not to belong to a particular LineProfiler at all. Install a single process-global hook which, at Thread.start(), snapshots the relevant active profiler/count state for that starting thread and arranges for those states to be activated in the child. Then separately manage installation ownership/reference counts.

If multiple concurrent curated sessions are intentionally unsupported, that constraint should instead be enforced explicitly. The current first-one-wins behavior is difficult to reason about.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now the patch is session-bound and it's the session's profiler instance that uses it. But we can probably do better by dynamically fetching the active profiler instances on the starting thread and syncing all of them.

May have to think harder about whether we want to cater to the overlapping context use-case though. Worst case scenario, it isn't as modular as we'd want it to be, and we just fall back to having ~.curated_profiling be private. But I'll see if I can do a cleaner fix.

@TTsangSC TTsangSC Sep 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On second thought syncing .enable_count on all LineProfiler instances (active on the starting thread) is probably not a good idea – we should probably only be doing that to profiler instances passed to the CuratedProfilingContext. Anyway, will think a bit more about having the multiple-profilers-per-context and multiple-contexts cases.

EDIT: most of the changes made by CuratedProfilerContext (as inherited from kernprof) already assume the existence of a "main profiler":

  • It is interpolated into the builtins namespace with the name 'profile'.
  • It is installed as the LineProfiler instance backing the public GlobalProfiler instance (line_profiler.profile).

So maybe it simply doesn't make sense for there to be more than one contexts and "main profilers" at any given time, which I think is probably okay as long as the limitation is well-documented. Meanwhile, I think ~._threading_patches can still be updated to accommodate multiple profiler instances.

Comment thread tests/test_threading.py
timings = stats.timings.get(code_to_timing_key(func.__code__), [])
return {lineno: nhits for lineno, nhits, _ in timings}.get(lineno, 0)


Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 GPT 5.6 says:

Important missing regression test: threading.settrace()

The commit fixing the CI/coverage interaction introduces the extra _bootstrap → run wrapping specifically because threading.settrace() installs the trace function inside _bootstrap_inner().

That code is marked # nocover, understandably because coverage itself participates in this mechanism. But I would still add an isolated subprocess regression that explicitly installs a threading.settrace() callback under the legacy backend and verifies both:

  1. the foreign callback runs; and
  2. line-profiler collects the expected statistics.

Otherwise the behavior that motivated wrap_thread_bootstrap() is largely being tested incidentally by running pytest under coverage rather than captured as an explicit contract.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah should've done that. Right now I do know that it works because that was what messed the first pipeline up (because coverage used threading.settrace()), but we should have an explicit test for it anyway.

@TTsangSC

Copy link
Copy Markdown
Collaborator Author

Thanks for all the comments! Will work on them when I'm back (am out in the city on an apartment hunt; wish me luck).

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants