Conversation
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
Codecov Report❌ Patch coverage is Additional details and impacted files@@ 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
Continue to review full report in Codecov by Harness.
🚀 New features to boost your workflow:
|
|
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 EDIT of course it's 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)
Erotemic
left a comment
There was a problem hiding this comment.
🧑🦱: 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
Threadsubclass that overridesrun(); - handles thread construction and starting on different threads;
- fixes the process-global lifetime of the
sys.monitoringregistration; - 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:
- descriptor-safe
Cleanup.patch()restoration /Thread.runcorruption; - exact restoration of
GlobalProfilerstate; - actual nested-context reentrancy;
- 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`). | |||
There was a problem hiding this comment.
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.
| mapping: MutableMapping[K, V], | ||
| updates: Mapping[K, V], | ||
| *, | ||
| _format_debug_msg: Callable[[Mapping[K, V], K, str], str] = ( |
There was a problem hiding this comment.
I would much rather define this default as a private named method somewhere than have a large lambda in a function signature.
| self, *, | ||
| delete: bool = True, | ||
| priority: float = 0, | ||
| _format_debug_msg: Callable[[Path], str] = ( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]) |
There was a problem hiding this comment.
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).
| ... | ||
|
|
||
|
|
||
| class Cleanup: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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...
| priority: float = 0, | ||
| ) -> None: | ||
| """ | ||
| Patch an attribute on an object. |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
🤖 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 = TrueTherefore teardown does:
_global_profiler._profile = None
_global_profiler.enabled = TrueThat 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 Noneand 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) == beforeIt would also be useful to actually decorate/call a function afterward.
There was a problem hiding this comment.
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')
raiseThere 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 |
There was a problem hiding this comment.
🤖 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 hereThe 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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
🤖 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:
- context A patches
Thread.start, capturing profiler A; - context B sees
_PATCHED_MARKERand does nothing; - threads started while both are active only inherit profiler A;
- if context A exits first, it restores
Thread.starteven 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
builtinsnamespace with the name'profile'. - It is installed as the
LineProfilerinstance backing the publicGlobalProfilerinstance (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.
| timings = stats.timings.get(code_to_timing_key(func.__code__), []) | ||
| return {lineno: nhits for lineno, nhits, _ in timings}.get(lineno, 0) | ||
|
|
||
|
|
There was a problem hiding this comment.
🤖 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:
- the foreign callback runs; and
- 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.
There was a problem hiding this comment.
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.
|
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 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)sys.monitoringcallbacks: now the callbacks themselves are process-global (see bac55fe), and theset[LineProfiler]passed to the C++-level inner tracing callback is thread-local, allowing for behavior that is (1) to-spec and (2) consistent across backendskernprofto make heavier use of context managers for setup and teardownline_profiler.LineStatsto allow for subtraction of a "baseline" value, to be used later when we handle profiling data from forked processesline_profiler._threading_patches:New module for
threadingpatches so that profiling continues seamlessly into child threadsline_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:sys.monitoringcallbacks are thread-local (they aren't); but see the following for change on top thereof.sys.monitoring.register_callback()are now instance methods on_SysMonitoringStateinstead of_LineProfilerManager, and_SysMonitoringState.active_instancesare now thread-local, allowing for thread-localLineProfiler.enable_countto be handled consistently._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:make_thread_init_wrapper()withwrap_thread_start()to prevent issues with delayed start.make_syncing_wrapper()to directly wrapThread._target, we now wrapThread._bootstrap()and.run()so that the patch is effective onThreadsubclasses with an overridden.run()method.${LINE_PROFILER_CORE}used; the previous behavior where it seemed to be gratuitous when usingsys.monitoringwas only an artifact of how the callbacks were erroneously handled (see above).SHOULD_PATCH_THREADINGis 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 ofinspectwith 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 onetest_child_thread_profiling_toggle_by_count():Where the
.enable_counthas been altered between thread creation and starting.test_child_thread_profiling_subclassed():Where a
Threadsubclass with an overridden.run()is used.test_child_thread_profiling_separate_creation_and_consumption():Where a
Threadobject 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 inline_profiler._line_profilerare not leakedMisc. fixes
kernprof.py::_pre_profile():Fixed bug where
sys.argvis replaced with a differentlist[str]object, thus preventingline_profiler.line_profiler_utils.restorefrom restoring its content.line_profiler/toml_config.py::ConfigSource.get_subconfig():Fixed bug where
copy=Truewas 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
pytestexited.TODO
In the following PRs:
line_profiler/_child_process_profiling_line_profiler_hooks.py.github/workflows/tests.yml,setup.py, andline_profiler/rc/line_profiler.tomltests/test_child_procsline_profiler._child_process_profilinginkernprof.pyPool._result_handlerand._worker_handlerthreads.multiprocessingserver processes:.resource_trackerprocess, try to refactor the setup so that is not run there to begin with..forkserverto the children themselves instead of the server.line_profilercomponents propagating to user-space and affecting the normal execution of the profiled code.kernprof --output-interval