Repository: pyodide/pyodide

Issue:
Potential memory leak in WebLoop.py
WebLoop.py uses `js.setTimeout` to implement call_* functions. The code is:

```
h = asyncio.Handle(callback, args, self, context=context)
setTimeout(h._run, delay * 1000)
return h
```

https://github.com/iodide-project/pyodide/blob/de7c3420103fabbf01c589fcf2a429d157a6bd5e/src/pyodide-py/pyodide/webloop.py#L139

I think this may result in a memory leak, since the `h._run` JS proxy is never released.


Discussion:
Yes, that's a memory leak. Thanks for the report! (There's currently a bunch of these =( )
From a design perspective, I'm not sure what the best way to fix leaks like this is. We need control over the lifetime of PyProxy from Python. I think being able to create a JsProxy of a PyProxy would help a lot because then we could directly access the `destroy` api from Python. Once #1306 is merged, this will be mitigated in any browser that supports `FinalizationRegistry` (it might take a long time for the memory to be reclaimed in that way though).

Resolving patch:
diff --git a/src/pyodide-py/pyodide/__init__.py b/src/pyodide-py/pyodide/__init__.py
--- a/src/pyodide-py/pyodide/__init__.py
+++ b/src/pyodide-py/pyodide/__init__.py
@@ -1,5 +1,5 @@
 from ._base import open_url, eval_code, eval_code_async, find_imports, as_nested_list
-from ._core import JsException  # type: ignore
+from ._core import JsException, create_once_callable, create_proxy  # type: ignore
 from ._importhooks import JsFinder
 from .webloop import WebLoopPolicy
 import asyncio
@@ -26,4 +26,6 @@
     "JsException",
     "register_js_module",
     "unregister_js_module",
+    "create_once_callable",
+    "create_proxy",
 ]
diff --git a/src/pyodide-py/pyodide/_core.py b/src/pyodide-py/pyodide/_core.py
--- a/src/pyodide-py/pyodide/_core.py
+++ b/src/pyodide-py/pyodide/_core.py
@@ -1,7 +1,13 @@
 import platform
+from typing import Any, Callable
 
 if platform.system() == "Emscripten":
-    from _pyodide_core import JsProxy, JsException
+    from _pyodide_core import (
+        JsProxy,
+        JsException,
+        create_proxy,
+        create_once_callable,
+    )
 else:
     # Can add shims here if we are so inclined.
     class JsException(Exception):  # type: ignore
@@ -16,5 +22,23 @@ class JsProxy:  # type: ignore
 
         # Defined in jsproxy.c
 
+        # Defined in jsproxy.c
+
+    def create_once_callable(obj: Callable) -> JsProxy:
+        """Wrap a Python callable in a Javascript function that can be called
+        once. After being called the proxy will decrement the reference count
+        of the Callable. The javascript function also has a `destroy` API that
+        can be used to release the proxy without calling it.
+        """
+        return obj
+
+    def create_proxy(obj: Any) -> JsProxy:
+        """Create a `JsProxy` of a `PyProxy`.
+
+        This allows explicit control over the lifetime of the `PyProxy` from
+        Python: call the `destroy` API when done.
+        """
+        return obj
+
 
-__all__ = ["JsProxy", "JsException"]
+__all__ = ["JsProxy", "JsException", "create_proxy", "create_once_callable"]
diff --git a/src/pyodide-py/pyodide/webloop.py b/src/pyodide-py/pyodide/webloop.py
--- a/src/pyodide-py/pyodide/webloop.py
+++ b/src/pyodide-py/pyodide/webloop.py
@@ -132,11 +132,12 @@ def call_later(
         This uses `setTimeout(callback, delay)`
         """
         from js import setTimeout
+        from . import create_once_callable
 
         if delay < 0:
             raise ValueError("Can't schedule in the past")
         h = asyncio.Handle(callback, args, self, context=context)
-        setTimeout(h._run, delay * 1000)
+        setTimeout(create_once_callable(h._run), delay * 1000)
         return h
 
     def call_at(


Test patch:
diff --git a/src/tests/test_asyncio.py b/src/tests/test_asyncio.py
--- a/src/tests/test_asyncio.py
+++ b/src/tests/test_asyncio.py
@@ -35,6 +35,122 @@ async def temp():
         )
 
 
+def test_then_jsproxy(selenium):
+    selenium.run(
+        """
+        def prom(res, rej):
+            global resolve
+            global reject
+            resolve = res
+            reject = rej
+
+        from js import Promise
+        result = None
+        err = None
+        finally_occurred = False
+
+        def onfulfilled(value):
+            global result
+            result = value
+
+        def onrejected(value):
+            global err
+            err = value
+
+        def onfinally():
+            global finally_occurred
+            finally_occurred = True
+        """
+    )
+
+    selenium.run(
+        """
+        p = Promise.new(prom)
+        p.then(onfulfilled, onrejected)
+        resolve(10)
+        """
+    )
+    time.sleep(0.01)
+    selenium.run(
+        """
+        assert result == 10
+        assert err is None
+        result = None
+        """
+    )
+
+    selenium.run(
+        """
+        p = Promise.new(prom)
+        p.then(onfulfilled, onrejected)
+        reject(10)
+        """
+    )
+    time.sleep(0.01)
+    selenium.run(
+        """
+        assert result is None
+        assert err == 10
+        err = None
+        """
+    )
+
+    selenium.run(
+        """
+        p = Promise.new(prom)
+        p.catch(onrejected)
+        resolve(10)
+        """
+    )
+    time.sleep(0.01)
+    selenium.run("assert err is None")
+
+    selenium.run(
+        """
+        p = Promise.new(prom)
+        p.catch(onrejected)
+        reject(10)
+        """
+    )
+    time.sleep(0.01)
+    selenium.run(
+        """
+        assert err == 10
+        err = None
+        """
+    )
+
+    selenium.run(
+        """
+        p = Promise.new(prom)
+        p.finally_(onfinally)
+        resolve(10)
+        """
+    )
+    time.sleep(0.01)
+    selenium.run(
+        """
+        assert finally_occurred
+        finally_occurred = False
+        """
+    )
+
+    selenium.run(
+        """
+        p = Promise.new(prom)
+        p.finally_(onfinally)
+        reject(10)
+        """
+    )
+    time.sleep(0.01)
+    selenium.run(
+        """
+        assert finally_occurred
+        finally_occurred = False
+        """
+    )
+
+
 def test_await_fetch(selenium):
     selenium.run(
         """
diff --git a/src/tests/test_pyodide.py b/src/tests/test_pyodide.py
--- a/src/tests/test_pyodide.py
+++ b/src/tests/test_pyodide.py
@@ -255,3 +255,87 @@ def test_run_python_last_exc(selenium):
         `);
         """
     )
+
+
+def test_create_once_callable(selenium):
+    selenium.run_js(
+        """
+        window.call7 = function call7(f){
+            return f(7);
+        }
+        pyodide.runPython(`
+            from pyodide import create_once_callable, JsException
+            from js import call7;
+            from unittest import TestCase
+            raises = TestCase().assertRaisesRegex
+            class Square:
+                def __call__(self, x):
+                    return x*x
+
+                def __del__(self):
+                    global destroyed
+                    destroyed = True
+
+            f = Square()
+            import sys
+            assert sys.getrefcount(f) == 2
+            proxy = create_once_callable(f)
+            assert sys.getrefcount(f) == 3
+            assert call7(proxy) == 49
+            assert sys.getrefcount(f) == 2
+            with raises(JsException, "can only be called once"):
+                call7(proxy)
+            destroyed = False
+            del f
+            assert destroyed == True
+            del proxy # causes a fatal error =(
+        `);
+        """
+    )
+
+
+def test_create_proxy(selenium):
+    selenium.run_js(
+        """
+        window.testAddListener = function(f){
+            window.listener = f;
+        }
+        window.testCallListener = function(f){
+            return window.listener();
+        }
+        window.testRemoveListener = function(f){
+            return window.listener === f;
+        }
+        pyodide.runPython(`
+            from pyodide import create_proxy
+            from js import testAddListener, testCallListener, testRemoveListener;
+            class Test:
+                def __call__(self):
+                    return 7
+
+                def __del__(self):
+                    global destroyed
+                    destroyed = True
+
+            f = Test()
+            import sys
+            assert sys.getrefcount(f) == 2
+            proxy = create_proxy(f)
+            assert sys.getrefcount(f) == 3
+            assert proxy() == 7
+            testAddListener(proxy)
+            assert sys.getrefcount(f) == 3
+            assert testCallListener() == 7
+            assert sys.getrefcount(f) == 3
+            assert testCallListener() == 7
+            assert sys.getrefcount(f) == 3
+            assert testRemoveListener(f)
+            assert sys.getrefcount(f) == 3
+            proxy.destroy()
+            assert sys.getrefcount(f) == 2
+            destroyed = False
+            del f
+            assert destroyed == True
+        `);
+        """
+    )