Repository: pyodide/pyodide

Issue:
Using `asyncio.gather()` with `syncify()` raises error
## 🐛 Bug
Using `asyncio.gather()` with `syncify()` isn't possible out of the box

### To Reproduce
Open [browser with last version of Pyodide](https://pyodide.org/en/latest/console.html) and run the next code:
```python
await pyodide.runPythonSyncifying(`
    import asyncio

    async def test(x):
        await asyncio.sleep(0.1 * x)
        return x + 1

    res = asyncio.create_task(asyncio.gather(test(5), test(3), test(10))).syncify()
    print(*res)
`);
```
This code will raise the error:
![image](https://github.com/pyodide/pyodide/assets/32600554/54d3e8bb-23c2-4ada-a051-c858d74e5471)

The next workaround will work as expected:
```python
await pyodide.runPythonSyncifying(`
    import asyncio

    async def test(x):
        await asyncio.sleep(0.1 * x)
        return x + 1

    async def better_gather(*coros):
        return await asyncio.gather(*coros)

    res = asyncio.create_task(better_gather(test(5), test(3), test(10))).syncify()
    print(*res)
`);
```

### Expected behavior
Expected the first code snippet to work :)

### Environment

- Pyodide Version: 0.25.0
- Browser version: Version 120.0.6099.199 (Official Build) (64-bit)
- Any other relevant information:
- - Enabled JSPI flag in Chrome: chrome://flags/#enable-experimental-webassembly-stack-switching



Discussion:

Resolving patch:
diff --git a/src/py/_pyodide/_core_docs.py b/src/py/_pyodide/_core_docs.py
--- a/src/py/_pyodide/_core_docs.py
+++ b/src/py/_pyodide/_core_docs.py
@@ -1495,6 +1495,11 @@ def destroy_proxies(pyproxies: JsArray[Any], /) -> None:
     pass
 
 
+def run_sync(x: Awaitable[T]) -> T:
+    """Hi!"""
+    raise NotImplementedError
+
+
 __name__ = _save_name
 del _save_name
 
@@ -1519,6 +1524,7 @@ def destroy_proxies(pyproxies: JsArray[Any], /) -> None:
     "JsDomElement",
     "JsCallable",
     "JsTypedArray",
+    "run_sync",
     "create_once_callable",
     "create_proxy",
     "destroy_proxies",
diff --git a/src/py/pyodide/ffi/__init__.py b/src/py/pyodide/ffi/__init__.py
--- a/src/py/pyodide/ffi/__init__.py
+++ b/src/py/pyodide/ffi/__init__.py
@@ -20,6 +20,7 @@
     #   that would be difficult to maintain.
     for t in [
         "JsException",
+        "run_sync",
         "create_once_callable",
         "create_proxy",
         "destroy_proxies",
diff --git a/src/py/pyodide/webloop.py b/src/py/pyodide/webloop.py
--- a/src/py/pyodide/webloop.py
+++ b/src/py/pyodide/webloop.py
@@ -167,26 +167,6 @@ def wrapper(fut: Future[T]) -> None:
         self.add_done_callback(wrapper)
         return result
 
-    def syncify(self):
-        """Block until the future is resolved. Only works if JS Promise
-        integration is enabled in the runtime and the current Python call stack
-        was entered via :js:func:`pyodide.runPythonAsync`, by calling an async
-        Python function, or via :js:func:`~PyCallable.callSyncifying`.
-
-
-        .. admonition:: Experimental
-           :class: warning
-
-           This feature is not yet stable.
-        """
-        from .ffi import create_proxy
-
-        p = create_proxy(self)
-        try:
-            return p.syncify()  # type:ignore[attr-defined]
-        finally:
-            p.destroy()
-
 
 class PyodideTask(Task[T], PyodideFuture[T]):
     """Inherits from both :py:class:`~asyncio.Task` and


Test patch:
diff --git a/src/tests/test_stack_switching.py b/src/tests/test_stack_switching.py
--- a/src/tests/test_stack_switching.py
+++ b/src/tests/test_stack_switching.py
@@ -6,15 +6,84 @@
 
 @requires_jspi
 @run_in_pyodide
-def test_syncify_create_task(selenium):
-    import asyncio
+def test_syncify_awaitable_types_accept(selenium):
+    from asyncio import create_task, gather, sleep
+
+    from js import sleep as js_sleep
+    from pyodide.code import run_js
+    from pyodide.ffi import run_sync
 
     async def test():
-        await asyncio.sleep(0.1)
+        await sleep(0.1)
         return 7
 
-    task = asyncio.create_task(test())
-    assert task.syncify() == 7  # type:ignore[attr-defined]
+    assert run_sync(test()) == 7
+    assert run_sync(create_task(test())) == 7
+    run_sync(sleep(0.1))
+    run_sync(js_sleep(100))
+    res = run_sync(gather(test(), sleep(0.1), js_sleep(100), js_sleep(100)))
+    assert list(res) == [7, None, None, None]
+    p = run_js("[sleep(100)]")[0]
+    run_sync(p)
+
+
+@requires_jspi
+@run_in_pyodide
+def test_syncify_awaitable_type_errors(selenium):
+    import pytest
+
+    from pyodide.ffi import run_sync
+
+    with pytest.raises(TypeError):
+        run_sync(1)  # type:ignore[arg-type]
+    with pytest.raises(TypeError):
+        run_sync(None)  # type:ignore[arg-type]
+    with pytest.raises(TypeError):
+        run_sync([1, 2, 3])  # type:ignore[arg-type]
+    with pytest.raises(TypeError):
+        run_sync(iter([1, 2, 3]))  # type:ignore[arg-type]
+
+    def f():
+        yield 1
+        yield 2
+        yield 3
+
+    with pytest.raises(TypeError):
+        run_sync(f())
+
+
+@pytest.mark.skip(reason="FIXME!")
+def test_throw_from_switcher(selenium):
+    """
+    Currently failing:
+
+    Uncaught PythonError: Traceback (most recent call last):
+      File "<exec>", line 9, in b
+    Exception: hi
+
+    The above exception was the direct cause of the following exception:
+
+    SystemError: <function a at 0x9aaea0> returned a result with an exception set
+    """
+    selenium.run_js(
+        """
+        pyodide.runPython(`
+            async def a():
+                pass
+
+            def b():
+                raise Exception("hi")
+        `);
+
+        const a = pyodide.globals.get("a");
+        const b = pyodide.globals.get("b");
+
+        await Promise.all([
+            b.callSyncifying(),
+            a(),
+        ]);
+        """
+    )
 
 
 @pytest.mark.xfail_browsers(node="Scopes don't work as needed")
@@ -31,7 +100,10 @@ def test_syncify_not_supported1(selenium_standalone_noload):
           "WebAssembly stack switching not supported in this JavaScript runtime"
         );
         await assertThrows(
-          () => pyodide.runPython("from js import sleep; sleep().syncify()"),
+          () => pyodide.runPython(`
+            from pyodide.ffi import run_sync
+            run_sync(1)
+          `),
           "PythonError",
           "RuntimeError: WebAssembly stack switching not supported in this JavaScript runtime"
         );
@@ -56,7 +128,10 @@ def test_syncify_not_supported2(selenium_standalone_noload):
           "WebAssembly stack switching not supported in this JavaScript runtime"
         );
         await assertThrows(
-          () => pyodide.runPython("from js import sleep; sleep().syncify()"),
+          () => pyodide.runPython(`
+            from pyodide.ffi import run_sync
+            run_sync(1)
+          `),
           "PythonError",
           "RuntimeError: WebAssembly stack switching not supported in this JavaScript runtime"
         );
@@ -68,6 +143,7 @@ def test_syncify_not_supported2(selenium_standalone_noload):
 @run_in_pyodide
 def test_syncify1(selenium):
     from pyodide.code import run_js
+    from pyodide.ffi import run_sync
 
     test = run_js(
         """
@@ -77,7 +153,7 @@ def test_syncify1(selenium):
         })
         """
     )
-    assert test().syncify() == 7
+    assert run_sync(test()) == 7
 
 
 @requires_jspi
@@ -87,12 +163,13 @@ def test_syncify2(selenium):
 
     import pytest
 
+    from pyodide.ffi import run_sync
     from pyodide_js import loadPackage
 
     with pytest.raises(ModuleNotFoundError):
         importlib.metadata.version("micropip")
 
-    loadPackage("micropip").syncify()
+    run_sync(loadPackage("micropip"))
 
     assert importlib.metadata.version("micropip")
 
@@ -103,7 +180,7 @@ def test_syncify_error(selenium):
     import pytest
 
     from pyodide.code import run_js
-    from pyodide.ffi import JsException
+    from pyodide.ffi import JsException, run_sync
 
     asyncThrow = run_js(
         """
