Repository: pyscript/pyscript

Issue:
PyodideRuntime.run should not `await`
I noticed this while looking at the source code.

`Runtime.run` is marked async:
https://github.com/pyscript/pyscript/blob/aa85f5f5964e61f47b77cac73511d3d74c63328d/pyscriptjs/src/runtime.ts#L52-L57

But then when we call pyodide we do the following:
https://github.com/pyscript/pyscript/blob/aa85f5f5964e61f47b77cac73511d3d74c63328d/pyscriptjs/src/pyodide.ts#L69-L71

Note the `return await`, which should probably be just a `return`. I'm not fully sure of the implications of doing the `await` here but I guess it causes code to be less parallel that it should be.

I *think* that it means that if we do the following:
```typescript
await Promise.all([
    runtime.run(src1),
    runtime.run(src2),
    runtime.run(src3)
]);
```

src1, src2 and src3 will not be executed in parallel but sequentially.

In general, I think we have a problem with our usage of async in our codebase: we use them a bit too freely and without really understanding what's going on (or at least: *personally* I don't fully understand what's going on: if you do, please explain to me :)).



Discussion:
@antocuni I would agree with you on either doing `return` or `return void` in pyodide.ts there. I don't think there's a need to await runPythonAsync, since we don't need to allow the promise is returns to resolve before moving on to other tasks, since we don't do anything with the value of that

I am also on the boat of not really understanding what's going on with our async usage in the codebase. I think it would be an interesting (if time consuming) exercise to chart out the chain of calls from `globalApp.main();` down to `runPythonAsync` to see what's currently being await'd and what's not, since we do seem to sprinkling `awaits` liberally throughout.


> I am also on the boat of not really understanding what's going on with our async usage in the codebase. I think it would be an interesting (if time consuming) exercise to chart out the chain of calls from `globalApp.main();` down to `runPythonAsync` to see what's currently being await'd and what's not, since we do seem to sprinkling `awaits` liberally throughout.

I agree.
I think it would be saner to completely *remove* all support for running async python code, make sure that we run sync everywhere, and then re-add python-async support by modifying only the places which are necessary.
Semi-related, I'm still not sure that it's a good idea to allow top-level async in our `py-script` tag, because at least to me it's not completely clear what is the semantics (and also, if the JS people decided *not* to allow top-level async, they probably have their reasons).

For example I imagine that we could have a `<py-task>` tag which automatically does `asyncio.create_task`, which IMHO it's much clearer in what it does.
> I think it would be saner to completely remove all support for running async python code
> I'm still not sure that it's a good idea to allow top-level async in our py-script tag,

I think these are both related, and I agree with them both. If we make one change (and probably unwind some other `awaits`):

```diff
#pyodide.ts 70:1
-  return await this.interpreter.runPythonAsync(code);
+  return this.interpreter.runPython(code);
```

Then as far as the typescript side of PyScript is concerned, execution of the Python code is synchronous, and that'll make it a lot easier for us to reason about.

And the only thing we lose is the ability to use top-level-await/[implicit async](https://jeff.glass/post/pyscript-asyncio/#implicitasync). Which I agree is an oddity coming from Pyodide, and not something we necessarily need to replicate everywhere. So this would no longer be possible generally:

```python
<py-script>
  from js import console
  import asyncio

  for a in range(5):
    console.log(f"A {a}")
    await asyncio.sleep(1) #top-level await
</py-script>

<py-script>
  for b in range(5):
    console.log(f"B {b}")
    await asyncio.sleep(1) #top-level await
</py-script>
```

BUT this still works, and achieves the same thing:

```python
<py-script>
  from js import console
  import asyncio

  async def count_a():
    for a in range(5):
      console.log(f"A {a}")
      await asyncio.sleep(1)

  async def count_b():
    for b in range(5):
      console.log(f"B {b}")
      await asyncio.sleep(1)
  
  PyScript.loop.create_task(count_a())
  PyScript.loop.create_task(count_b())
</py-script>
```

We may still have to think through the implications for async code for testing, and per your and @sumahadevan's investigations in #751, I personally suspect there's some subtle differences between the standard asyncio event loop and the Pyodide asyncio loop. But that I think is a separate different project.
> I think these are both related, and I agree with them both. If we make one change (and probably unwind some other `awaits`):
> 
> ```diff
> #pyodide.ts 70:1
> -  return await this.interpreter.runPythonAsync(code);
> +  return this.interpreter.runPython(code);
> ```
> 
> Then as far as the typescript side of PyScript is concerned, execution of the Python code is synchronous, and that'll make it a lot easier for us to reason about.

+1

> And the only thing we lose is the ability to use top-level-await/[implicit async](https://jeff.glass/post/pyscript-asyncio/#implicitasync). Which I agree is an oddity coming from Pyodide, and not something we necessarily need to replicate everywhere. So this would no longer be possible generally:
> 
> ```python
> <py-script>
>   from js import console
>   import asyncio
> 
>   for a in range(5):
>     console.log(f"A {a}")
>     await asyncio.sleep(1) #top-level await
> </py-script>
> 
> <py-script>
>   for b in range(5):
>     console.log(f"B {b}")
>     await asyncio.sleep(1) #top-level await
> </py-script>
> ```
> 
> BUT this still works, and achieves the same thing:
> 
> ```python
> <py-script>
>   from js import console
>   import asyncio
> 
>   async def count_a():
>     for a in range(5):
>       console.log(f"A {a}")
>       await asyncio.sleep(1)
> 
>   async def count_b():
>     for b in range(5):
>       console.log(f"B {b}")
>       await asyncio.sleep(1)
>   
>   PyScript.loop.create_task(count_a())
>   PyScript.loop.create_task(count_b())
> </py-script>
> ```

yes exactly, that's precisely my point. Top level async might sound tempting, but I fear it comes with too many problems for us and ultimately undermines the reliability/stability of the whole codebase.

I'm not even sure that the your two examples are *exactly* the same: does pyodide automatically create a task when it sees a top-level await? And if not, what does it do exactly?
Personally I don't really know :man_shrugging: 

> We may still have to think through the implications for async code for testing, and per your and @sumahadevan's investigations in #751, I personally suspect there's some subtle differences between the standard asyncio event loop and the Pyodide asyncio loop. But that I think is a separate different project.

yes, IMHO and we should understand it fully before making a mess :).
Tagging @tedpatrick and @fpliger , since they might have opinions.

This is probably related to the semantics what we want for webworkers. For example, Ted proposed something like this:
```html
<py-script># run code sync in the main thread</py-script>
<py-script worker="xxx"># run code in a webworker</py-script>
<py-script async>#run code async in the mainthread</py-scrip>
```

`<py-script async>` looks very similar to the `<py-task>` I proposed above

FYI, just `return` and `return await` are almost the same, so the example works in parallel.

A runnable example:

```javascript
async function run(x) {
  return await new Promise((resolve) => { // await!
    console.log(`begin ${x}`);
    setTimeout(() => {
        console.log(`end ${x}`);
        resolve();
    }, x * 1000);
  });
}

Promise.all([
  run(1),
  run(2),
  run(3),
]);
```

```
begin 1
begin 2
begin 3
end 1
end 2
end 3
```
> FYI, just `return` and `return await` are almost the same, so the example works in parallel.

uhm, I have a vague understanding but I think that I'm missing something. Could you explain what is the exact difference between the two then?
The main difference is error handling. The following article may be helpful.
https://dmitripavlutin.com/return-await-promise-javascript/
> I'm not even sure that the your two examples are exactly the same: does pyodide automatically create a task when it sees a top-level await? And if not, what does it do exactly?

This I can speak to, since I got fairly deep in the weeds for [a recent blog post](https://jeff.glass/post/pyscript-asyncio/).

Edit: I should say - this  is all just from digging through the Pyodide codebase. I would be more than happy to be wrong and learn something new.

## SHORT ANSWER

The short answer is yes - when we run `runPythonAsync("await someCoroutine()")`, the coroutine is wrapped in a Task, and automatically scheduled into the browser event loop via `setTimeout(Task, 0)`. That Task is proxied as a Promise on the JS side, which can be awaited, used with `then()` syntax, etc like any other JS promise.

## MEDIUM ANSWER

The medium answer is: in Pyodide, *whenever* [1] we use a Python Object (PyProxy) as an argument for a JS function, if that PyProxy is a coroutine, that coroutine is:
  * Wrapped in a Task (which automatically schedules it in the event loop via `create_task()` and ultimately `setTimeout()`)
  * The results result of that coroutine are represented as a Promise, while also proxying the resolve and reject handles if necessary

So more correctly, the example above should have ended with
```python
asyncio.ensure_future(count_a())
asyncio.ensure_future(count_b())
```

## LONG ANSWER [2]

*Feel free to skip to the Recap at the bottom*

### runPython() vs runPythonAsync()

It's probably most useful to compare/contrast `pyodide.runPython()` and `pyodide.runPythonAsync()`, since they're more similar than they are different:

To start with - both functions are API wrappers <a href="https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/js/api.ts#L63-L70">[1]</a> <a href="https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/js/api.ts#L187-L194">[2]</a> around the lower-level functions <a href="https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/py/_pyodide/_base.py#L364-L460">eval_code()</a> and <a href="https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/py/_pyodide/_base.py#L464-L540">eval_code_async()</a>. With some extraneous arguments removed:

```python
def eval_code( source, flags ... )
) -> Any:
    return (
        CodeRunner(
            source,
            flags=flags,
            ...
        )
        .compile(flags )
        .run()
```
```python
async def eval_code_async(source, flags, ...))
    flags = flags or ast.PyCF_ALLOW_TOP_LEVEL_AWAIT
    return (
        await CodeRunner(
            source,
            flags=flags,
            ...
        )
        .compile(flags)
        .run_async()
```

So then, what does compiling with the ast.PyCF_ALLOW_TOP_LEVEL_AWAIT flag do, and what's the difference between `CodeRunner.run()` and `CodeRunner.run_async()`?

`PyCF_ALLOW_TOP_LEVEL_AWAIT` was introduced in Python 3.8, and as [the changelog](https://docs.python.org/3/whatsnew/3.8.html#builtins) describes:
> The [compile()](https://docs.python.org/3/library/functions.html#compile) built-in has been improved to accept the ast.PyCF_ALLOW_TOP_LEVEL_AWAIT flag. With this new flag passed, [compile()](https://docs.python.org/3/library/functions.html#compile) will allow top-level await, async for and async with constructs that are usually considered invalid syntax. Asynchronous code object marked with the CO_COROUTINE flag may then be returned.

Essentially, when compiling Python source with this flag, if it contains `await`, `async for` or `async with` at the top level, the returned code object will be tagged with the `CO_COROUTINE FLAG`. Crucially, when this code is `eval()`'d, the result _is a coroutine object_. (If there's no top-level-await statements, the returned value is just the results of eval() as usual).

So then, what's the difference between [CodeRunner.run()](https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/py/_pyodide/_base.py#L261) and [CodeRunner.run_async()](https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/py/_pyodide/_base.py#L315)? Again, some abbreviated snippets:
```python
#https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/py/_pyodide/_base.py#L261
def run(self, globals, locals):
    if self.code is None:
      return None
    eval(self.code, globals, locals)
```
```python
#https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/py/_pyodide/_base.py#L315
async def run_async(self, globals, locals):
    if self.code is None:
        return
    coroutine = eval(self.code, globals, locals)
    if coroutine:
        await coroutine
```

So, both methods compile their code and `eval()` it, but in `run_async`, if the results of the eval() are a coroutine (which they can be, thanks to the PyCF_ALLOW_TOP_LEVEL_AWAIT flag), we `await` that coroutine; this awaited coroutine at the top of the stack will be picked up and scheduled, as we'll see next.

### Automatic Coroutine Scheduling

So... how does this lead to the coroutines being automatically scheduled? Let's review the function call chain to this point:

```python
async function runPythonAsync(source)
    ...
    API.pyodide_code.eval_code_async(source)

# ^^^ JavaScript ^^^
# vvv   Python   vvv

async def eval_code_async(source)
    ...
    self.code = compile(source, PyCF_ALLOW_TOP_LEVEL_AWAIT)
    run_async()

async def run_async(source)
    coroutine = eval(self.code)
    if coroutine:
        await coroutine
```

We'd better understand a bit more about why `Api.pyodide_code.eval_code_async(source)` works - it's calling a Python function from within JavaScript after all.

`Api.pyodide_code` is a module object [imported during Pyodide's bootstrapping process](https://github.com/pyodide/pyodide/blob/7dfee03a82c19069f714a09da386547aeefef242/src/js/pyodide.ts#L146). When we call a Python function from a module, we invoke the [Module.callPyObjectKwargs](https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/core/pyproxy.ts#L364-L404)[3] function, which handles calling the appropriate function, proxying it to JS if necessary, and, most importantly _automatically scheduling coroutines_:

```js
// https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/core/pyproxy.ts#L397-L402
Module.callPyObjectKwargs = function (
    ...
    let result = Hiwire.pop_value(idresult);
    // Automatically schedule coroutines
    if (result && result.type === "coroutine" && result._ensure_future) {
        result._ensure_future();
    }
    return result;
)
```

`_ensure_future`, [as the code comments tells us](https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/core/pyproxy.ts#L1088-L1094):
>  wraps __pyproxy_ensure_future and makes a function that converts a
>  Python awaitable to a promise, scheduling the awaitable on the Python
>   event loop if necessary.

It does this by [invoking asyncio.ensure_future()](https://github.com/pyodide/pyodide/blob/2326c05d83bb172d3a8b3d267d3440ed687f5c8d/src/core/pyproxy.c#L763) and doing some extra manipulation with callback functions to proxy them appropriately to JavaScript. [4]

[asyncio.ensure_future()](https://docs.python.org/3/library/asyncio-future.html#asyncio.ensure_future) returns the object as a Task in most cases (unless it was already a Future or Task). **This schedules the Task**, because `ensure_future` calls `loop.create_task()`. 

```python
#https://github.com/python/cpython/blob/75a6fadf369315b27e12f670e6295cf2c2cf7d7e/Lib/asyncio/tasks.py#L670-L673
def _ensure_future():
    ...
    if loop is None:
        loop = events._get_event_loop(stacklevel=4)
    try:
        return loop.create_task(coro_or_future)
```

And Tasks, on creation, automatically schedule themselves using `loop.call_soon()`:

```python
#https://github.com/python/cpython/blob/75a6fadf369315b27e12f670e6295cf2c2cf7d7e/Lib/asyncio/tasks.py#L120
class Task(futures._PyFuture):
    def __init__(self, coro, ...):
        ...
        self._loop.call_soon(self.__step, context=self._context)
        _register_task(self)
```

And the webloop implementation of `call_soon()` is really shorthand for `call_later(delay=0)`, which calls the browser's `setTimeout` function:

```python
#https://github.com/pyodide/pyodide/blob/f0ed62768df36e715a8cb96afad380aa3c06ac1b/src/py/pyodide/webloop.py#L279
if IN_BROWSER:
    from js import setTimeout
...
def call_later(  # type: ignore[override]
    self,
    delay: float,
    callback: Callable[..., Any],
    ...
    ) -> asyncio.Handle:
    ...
    setTimeout(create_once_callable(run_handle), delay * 1000)
    return handle
```

### RECAP

* We run `let source = await someCoroutine(); runtime.runPythonAsync(source);`
    * This calls `eval_code_async(source)`, which:
        * Parses the source and notices it uses top-level await
        * Compiles the source into a code object with the `CO_COROUTINE` flag set
    * Then calls `run_async()` which evals this code object
        * Because the `CO_COROUTINE` flag was set, the result of this eval() is a coroutine object
        * We tell Python we will `await` this coroutine, so the top object in the stack is this coroutine object
* Because the result of `eval_code_async()` is a coroutine object that we're passing back to a JS function (runPythonAsync), that coroutine is:
    * Wrapped in a Task, which on `__init__`:
        * Calls `ensure_future()` which
        * Calls `loop.create_task()` which
        * Calls `loop.call_soon()` (i.e. `loop.call_later(delay = 0)`) which
        * Calls the browser's `setTimeout()` method

## Footnotes

1. I am 90% sure this is true *in general* for JS functions handling Python coroutines.
3. I'm not 100% sure Pyodide calls Module.callPyObjectKwargs() directly, it might be one of other half-dozen methods in [pyproxy.ts](https://github.com/pyodide/pyodide/blob/97cd5bdc1cf62f4e5e44a305a7682d92b556a1e0/src/core/pyproxy.ts) that then call callPyObjectKwargs().
4. The way Pyodide handles proxying, this Python Future is proxied to a JavaScript Promise and returned to the user:

```js
//https://github.com/pyodide/pyodide/blob/2326c05d83bb172d3a8b3d267d3440ed687f5c8d/src/core/pyproxy.ts#L1088-L1129
export class PyProxyAwaitableMethods
_ensure_future(): Promise<any> {
    let promise = new Promise((resolve, reject) => {
        resolveHandle = resolve;
        rejectHandle = reject;
    });
    ...
    try {
        errcode = Module.__pyproxy_ensure_future(
        ptrobj,
        resolve_handle_id,
        reject_handle_id,
        );
    }
    ...
    return promise;
}
```

Where it can be `await`ed, `void`ed, or ignored like any other JavaScript promise. See [this chunk of PyProxy.ts](https://github.com/pyodide/pyodide/blob/2326c05d83bb172d3a8b3d267d3440ed687f5c8d/src/core/pyproxy.ts#L1130-L1197)
wow, THANK YOU a lot @JeffersGlass this was a heroic effort! I also tried to dive in the code and could understand ~50% of the logic, but thanks to your explanation I think that now I understand (almost?) all of it :).

I'm more and more convinced that we should treat python-async-in-the-browser as an useful but very advanced functionality: it should be opt-in and reserved to advanced users who knows what they do. The current behavior of enabling it just "on demand" just because you happen to do an `await` is a recipe for confusion.

Also, python and JS async models are subtly different; and pyodide asyncio has slightly different behavior that normal asyncio, which will lead to even more confusion unless you really know what you are doing.

In the meantime, I also played with `ContextVar`s, and I discovered that they are not very robust. Look for example at this test:
```python

    def test_contextvar_and_js(self):
        self.pyscript_run(
            """
            <script>
            function jsSleep(ms) {
                return new Promise(resolve => setTimeout(resolve, ms));
            }

            async function callPythonFromJs(pyfn, i) {
                if (i % 2 == 1)
                    await jsSleep(0);
                pyfn(i);
            }
            </script>

            <py-script>
                import js
                from contextvars import ContextVar

                OUTPUT = ContextVar('OUTPUT', default=None)

                def my_display(i):
                    output = OUTPUT.get()
                    print(f'    my_display({i}), output =', output)

                async def task_a():
                    OUTPUT.set('AAA')
                    for i in range(5):
                        #my_display(i)
                        js.callPythonFromJs(my_display, i)
                        await asyncio.sleep(0.2)

                PyScript.loop.create_task(task_a())
            </py-script>
            """
        )
        self.page.wait_for_timeout(1500)
```

I get the following output:
```
[  5.03 console.log     ]     my_display(0), output = AAA
[  5.25 console.log     ]     my_display(1), output = None
[  5.46 console.log     ]     my_display(2), output = AAA
[  5.68 console.log     ]     my_display(3), output = None
[  5.88 console.log     ]     my_display(4), output = AAA
```

As you can see by looking at the code, it seems that contextvars does not always survive a `Python->JS->Python` call chain. In particular, they survive the normal case, but they fail as soon as you use `await` in JS. I *think* (but not 100% sure) that what happens is the following:

1. as explained above, python tasks are ultimately scheduled by `pyodide.Webloop.call_later`:
```python
# https://github.com/pyodide/pyodide/blob/faa3b8ccb32867a3feb7e7a45ffd5c26fa6a47d2/src/py/pyodide/webloop.py#L127-L161
    def call_later(  # type: ignore[override]
        self,
        delay: float,
        callback: Callable[..., Any],
        *args: Any,
        context: contextvars.Context | None = None,
    ) -> asyncio.Handle:
        ...
        if delay < 0:
            raise ValueError("Can't schedule in the past")
        h = asyncio.Handle(callback, args, self, context=context)

        def run_handle():
            if h.cancelled():
                return
            h._run()

        setTimeout(create_once_callable(run_handle), delay * 1000)
        return h
```

2. the callback is run by `asyncio.Handle._run()`, which in turns calls `self._context.run`:
```python
# https://github.com/antocuni/cpython/blob/3d8993a744813c5144851da5347d7b4b1885f234/Lib/asyncio/events.py#L79-L83
    def _run(self):
        try:
            self._context.run(self._callback, *self._args)
        except (SystemExit, KeyboardInterrupt):
            raise
        ...
```

3. context.run is implemented in C, but basically set ups the context, execute the python code, and reset the context

4. in the case shown by the test above, as soon as the `callPythonFromJs` does the `await JsSleep()`, the whole python task is paused, causing the reset of the context

5. in the normal case, if we do an `await something()` in python, the code is resumed by the `asyncio` loop, which sets the appropriate context before (re)starting the python coroutine which was paused

6. but in this case, the code is resumed directly by the browser loop (because it was paused by a JS await, not a python await), bypassing the logic described in (5). The end result is that *the context is not set*.

As I said I'm not sure to fully understand everything, but I think that the above is a pretty reasonable explanation of what's happening. It might also be possible to fix this precise case by adding extra logic to Pyodide, but I don't really know.

But I think that the net result is that we should just that this python+js+async technology stack is probably a bit immature and fragile and as I said above, we should try to use it as less as possible.

Related issue/PR:
https://github.com/pyscript/pyscript/issues/769, where we discussed various problems about the implicit output of the `display()` function. @philippjfr proposed to use `ContextVar` but I think that this shows that it's not really a solution.

https://github.com/pyscript/pyscript/pull/749 which implemented `display()`.



@antocuni Thanks right back at you this! And for your keen eyes in finding these edges where Python and JS. I think your assessment is of what's happening there is right... though I'm still thinking that it may be possible to pin down the edge cases enough (like python->js->python) to provide clear docs to the users and still allow useful lexical scope. But maybe I'm just being overly optimistic. 

But as you say, circling back to what you opened this issue about in the first place: 

> I'm more and more convinced that we should treat python-async-in-the-browser as an useful but very advanced functionality: it should be opt-in and reserved to advanced users who knows what they do. The current behavior of enabling it just "on demand" just because you happen to do an await is a recipe for confusion.

+Yes. So, to move forward, a couple thoughts:

- Do we want to just make this change, or have deprecate it for a version and then remove it?
  - If we do want to show a deprecation warning while using `runPythonAsync()` for one more version, one option would be to use the methodology from #715 in to detect top-level-await code and warn the user?
- Do we want to split the functionality into another tag/attribute? I know you had floated `<py-task>`, @tedpatrick had suggested an `async` attribute for the `<py-script>` tag, there may be other ways...
  - I'd have a slight preference toward using a `<py-script>` tag with an attribute, but maybe not `async`, since it would mean a pretty different thing than `<script async...>`. Not a strong feeling from me there though.
There are various approaches, but my philosophy is the following: since it's an advanced feature which is potentially "dangerous" (maybe dangerous it's not the right word, but I think you understand what I mean), we should not offer special syntax to facilitate it: this way, we ensure that you use it only if you really need.
So, I think that my basic idea is the following:
- we use `pyodide.runPython` everywhere (no more `runPythonAsync`).
- no special syntax: if you want to start an async task, you do it manually with `asyncio.create_task`
- ideally, I would like pyscript to run in a webworker by default. If you want to run in the main thread (which is potentially risky because you can freeze the UI), you have to opt in.

So basically, instead of the current:
```html
<py-script>
while True:
    print('hello')
    await asyncio.sleep(1)
</py-script>
```

You would have to do the following:
```html
<py-script mainthread>
async def fn():
    while True:
        print('hello')
        await asyncio.sleep(1)

asyncio.create_task(fn())
</py-script>
```
(the exact syntax to run on the mainthread should be discussed, of course)

>   * If we do want to show a deprecation warning while using `runPythonAsync()` for one more version, one option would be to use the methodology from [Improve Detection of Scripts that Require Top-Level Await #715](https://github.com/pyscript/pyscript/pull/715) in to detect top-level-await code and warn the user?

+1 to detect the usage and show a message to the user.
I'm not sure it's worth a full deprecation cycle though: I think that we could just detect the top-level await and show a hard error with a link to the documentation which explain how to use asyncio explicitly.

> we use pyodide.runPython everywhere (no more runPythonAsync). no special syntax: if you want to start an async task, you do it manually with `asyncio.create_task`
> I'm not sure it's worth a full deprecation cycle though: I think that we could just detect the top-level await and show a hard error with a link to the documentation which explain how to use asyncio explicitly.
+3 to both, sounds good to me. 
I know it's a very long discussion, but I'm honestly curious to hear @fpliger thoughts on this.
I think his opinion might differ and he might bring valuable insight to this.

Just reading the discussion and thinking about all the problems I'm running into related to `async`ness, this seems very reasonable... But then again, I wonder how much this would impact users and if we should focus on having a better solution for `async`.
Wow... the time catching up on issues this week has confirmed a lot of my FOMO in the past weeks :)

This is a great discussion. Thanks @antocuni @JeffersGlass @marimeireles  for the super thoughtful insights and opinions!

My general thought here is that:

> we use pyodide.runPython everywhere (no more runPythonAsync).

Yes, +1 one. Let's make sync the default.

> no special syntax: if you want to start an async task, you do it manually with asyncio.create_task 

-0 . One one side I can be ok with it because it's the way of running code that we want to encourage the least (if not discourage) and it's also more explicit... otoh, I kind of think we can do better. But, given all the discouragement, I can totally live with it

> ideally, I would like pyscript to run in a webworker by default. If you want to run in the main thread (which is potentially risky because you can freeze the UI), you have to opt in.

That's, imho, the real solution. That being default is/was also my preference but iirc @ntoll  kind of made me doubt about it the last time we spoke about it. (because it can have implications that might not be easy to understand to all users) but, yeah, this should be either default or a close second that is very easy to opt in. 
> That's, imho, the real solution. That being default is/was also my preference but iirc @ntoll kind of made me doubt about it the last time we spoke about it. (because it can have implications that might not be easy to understand to all users) but, yeah, this should be either default or a close second that is very easy to opt in.

@ntoll what are these implications?

Resolving patch:
diff --git a/pyscriptjs/src/python/pyscript.py b/pyscriptjs/src/python/pyscript.py
--- a/pyscriptjs/src/python/pyscript.py
+++ b/pyscriptjs/src/python/pyscript.py
@@ -1,3 +1,4 @@
+import ast
 import asyncio
 import base64
 import html
@@ -404,4 +405,28 @@ def child_appended(self, child):
         pass
 
 
+class TopLevelAsyncFinder(ast.NodeVisitor):
+    def is_source_top_level_await(self, source):
+        self.async_found = False
+        node = ast.parse(source)
+        self.generic_visit(node)
+        return self.async_found
+
+    def visit_Await(self, node):
+        self.async_found = True
+
+    def visit_AsyncFor(self, node):
+        self.async_found = True
+
+    def visit_AsyncWith(self, node):
+        self.async_found = True
+
+    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
+        pass  # Do not visit children of async function defs
+
+
+def uses_top_level_await(source: str) -> bool:
+    return TopLevelAsyncFinder().is_source_top_level_await(source)
+
+
 pyscript = PyScript()


Test patch:
diff --git a/pyscriptjs/tests/integration/test_03_async.py b/pyscriptjs/tests/integration/test_03_async.py
--- a/pyscriptjs/tests/integration/test_03_async.py
+++ b/pyscriptjs/tests/integration/test_03_async.py
@@ -2,56 +2,146 @@
 
 
 class TestAsync(PyScriptTest):
+    # ensure_future() and create_task() should behave similarly;
+    # we'll use the same source code to test both
+    coroutine_script = """
+        <py-script>
+        import js
+        import asyncio
+        js.console.log("first")
+        async def main():
+            await asyncio.sleep(1)
+            js.console.log("third")
+        asyncio.{func}(main())
+        js.console.log("second")
+        </py-script>
+        """
+
+    def test_asyncio_ensure_future(self):
+        self.pyscript_run(self.coroutine_script.format(func="ensure_future"))
+        self.wait_for_console("third")
+        assert self.console.log.lines == [self.PY_COMPLETE, "first", "second", "third"]
+
+    def test_asyncio_create_task(self):
+        self.pyscript_run(self.coroutine_script.format(func="create_task"))
+        self.wait_for_console("third")
+        assert self.console.log.lines == [self.PY_COMPLETE, "first", "second", "third"]
+
+    def test_asyncio_gather(self):
+        self.pyscript_run(
+            """
+            <py-script id="pys">
+            import asyncio
+            import js
+            from pyodide.ffi import to_js
+
+            async def coro(delay):
+                await asyncio.sleep(delay)
+                return(delay)
+
+            async def get_results():
+                results = await asyncio.gather(*[coro(d) for d in range(3,0,-1)])
+                js.console.log(to_js(results))
+                js.console.log("DONE")
+
+            asyncio.ensure_future(get_results())
+            </py-script>
+            """
+        )
+        self.wait_for_console("DONE")
+        assert self.console.log.lines[-2:] == ["[3,2,1]", "DONE"]
+
     def test_multiple_async(self):
         self.pyscript_run(
             """
         <py-script>
             import js
             import asyncio
-            for i in range(3):
-                js.console.log('A', i)
-                await asyncio.sleep(0.1)
+            async def a_func():
+                for i in range(3):
+                    js.console.log('A', i)
+                    await asyncio.sleep(0.1)
+            asyncio.ensure_future(a_func())
         </py-script>
 
         <py-script>
             import js
             import asyncio
-            for i in range(3):
-                js.console.log('B', i)
-                await asyncio.sleep(0.1)
-            js.console.log("async tadone")
+            async def b_func():
+                for i in range(3):
+                    js.console.log('B', i)
+                    await asyncio.sleep(0.1)
+                js.console.log('b func done')
+            asyncio.ensure_future(b_func())
         </py-script>
         """
         )
-        self.wait_for_console("async tadone")
+        self.wait_for_console("b func done")
         assert self.console.log.lines == [
-            "Python initialization complete",
+            self.PY_COMPLETE,
             "A 0",
             "B 0",
             "A 1",
             "B 1",
             "A 2",
             "B 2",
-            "async tadone",
+            "b func done",
         ]
 
-    def test_multiple_async_multiple_display(self):
+    def test_multiple_async_multiple_display_targetted(self):
         self.pyscript_run(
             """
                 <py-script id='pyA'>
+                    import js
                     import asyncio
-                    for i in range(2):
-                        display('A')
-                        await asyncio.sleep(0)
-                </py-script>
 
+                    async def a_func():
+                        for i in range(2):
+                            display(f'A{i}', target='pyA')
+                            await asyncio.sleep(0.1)
+                    asyncio.ensure_future(a_func())
+
+                </py-script>
                 <py-script id='pyB'>
+                    import js
                     import asyncio
-                    for i in range(2):
-                        display('B')
-                        await asyncio.sleep(0)
+
+                    async def a_func():
+                        for i in range(2):
+                            display(f'B{i}', target='pyB')
+                            await asyncio.sleep(0.1)
+                        js.console.log("B DONE")
+
+                    asyncio.ensure_future(a_func())
                 </py-script>
             """
         )
+        self.wait_for_console("B DONE")
         inner_text = self.page.inner_text("html")
-        assert "A\nB\nA\nB" in inner_text
+        assert "A0\nA1\nB0\nB1" in inner_text
+
+    def test_async_display_untargetted(self):
+        self.pyscript_run(
+            """
+                <py-script id='pyA'>
+                    import asyncio
+                    import js
+
+                    async def a_func():
+                        try:
+                            display('A')
+                            await asyncio.sleep(0.1)
+                        except Exception as err:
+                            js.console.error(str(err))
+                        await asyncio.sleep(1)
+                        js.console.log("DONE")
+
+                    asyncio.ensure_future(a_func())
+                </py-script>
+            """
+        )
+        self.wait_for_console("DONE")
+        assert (
+            self.console.error.lines[-1]
+            == "Implicit target not allowed here. Please use display(..., target=...)"
+        )
diff --git a/pyscriptjs/tests/integration/test_importmap.py b/pyscriptjs/tests/integration/test_importmap.py
--- a/pyscriptjs/tests/integration/test_importmap.py
+++ b/pyscriptjs/tests/integration/test_importmap.py
@@ -1,6 +1,9 @@
+import pytest
+
 from .support import PyScriptTest
 
 
+@pytest.mark.xfail(reason="See PR #938")
 class TestImportmap(PyScriptTest):
     def test_importmap(self):
         src = """
diff --git a/pyscriptjs/tests/integration/test_py_config.py b/pyscriptjs/tests/integration/test_py_config.py
--- a/pyscriptjs/tests/integration/test_py_config.py
+++ b/pyscriptjs/tests/integration/test_py_config.py
@@ -100,7 +100,7 @@ def test_runtime_config(self, tar_location):
         """,
         )
 
-        assert self.console.log.lines == [self.PY_COMPLETE, "version 0.20.0"]
+        assert self.console.log.lines[-1] == "version 0.20.0"
         version = self.page.locator("py-script").inner_text()
         assert version == "0.20.0"
 
diff --git a/pyscriptjs/tests/integration/test_py_repl.py b/pyscriptjs/tests/integration/test_py_repl.py
--- a/pyscriptjs/tests/integration/test_py_repl.py
+++ b/pyscriptjs/tests/integration/test_py_repl.py
@@ -63,13 +63,9 @@ def test_execute_on_shift_enter(self):
             </py-repl>
             """
         )
+        self.page.wait_for_selector("#runButton")
         self.page.keyboard.press("Shift+Enter")
-
-        # when we use locator('button').click() the message appears
-        # immediately, with keyboard.press we need to wait for it. I don't
-        # really know why it has a different behavior, I didn't investigate
-        # further.
-        self.wait_for_console("hello world")
+        assert self.console.log.lines == [self.PY_COMPLETE, "hello world"]
 
     def test_display(self):
         self.pyscript_run(
diff --git a/pyscriptjs/tests/py-unit/test_pyscript.py b/pyscriptjs/tests/py-unit/test_pyscript.py
--- a/pyscriptjs/tests/py-unit/test_pyscript.py
+++ b/pyscriptjs/tests/py-unit/test_pyscript.py
@@ -1,4 +1,5 @@
 import sys
+import textwrap
 from unittest.mock import Mock
 
 import pyscript
@@ -48,3 +49,71 @@ def test_format_mime_HTML():
     out, mime = pyscript.format_mime(obj)
     assert out == "<p>hello</p>"
     assert mime == "text/html"
+
+
+def test_uses_top_level_await():
+    # Basic Case
+    src = "x = 1"
+    assert pyscript.uses_top_level_await(src) is False
+
+    # Comments are not top-level await
+    src = textwrap.dedent(
+        """
+        #await async for async with asyncio
+        """
+    )
+
+    assert pyscript.uses_top_level_await(src) is False
+
+    # Top-level-await cases
+    src = textwrap.dedent(
+        """
+        async def foo():
+            pass
+        await foo
+        """
+    )
+    assert pyscript.uses_top_level_await(src) is True
+
+    src = textwrap.dedent(
+        """
+        async with object():
+            pass
+        """
+    )
+    assert pyscript.uses_top_level_await(src) is True
+
+    src = textwrap.dedent(
+        """
+        async for _ in range(10):
+            pass
+        """
+    )
+    assert pyscript.uses_top_level_await(src) is True
+
+    # Acceptable await/async for/async with cases
+    src = textwrap.dedent(
+        """
+        async def foo():
+            await foo()
+        """
+    )
+    assert pyscript.uses_top_level_await(src) is False
+
+    src = textwrap.dedent(
+        """
+        async def foo():
+            async with object():
+                pass
+        """
+    )
+    assert pyscript.uses_top_level_await(src) is False
+
+    src = textwrap.dedent(
+        """
+        async def foo():
+            async for _ in range(10):
+                pass
+        """
+    )
+    assert pyscript.uses_top_level_await(src) is False