fix(tracing): release the trace scope when a generator is closed - #4221
Conversation
NoOpTrace.__exit__ always reset the current-trace context token, unlike TraceImpl, ReattachedTrace, SpanImpl and NoOpSpan, which all skip the reset when the block unwinds with GeneratorExit. asyncio finalizes an abandoned async generator from its own task, so the generator body resumes in a context that never set the token and the reset raises ValueError. Runs with tracing disabled now behave the same as runs with tracing enabled.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 731abaa2bb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Skipping the reset whenever GeneratorExit is in flight also skips it for a generator closed from the same task, where the token is still valid. That leaves the finished trace current, so a later runner-created trace sees an active trace and never exports a real one. Attempt the reset and swallow only the ValueError that a foreign context raises. This also fixes ReattachedTrace and TraceImpl, which already had the unconditional skip before this branch.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 83506d4a00
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The branch stopped skipping the reset on GeneratorExit two commits ago, so the old test name no longer described the behavior. Split it into the two cases the change actually covers: a same-task close, which must release the scope and which regressed on main for TraceImpl and ReattachedTrace, and a cross-task close, which cannot reset and must at least not raise.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 01493d715a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
seratch
left a comment
There was a problem hiding this comment.
The two generator-close defects are valid, and the trace-only scope is reasonable. Before merging, please scope the ValueError suppression to GeneratorExit-driven __exit__ cleanup. The current helper is used by all three finish(reset_current=True) implementations, so an explicit finish from the wrong context now silently discards the saved token instead of exposing the context-ownership violation.
Please add a regression showing that a non-GeneratorExit foreign-context finish still raises, include ReattachedTrace in the same-task and cross-task test matrix (or stop changing it), and clear the caller context after the cross-task test. The unavoidable caller-context residue can remain documented, and the analogous span behavior can be handled separately. After these focused changes, this should be ready for another review.
The shared helper swallowed ValueError for every finish(reset_current=True) caller, so an explicit finish from a context that never set the token silently discarded it instead of surfacing the ownership violation. finish now resets strictly again, and only __exit__ tolerates the failure, and only when GeneratorExit is unwinding the block, which is the case that cannot succeed. Tests add a foreign-context finish regression, cover ReattachedTrace alongside NoOpTrace and TraceImpl in both generator matrices, and clear the caller context the cross-task close leaves behind.
|
All four points addressed in e245662. Scoping the suppression. You are right that the shared helper was too broad. It sat inside
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type is GeneratorExit:
_finish_on_generator_exit(self)
else:
self.finish(reset_current=True)
Foreign-context regression. ReattachedTrace. Added to the matrix rather than dropped. Both generator tests and the new one now run over Cross-task residue. The cross-task test now clears the caller context at the end, with a comment saying the residue is the documented consequence of finalizing from another task rather than something the test is asserting away. Verification: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e245662446
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
Codex posted one more P2 here a few minutes after this merged, and it is correct, so flagging it since the defect is now on main. My def finish(self, reset_current: bool = False):
if not self._started:
return
self._processor.on_trace_end(self) # can raise
if reset_current and self._prev_context_token is not None:
Scope.reset_current_trace(self._prev_context_token)
self._prev_context_token = NoneSo a processor whose I have the fix on trace.finish(reset_current=False)
token = trace._prev_context_token
if token is None:
return
trace._prev_context_token = None
try:
Scope.reset_current_trace(token)
except ValueError:
logger.debug("Skipping trace context reset, token belongs to another context")
Happy to open that as a PR, or if you would rather fold it in differently just say so. |
|
Yeah, fixing it as well would be appreciated! |
|
Thanks, will do. The branch is |
|
Opened as #4232. |
Closing an async generator that has a
with trace(...)block open behaves differently depending on whether tracing is enabled and on which task runs the close. Measured onmain, advancing the generator once and then closing it:aclose()resultNoOpTraceNoOpTraceValueErrorTraceImplTraceImplTwo separate defects sit in that table.
The first is the crash.
NoOpTrace.finishcallsScope.reset_current_traceunconditionally, and aTokenis only valid in theContextthat created it. asyncio finalizes an abandoned async generator from whichever task happens to run itsaclose, so the body resumes in a context that never set the token andContextVar.resetraisesValueError: <Token ...> was created in a different Context. Disabling tracing should not turn a working teardown into an exception.The second is a context leak.
TraceImpl.__exit__andReattachedTrace.__exit__passedreset_current=exc_type is not GeneratorExit, so they skipped the reset for everyGeneratorExit, including the ordinary same-taskclose()where the token is perfectly valid. The ended trace stays current for the rest of the caller's scope and later traces attach under it.This replaces the blanket skip with a
_reset_current_tracehelper that resets and swallows only the cross-contextValueError. Same-task closes now restore the caller's trace for all three trace types, and cross-task closes no longer raise. What this cannot do is repair the caller's context from the finalizing task, because that task cannot rewrite anotherContext. The remaining leak on the cross-task rows above is therefore unchanged, and the helper's docstring says so rather than pretending the reset succeeded.Tests in
tests/tracing/test_traces_impl.pyare parametrized overNoOpTraceandTraceImpland cover both closes. Onmainthe same-task case fails forTraceImpland the cross-task case fails forNoOpTrace, which is exactly the two-defect split above.make lint,make typecheck, and the 180 tests acrosstests/tracing/,tests/test_tracing.py,tests/test_trace_processor.py,tests/test_agent_tracing.py,tests/test_tracing_errors.pyandtests/test_tracing_errors_streamed.pyare green.Note that
SpanImpl.__exit__andNoOpSpan.__exit__still carry the sameGeneratorExitskip and so still have the second defect at span level. That is left out of this change deliberately to keep the diff to one lifecycle type, and it is easy to follow up on.This is a refile of #3225, which was closed in an inactivity sweep with an invitation to revisit.