@@ -114,13 +191,14 @@ def test_syncify_error(selenium):
     )
 
     with pytest.raises(JsException, match="hi"):
-        asyncThrow().syncify()
+        run_sync(asyncThrow())
 
 
 @requires_jspi
 @run_in_pyodide
 def test_syncify_null(selenium):
     from pyodide.code import run_js
+    from pyodide.ffi import run_sync
 
     asyncNull = run_js(
         """
@@ -130,7 +208,7 @@ def test_syncify_null(selenium):
         })
         """
     )
-    assert asyncNull().syncify() is None
+    assert run_sync(asyncNull()) is None
 
 
 @requires_jspi
@@ -140,6 +218,7 @@ def test_syncify_no_suspender(selenium):
         await pyodide.loadPackage("pytest");
         pyodide.runPython(`
             from pyodide.code import run_js
+            from pyodide.ffi import run_sync
             import pytest
 
             test = run_js(
@@ -151,7 +230,7 @@ def test_syncify_no_suspender(selenium):
                 '''
             )
             with pytest.raises(RuntimeError, match="No suspender"):
-                test().syncify()
+                run_sync(test())
             del test
         `);
         """
@@ -163,6 +242,7 @@ def test_syncify_no_suspender(selenium):
 @run_in_pyodide(packages=["fpcast-test"])
 def test_syncify_getset(selenium):
     from pyodide.code import run_js
+    from pyodide.ffi import run_sync
 
     test = run_js(
         """
@@ -175,7 +255,7 @@ def test_syncify_getset(selenium):
     x = []
 
     def wrapper():
-        x.append(test().syncify())
+        x.append(run_sync(test()))
 
     import fpcast_test
 
@@ -192,6 +272,7 @@ def wrapper():
 @run_in_pyodide
 def test_syncify_ctypes(selenium):
     from pyodide.code import run_js
+    from pyodide.ffi import run_sync
 
     test = run_js(
         """
@@ -203,7 +284,7 @@ def test_syncify_ctypes(selenium):
     )
 
     def wrapper():
-        return test().syncify()
+        return run_sync(test())
 
     from ctypes import py_object, pythonapi
 
@@ -219,6 +300,7 @@ def test_cpp_exceptions_and_syncify(selenium):
         selenium.run_js(
             """
             ptr = pyodide.runPython(`
+                from pyodide.ffi import run_sync
                 from pyodide.code import run_js
                 temp = run_js(
                     '''
@@ -231,7 +313,7 @@ def test_cpp_exceptions_and_syncify(selenium):
 
                 def f():
                     try:
-                        return temp().syncify()
+                        return run_sync(temp())
                     except Exception as e:
                         print(e)
                         return -1
@@ -261,11 +343,12 @@ def test_two_way_transfer(selenium):
     res = selenium.run_js(
         """
         pyodide.runPython(`
+            from pyodide.ffi import run_sync
             l = []
             def f(n, t):
                 from js import sleep
                 for i in range(5):
-                    sleep(t).syncify()
+                    run_sync(sleep(t))
                     l.append([n, i])
         `);
         f = pyodide.globals.get("f");
@@ -295,16 +378,17 @@ def f(n, t):
 def test_sync_async_mix(selenium):
     res = selenium.run_js(
         """
-        pyodide.runPython(
-        `
+        pyodide.runPython(`
+            from pyodide.ffi import run_sync
             from js import sleep
+
             l = [];
             async def a(t):
                 await sleep(t)
                 l.append(["a", t])
 
             def b(t):
-                sleep(t).syncify()
+                run_sync(sleep(t))
                 l.append(["b", t])
         `);
         const a = pyodide.globals.get("a");
@@ -357,18 +441,19 @@ def test_nested_syncify(selenium):
         pyodide.globals.set("getStuff", getStuff);
 
         pyodide.runPython(`
+            from pyodide.ffi import run_sync
             from js import sleep
             def g():
-                sleep(25).syncify()
-                return f1().syncify()
+                run_sync(sleep(25))
+                return run_sync(f1())
 
             def g1():
-                sleep(25).syncify()
-                return f2().syncify()
+                run_sync(sleep(25))
+                return run_sync(f2())
 
             def g2():
-                sleep(25).syncify()
-                return getStuff().syncify()
+                run_sync(sleep(25))
+                return run_sync(getStuff())
         `);
         const l = pyodide.runPython("l = []; l")
         const g = pyodide.globals.get("g");
@@ -379,7 +464,7 @@ def g2():
         p.push(pyodide.runPythonAsync(`
             from js import sleep
             for i in range(20):
-                sleep(9).syncify()
+                run_sync(sleep(9))
                 l.append(i)
         `));
         await Promise.all(p);
@@ -398,9 +483,10 @@ def g2():
 @requires_jspi
 @run_in_pyodide
 async def test_promise_methods(selenium):
-    from asyncio import ensure_future, sleep
+    from asyncio import sleep
 
     from pyodide.code import run_js
+    from pyodide.ffi import run_sync
 
     async_pass = run_js(
         """
@@ -420,7 +506,7 @@ async def test_promise_methods(selenium):
 
     def f(*args):
         print("will sleep")
-        ensure_future(sleep(0.1)).syncify()  # type:ignore[attr-defined]
+        run_sync(sleep(0.1))
         print("have slept")
 
     await async_pass().then(f, f)