-
Notifications
You must be signed in to change notification settings - Fork 1.4k
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
async <py-script> tags are blocking the execution flow #751
Comments
I stumbled across this issue the other day, but convinced myself it was an issue with my environment. Glad to see I wasn't loosing my marbles. Especially since this issue isn't present in the 2022.06.1 release. I believe you are correct in identifying source of the issue - doing a loader?.log('Initializing scripts...');
if (mode_ == 'play') {
for (const script of scriptsQueue_) {
- script.evaluate();
+ await script.evaluate();
}
scriptsQueue.set([]);
} I believe this is simply a syntax issue - we should be using for await ... of instead of ...
for await (const script of scriptsQueue_) {
script.evaluate();
}
... This gives what I think is the correct behavior: Python with top-level-await does not block, but synchronous Python code does. That said, maybe that's not the desired behavior in PyScript. For example, if your example was preceded by another |
woooh, thanks for identifying the issue! It's ironic that the bug was introduced by PR which says "minor changes" 😂.
If I understand it correctly, it would start all async blocks together, and then wait until they all complete before moving on, is it correct?
+1 for matching the behavior of |
I may actually have made it more complicated than it needs to be. I think we can do: for (const script of scriptsQueue_) {
script.evaluate(); Because Unfortunately, this breaks any integration test which relies on For the sake of the tests, I think may need a better method of detecting when all py-script tags have finished evaluating. There was an example in the Nucleus PyScript forum, but I think it assumes all scripts are synchronous. [1] |
@antocuni @JeffersGlass I was following your discussion regarding the py-script tags closely, and have now verified that the correction above in file runtime.ts (that is, there should be no 'await' before 'script.evaluate') is indeed correct, and fixes BOTH the above issue AND issue #678 (the fact that is fixes #678 may be a little surprising):
The reason also became clearly after reading up a little bit on Mozilla Developer Network (MDN) - the await WILL BLOCK all following code in the function is it used in, but WILL NOT BLOCK the calling function one level above. So putting an await here means that all following scripts in the same loop are blocked (which we dont want), while having the await at a much lower level (for example: await runtime.run) means that the following scripts in the loop, AND higher-level calling functions, can execute. In particular, all the messages on screen are displayed AND cleared without getting blocked (the infinite loop in the WebGL Python code is reeally no different from the infinite loops on a Canvas element in HTML games, and whatever happens code executes before this loop actually starts will complete successfully).
A 0 This is because the line "await asyncio.sleep(0.1)" functions EXACTLY like a blocking await used in a Javascript loop.
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function async_await_nonblocking_javascript_twice.zip |
I have made a set of Pyscript examples to compare the behavior of Python's asyncio's async/await with that of Javascript's async/await. The behavior of Python's version of async/await is generally similar to that of Javascript's - namely, where you put the await determines whether it will block the loop or not. There is however one key difference in Python/Pyscript's behavior, as shown in example 4. Firstly, to see the behavior of these Pyscript examples correctly, the most important thing is that the correction pointed out by @JeffersGlass (remove the await before script.evaluate() in runtime.ts) HAS to be made (which I have done locally). Secondly, the examples are basically just variations of the example given by @antocuni above, but within HTML files that can just be placed in the same location as other local Pyscript examples (that is, in pyscript/pyscriptjs/examples) and the web pages viewed either on localhost (through npm run dev), or just by double-clicking on them (possible on both Chrome and Firefox for these simple pages). There are four examples:
Pyodide does tell us that - RuntimeWarning: coroutine 'asyncCall1' was never awaited - and similarly for asyncCall2.
Surprisingly, B0 is output before A0 (and consequently B2 before A2, and B3 before A3)! What this tells us is that in Python's asyncio, if we dont await an async coroutine, it doesn't even wait for the coroutine to reach the asyncio.await call inside it, it just goes ahead with the code (second loop) that follows the call to the coroutine (first loop). For comparison, Javascript will only hand back control like this when actually executing the await. I am attaching the above four Pyscript asyncio examples (HTML files) as a single zip, and thought they might be helpful to illustrate Pyscript's behavior in the different cases (and did learn a lot about Python's asyncio in this process, not having used it before). async_await_pyscript_twice_examples.zip The outputs in the various cases can then be seen in the console log. |
@sumahadevan This is great work! And really quite interesting as a set of use cases and test cases. I do wonder if part of the behavior we're seeing in that last example ( That said, it does seem that this behavior is at least consistent between PyScript (with the modification you mention) and Pyodide, as the following code (which runs Python in Pyodide directly) gives the same output as your example 4: //Tested with Pyodide 0.21.2
async function main() {
var pyodide = await loadPyodide();
pyodide.runPythonAsync(`
import js
import asyncio
async def asyncCallLoop1():
for i in range(3):
js.console.log('A', i)
await asyncio.sleep(2)
asyncCallLoop1()
`);
pyodide.runPythonAsync(`
import js
import asyncio
for i in range(3):
js.console.log('B', i)
await asyncio.sleep(2)
`);
};
main(); Output:
|
@JeffersGlass I finally got round to checking the corresponding async/await behavior of asyncio in Python itself, and I need to make some important corrections to what I had said earlier regarding the four Pyscript examples.
` #!/usr/bin/env python3 async_await_non_blockingloop_python_twice.pyimport asyncio async def asyncCallLoop1(): async def asyncScript1(): await asyncCallLoop1() async def asyncScript2(): for i in range(3): async def main(): await asyncScript1()await asyncScript2()await asyncio.gather(asyncScript1(), asyncScript2()) asyncio.run(main()) ` It doesn't matter how the code is structured, as long as we use 'gather' this will produce interleaved output exactly as in 2) above.
In sum, Pyodide (and hence Pyscript) behaves differently and is more lax is flagging missing awaits in asyncio async/await code, as compared to standard Python. This could be considered as a possible issue (in the semantics of Pyodide's implementation of the Python interpreter). |
The lines in bold in the Python program above were actually Python comments - though I guess you would have figured that out. |
Also, the Python indentation has been lost (though the code is straightforward). |
Since PyScript 2022.09.1 has reached release candidate 1, I think we should open a small PR that makes the small change in loader?.log('Initializing scripts...');
if (mode_ == 'play') {
for (const script of scriptsQueue_) {
+ script.evaluate();
- await script.evaluate();
}
scriptsQueue.set([]);
} Clearly, we have some more work to do here to come to a fully correct solution, but without this change top-level-await scripts in PyScript 2022.09.1 do not work at all, which would be a significant regression from 2022.06.1. I think we should make the change for 2022.06.1 and continue working on improvements from there. Thoughts? |
I would agree that making this change would at least partially fix the problem with the top-level await scripts, and I just saw that a 2022.09 release is out for testing, so getting this into the release would be good. @JeffersGlass would you be doing the PR (or do you want me to do it)? @antocuni your view on making this change? After this change, the two async py-script tags do correctly produce their output in alternation as was initially expected by you. |
I am testing an approach using Promise.all() like so: const scriptsPromises = []
for (const script of scriptsQueue_) {
scriptsPromises.push( script.evaluate() );
}
await Promise.all(scriptsPromises);
scriptsQueue.set([]); Locally, all tests pass and all examples work including the case provided by @antocuni and @sumahadevan Branch: https://github.com/pyscript/pyscript/tree/promise-all-to-enable-root-async Please test the changes locally and post feedback. Thanks. |
@tedpatrick One hangup there is that async Python scripts that never terminate don't allow PyScript to finish initializing nor the loader to close: <py-script>
import asyncio
from itertools import count
for i in count():
print(f"Count: {i}")
await asyncio.sleep(1)
</py-script> Removing the My thinking is: |
I tested out this solution using void. This properly handles the Promise returned from script.evaluate(). for (const script of scriptsQueue_) {
void script.evaluate();
} |
@tedpatrick will check this locally on my machine - the link to your branch seems broken (though I can make the same changes myself). I wanted to add a small wrinkle to the above discussion. In base,ts, the routine "async evaluate()" returns a void promise (but a PROMISE nonetheless). The issue is that in runtime.ts, this is called DIRECTLY from the loop and awaited there. Let us suppose there are good reasons to AWAIT the async routine evaluate() in order to do something after that. How should we do this? The correct solution is to define an async routine, say "HandleScriptEvaluate(script)", that internally calls the async routine evaluate() and awaits its completion (in order to do something (say log that it is done). This intermediate routine is called from the loop in runtime,ts, which doesnt await anything at all. Doing this is beneficial due to the way await works in Javascript (affects the awaiting routine but not those calling it). My main point is that we can still usefully await the promise returned by "async evaluate", but do it one level lower than the loop in a separate routine (thus having our cake and eating it too). Of course, I still have to check that this does work as expected. One more thing that I will check is whether your solution resolves #678 - in that case there is only a single Python script that loops infinitely, so awaiting it causes later Javascript code that clears messages on the web page to never get reached/executed (if one doesnt await at all then of course the Javascript code executes). |
I believe we have 2 conflicting issues.
I believe we need to await all calls to const scriptPromises = [];
for (const script of scriptsQueue_) {
scriptPromises.push( script.evaluate() );
}
await Promise.all(scriptPromises);
scriptsQueue.set([]); This results in very accurate loading behavior on all but 2 examples: Endless loop and root async await. These 2 examples are related 100% to the loader closing but there is nothing wrong with them logically. This example was provided by @JeffersGlass <!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Async Await BLOCKING LOOP Pyscript Twice</title>
<link rel="stylesheet" href="https://pyscript.net/latest/pyscript.css" />
<script defer src="https://pyscript.net/latest/pyscript.js"></script>
</head>
<body>
<style>
py-loader {
display:none
}
</style>
<py-script>
import asyncio
from itertools import count
for i in count():
print(f"Count: {i}")
await asyncio.sleep(1)
</py-script>
</body>
</html> The other example is the webgl/raycaster. Both work perfectly with py-loader set to The py-loader can be easily hidden as follows using css. <style>py-loader{ display:none; }</style> This is not ideal either but downstream we can make the loader event aware so endless loops or root await that never return can be handled with an api. # futures
emit( "py-loader.close" ) # this global will emit an event to close the py-loader anywhere within an application. I want to support the endless looping use case but we are not yet ready with a programmatic loader for these cases. Ideally, we can get this into the 2022.10.1 release, and better support these use cases. A PR for Review: #796 |
@sumahadevan I would love to see a proof-of-concept of this, as I don't think I fully understand. But I'd love to play with it. I would also point out, as you continue your investigations (thank you again for your diligent and detailed work!) that So it's probably worth distinguishing behaviors of "Python" vs. "Python inside Pyodide" for maximum clarity, as they aren't necessarily the same. |
Some whys... Why do we Why do we need to know it is done? Some scripts are never done. Why is At the root, the loader is the problem. We are waiting to remove the loader at a more appropriate time when scripts have completed execution vs when scripts have started. Although the loader being tied to the script(s) completion is a closer approximation of loader accuracy, it causes problems for scripts that never end. In this light, we should Also key is @JeffersGlass point that the downstream logic never is executed if we Let's ship |
PR is updated to |
@tedpatrick your explanation of why we need to await in the code (for processing all scripts) is very comprehensive, and it also clarifies the design of the code (and also highlights the important point that the loader issues need to be resolved separately and not by dropping the await). This definitely looks like the best possible solution (leaving aside the loader issues for a separate resolution). @JeffersGlass I will do a small POC of the code restructuring I was suggesting - as emphasized above, this code will STILL AWAIT, but I was thinking that a wrapper function around script.evaluate() might be helpful (will try this minor change out, but only once the above PR has been merged - the main purpose of the wrapper would be to output messages about the status of the script being evaluated). And yes, in any further investigation of Pyodide/Python asyncio scripts, I will definitely keep in mind the differing behavior of the Pyodide asyncio vs Python asyncio (to further confuse me, they both behave quite differently from Javascript's async/await which is what I have been used to)! |
I'm not 100% sure of this one. Do we really want to support "implicit endless loops"? They look very weird to my python eyes.
In this way, the endless loop pattern becomes something like this: <py-script>
import itertools, asyncio
async def my_loop():
for i in itertools.count(0):
print(i)
await asyncio.sleep(1)
run_soon(my_loop)
</py-script> Advantages: the top-level scripts are always synchronous, their execution order is easy to predict and we have a clear separation between "preparation logic" (done at top level) and "event-driven logic" (which is the endless part).
Assuming that we want to allow endless top-level loop (see above), I agree. |
Consider the following case:
I would expect it to print
A
andB
lines alternatively, i.e.:However, pyscript
await
s the end of each code tag before commencing to execute the next, so the output isA0 A1 A2 B0 B1 B2
. I think the culprit is theawait
in the following line:pyscript/pyscriptjs/src/runtime.ts
Lines 161 to 165 in 4694602
My gut feeling is that pyscript should probably start all scripts together and then
asyncio.wait()
for all of them at the end (but we need to check how this interacts with the JS event loop).But also, I wonder whether this has any unintended and/or surprising consequences.
Related issues:
sync
by default and require the user to explicitly opt-in forasync
semanticsThe text was updated successfully, but these errors were encountered: