-
-
Notifications
You must be signed in to change notification settings - Fork 335
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Elide task.context.run() and contextvars.callable() frames from tracebacks #631
Conversation
Codecov Report
@@ Coverage Diff @@
## master #631 +/- ##
==========================================
+ Coverage 99.28% 99.28% +<.01%
==========================================
Files 91 91
Lines 10763 10781 +18
Branches 768 770 +2
==========================================
+ Hits 10686 10704 +18
Misses 58 58
Partials 19 19
Continue to review full report at Codecov.
|
trio/_core/_run.py
Outdated
final_result = Error(task_exc) | ||
# Store for later, removing uninteresting top frames: | ||
# 1. task.context.run() | ||
# 2. contextvars.callable() (< Python 3.7 only) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Huh, I thought the top two frames were trio._core._run.run_impl
and (on <3.7 only) contextvars.Context.run
. Do you know where the discrepancy comes from? am I confused? :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
here's what you wrote in #56:
File "/.../site_packages/trio/_core/_run.py", line 1374, in run_impl
msg = task.context.run(task.coro.send, next_send)
This is noise... when we catch a user exception in run_impl, we should unconditionally discard the top frame in the traceback (which will be this one).
File "/.../site-packages/contextvars/__init__.py", line 38, in run
return callable(*args, **kwargs)
This line will disappear on 3.7, where contextvars.Context.run is implemented in C, so maybe not worth worrying about. Or we could discard it at the same time we fix the run_impl frame above.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see, I'm enumerating the child functions rather than the parents.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ahhhh yeah, right, so we're just looking at different lines in the traceback :-). that makes sense!
For a test I guess it'd work do so something like: async def my_child_task():
raise KeyError()
async def test_frame_removal():
try:
# Trick: For now cancel/nursery scopes still leave a bunch of tb gunk behind.
# But if there's a MultiError, they leave it on the MultiError, which lets us get
# a clean look at the KeyError itself. Someday I guess this will always be
# a MultiError (#611), but for now we can force it by raising two exceptions.
async with trio.open_nursery() as nursery:
nursery.start_soon(my_child_task)
nursery.start_soon(my_child_task)
except trio.MultiError as exc:
assert isinstance(exc.exceptions[0], KeyError)
# The top frame in the exception traceback should be inside the child task,
# not trio/contextvars internals. And there's only one frame inside the child task,
# so this will also detect if our frame-removal is too eager.
assert exc.exceptions[0].__traceback__.tb_frame.f_code is my_child_task.__code__ |
Thanks for the test guidance. It almost works, but appears to be testing the frame at the wrong end of the trace. Investigating... |
...but there should only be 1 frame in the trace...? |
( |
The first exception of the MultiError is as follows: File "/Users/john/dev/trio/trio/_core/_run.py", line 201, in open_cancel_scope
yield scope
File "/Users/john/dev/trio/trio/_core/_run.py", line 319, in __aexit__
await self._nursery._nested_child_finished(exc)
File "/Users/john/dev/trio/trio/_core/_run.py", line 425, in _nested_child_finished
raise MultiError(self._pending_excs)
File "/Users/john/dev/trio/trio/_core/tests/test_run.py", line 1903, in my_child_task
raise KeyError() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The first exception of the MultiError is as follows:
Oh, that's unfortunate. (And confusing!)
It wouldn't be the end of the world if we had to wait until after we did the next step of #55 (which will clean up those frames you quoted). It'll be much easier to write a test to make sure the tracebacks contain exactly the frames we expect, instead of writing a test to assert particular frames are missing.
trio/_core/_run.py
Outdated
# 1. trio._core._run.run_impl() | ||
# 2. contextvars.Context.run() (< Python 3.7 only) | ||
tb_next = task_exc.__traceback__.tb_next | ||
if sys.version_info < (3, 7): # pragma: no cover |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pragma: no cover
is for cases where we don't even want to have test coverage. Here, not only is it useful to have tests that cover both branches of the if
, we actually have those tests :-). (We test on multiple python versions and combine the coverage.) So we shouldn't use pragma: no cover
here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh that's good-- I assumed the coverage testing was looking at each version run individually rather than the aggregate.
trio/_core/tests/test_run.py
Outdated
assert isinstance(first_exc, KeyError) | ||
tb_text = ''.join(traceback.format_tb(first_exc.__traceback__)) | ||
for r in ('/trio/_core/.* in run_impl$', '/contextvars/.* in run$'): | ||
assert not re.search(r, tb_text, re.MULTILINE) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you're doing it this way, then I think you can simplify to just
try:
async with trio.open_nursery() as nursery:
nursery.start_soon(my_child_task)
except KeyError as exc:
...
(Of course that will then need adjusting when we're working on #611, but everything will, so I wouldn't worry about that.)
this is less fragile, e.g. should python 3.7 contextvars be backported, or should trio core frames vary by code path
I mentioned this a bit in chat, but let me say it again here with more carefully collected thoughts :-). So the traceback frames that are relevant for this PR are:
At one point I was confused and thought that this was also attempting to address the frames that the nursery aexit machinery is adding, but that was wrong, it can't and doesn't affect those either way. It's just about the frames mentioned above. So some options include:
These all seem like viable options, with slightly different complexity/reliability tradeoffs. The current PR has a different approach, based on calling
So I don't think we should be checking filenames here. On an unrelated note, the test isn't actually testing anything right now, I think? Like, it passes on current master, right, regardless of this change? If we want to test this change specifically we could iterate over the traceback and assert that |
change test to confirm frame count and bottom-most watermark frame
@njsmith ready to go-- thanks for your review and patience |
Thank you for your patience :-) |
This is kind of ridiculous overkill, but I was thinking about how you could do it, then wrote some code to experiment, then realized that I had implemented it so we might as well use it... See also: python-trio#631
Count how many frames Context.run adds to tracebacks and use this info to elide frames in run_impl(). See also: #631
for #56
TODO: