Motivation
async def f():
x = g()
y = await h()
return x + y
def g():
breakpoint() # B1
do_effect()
return 1
async def h():
breakpoint() # B2
y = await j(3)
return y
async def j(x):
return x + 1
In the above code, breakpoints work. of course. But once you’re in a pdb
shell, there’s some trickiness. Though I’m testing async-heavy code, I can’t simply type something like await j(2)
into pdb
.
But, of course, I would like to be able to await a value inside the debug shell.
If I’m outside of the the event loop, I can get by with a call to asyncio.run
. But if I’m already in the event loop, I can’t call back into it, because event loops are not re-entrant!
So this leads to a very awkward scenario where breakpoint()
does give you inspection capabilities with async work, but you likely won’t be able to explore async results very well within a pdb
REPL!
I use REPLs in running systems all the time to try and work through issues, and as I work more and more with async
I’m finding myself not getting the full Python experience.
So, how could we get full support for working with awaitables in a pdb
shell?
Difficulties
(I’m focusing on CPython for implementation discussions here)
Let’s imagine I run:
asyncio.run(h())
I end up at the breakpoint B2
. How could I do something like await j(2)
?
I’m in an event loop, so I can’t call into the event loop again. The top frame is the coroutine (so, effectively for CPython, a generator). await j(2)
is, effective, yield from j(2)
.
So maybe all that is needed is a way to point at a frame and say “OK, you now are yielding from this value”.
Debuggers are already able to outright change the line number in a frame by setting f_lineno
, so “direct frame manipuation” is a debugging utility. But implementing yield from j(2)
would be a bit tricky.
-
One way to do it would involve outright having a special value on a frame, like “before moving forward, just yield from this”. This feels very weird. A variant on this idea would be to lean on
f_trace
or have a similar trace-like function that could pre-empt the actual bytecode execution on a frame. This more interesting to me becausef_trace
is already doing something like this. Af_patch
property that could replace the frame’s single stepping while it is set? -
Another idea that I don’t think really goes anywhere would involve being able to patch code objects to, effectively, insert some bytecode right after the breakpoint to “do the yield”. There’d be a lot of things you could do with that kind of capability but it also would be super error prone.
But all of what I said is in the case that my breakpoint was in a coroutine to begin with. In that case the caller to the coroutine expects a coroutine, can consume values yielded by the coroutine.
A harder thing:
asyncio.run(f())
with the above call, we enter f()
, and in there, call g()
and hit a breakpoint at B1. Now we have a problem. The top frame is not a coroutine, it’s just a function! We can’t yield values, and the caller wouldn’t understand values being yielded anyways!
In that scenario, how could we support await j(2)
? We really would want to use the event loop to execute j(2)
, but at the same time we can’t get back to the event loop without finishing the execution of g()
.
So either through a return
(going through do_effect()
in the process, or … jumping over do_effect()
? But that’s no longer a real debugging session), or raising an exception (tearing down a bunch of frames in the process). Obviously these are both immensely unsatisfying.
How can I have my cake and eat it too? How could I yield back to the event loop (hopefully to give it a chance ot deal with a future I care about), without me losing my valuable context?
A Proposal
Here is an idea for CPython to make this debugging story better. Please forgive the naming
-
Intro a new opcode,
CALL_SUSPEND
. It works likeCALL
, but before going into the caller’s frame, it first pushesTrue
to the stack (for reasons that will become clear). -
Intro another new opcode,
SUSPEND
. This opcode, when executed, does the following:- traverses back into the stack frames, looking for a frame currently in
CALL_SUSPEND
. If no frame exists in that state, an exception is raised. - Pops the
True
currently on the top of theCALL_SUSPEND
stack, and the pushesFalse
, then the current frame onto the stack. - Sets the current frame to the
CALL_SUSPEND
frame.
- traverses back into the stack frames, looking for a frame currently in
-
Intro a final opcode,
RESUME_SUSPEND
. It expects a frame to be on the stack. LikeCALL_SUSPEND
it will first pushTrue
on the stack. It then sets the frame on the stack to be the current frame.
In practice, what does that mean?
CALL_SUSPEND
ing a function that executes normally, will end up with[True, retval]
on the stack. The first value indicates that the call completed. The second is the return valueCALL_SUSPEND
ing a function thatSUSPEND
s leaves you with[False, frame]
on the stack. The first value indicates that the call did not complete/was suspended. The second value is the frame that was suspended.RESUME_SUSPEND
just sets the frame back in place. ButSUSPEND
could be called again, so we should still prep the stack in the same way, to be able to tell if suspensions happened.
On top of these opcodes, asyncio
could provide a function, suspend
, that just does SUSPEND
:
asyncio.suspend() # frame gets suspended
We would then have call_with_suspension
, as an interface to CALL_SUSPEND
:
result = asyncio.call_with_suspension(func, *args, **kwargs)
result
could either be Completed(done=True, result=retval)
, or Suspended(done=False, frame=frame)
.
And from there, we could also have resume_suspense(suspense)
result = asyncio.resume_suspension(suspense)
(Passing in the Suspended
object instead of the frame directly feels useful for “disincentivising weird programs” reasons)
If an event loop slightly modifies its callback handling to use call_with_suspension
/resume_suspension
(which, a bit awkwardly, kind of looks like generators…), and a debugger smartly uses create_task
and friends, then pdb
could support the following kind of flow:
- you hit a breakpoint
- you
await j(2)
(even in a sync frame!), which would create the coroutine fromj(2)
, create a task from it, hold onto a reference to the task, thenSUSPEND
- the event loop gets control, probably schedules your in-progress task to later, handles other tasks (including
j(2)
) - if
RESUME_SUSPEND
is called, the debugging frame is brought back into existence. the trace function kicks in, and the debugger could then check the state ofj(2)
. If it’s not done yet,SUSPEND
again! Since you’re a debugger you could probably be a bit smart with timeouts or the like. - If
j(2)
is complete, of course then the value could be inspected as you would expect
So concretely, your debugger code could look like the following:
def do_await_command(expr):
"""
(pdb) await j(2)
-> do_await_command("j(2)")
"""
coro = eval_in_frame(expr, current_frame)
task = asyncio.create_task(coro)
while not task.done():
# maybe put some timeout logic here
asyncio.suspend()
# do what you want to do with the task
print(task.result())
Of course the above is a very simplified version, but I think it holds the core.
Objections
There are many objections, to this idea, of course.
- This gives continuations!
This is obviously super powerful for doing really weird control flow. Python’s generator story (and coroutine story!) has a static quality to it. A function body determines if it can be suspended (via async
or presence of yield
). This provides an arbitrary suspension point that totally breaks the idea that calling f()
calls f
to completion.
I think it would be good present this as a debugging utility (in the same way that f_lineno
exists but to my knowledge isn’t used to write weird libraries). But people will try weird stuff
- This is too many extra parts for one thing!
This is 3 opcodes to be able to call await
in pdb
. I think it’s super important to be able to call await
in pdb
(or, more concretely, to be able to wait for a task to complete). And I think this sort of functionality would likely unblock other ideas in debug tooling.
- This is not at all detailed enough to provide a good impression of the work involved! I need a POC!
I have spent a while looking over parts of CPython to try and figure this out, and I hope this is a detailed enough sketch to make people at least have an opinion. But I have no idea how this would play with things like the JIT work.
Conclusion
I want to have a better debugging story for seeing async task results. I think if we have a way to suspend a frame (including sync frames!), then debuggers could use that infrastructure to provide a good debugging story.
But I do not hold a preference to how one can get better debugging. I just think better debugging is very important, and I did not successfully find prior art.