Repository: pyodide/pyodide

Issue:
Use new Javascript GC apis to leak less memory
@phorward has discussed here #693 the fact that we leak lots of memory. With `FinalizationRegistry` and `WeakRef` we can do better. We must still leak general Python ==> javascript ==> Python reference loops (I believe I can prove that it is theoretically impossible to detect these with the available APIs unless we are allowed to only use javascript objects produced by pyodide), but there are two cases that we can handle:
1. the case when no reference loops cross the javascript / python border, and
2. the case when the js objects involved in the reference loop are only referenced in ways that the Python GC knows about (so the javascript objects are owned by Python).

Case 1 is simple, case 2 is complicated (we'd have to implement a mark and sweep algorithm) and definitely not worth doing except for fun.
One issue that would come up with testing these things is that Javascript gives no way to ask for the garbage collector to run. So there's no way to get a deterministic expected behavior -- how do you test "Javascript will eventually do this"?


Discussion:
Yes, utilizing `FinalizationRegistry` would be good to automate reference count decrementing of Python created objects when they go out of scope on the JS side. Right now, there is the manual approach to do this using the [`.destroy()`](https://pyodide.readthedocs.io/en/latest/type_conversions.html?highlight=.destroy()#python-from-javascript) method from the JS side, which was necessitated before the availability of `FinalizationRegistry` . Of course, the manual `.destroy()` method doesn't solve the issue raised in #693, but using `FinalizationRegistry` should allow us to fix this. However, to make this work correctly, we'll need to get the reference counting correct for Python objects used on the javsacript side. If the same python is sent to javascript multiple times, its reference count in python needs to be increased each time. This is currently not handled correctly (I initially thought this was due to #708, but the same thing happens in pyodide v15, which was before #708). The following example has an error when the first reference to the Python object is destroyed while the second reference is still in scope. This will need to be fixed for `FinalizationRegistry` to work properly otherwise Python may garbage collect objects that are still in scope on the JS side like in the example below.


```html
<!DOCTYPE html>
<html>
  <head>
      <script type="text/javascript">
          // set the pyodide files URL (packages.json, pyodide.asm.data etc)
          window.languagePluginUrl = 'https://cdn.jsdelivr.net/pyodide/v0.16.1/full/';
      </script>
      <script src="https://cdn.jsdelivr.net/pyodide/v0.16.1/full/pyodide.js"></script>
  </head>
  <body>
    Pyodide test page <br>
    Open your browser console to see pyodide output
    <script type="text/javascript">
          languagePluginLoader.then(function () {
            ref1 = pyodide.runPython('a=10');
            ref2 = pyodide.runPython('a');

            console.log('first try:', ref2);

            // manually decrement the python reference count
            // this was the only option before FinalizationRegistry was available
            // ref2 should still be enough to keep the python object around
            ref1.destroy();

            // force python garbage collection
            pyodide.runPython(`
import gc
gc.collect()
            `);

            console.log('second try:', ref2); // results in error: TypeError: ref1 is undefined
          });
    </script>
  </body>
</html>
```
@mgreminger Thanks for pointing out that bug. I think that we should probably do actual refcounting rather than the current approach which is a bit naive. On a related note, I also want to add `WeakPyProxy` and `WeakJsProxy` APIs.
I don't understand this code though:
```javascript
ref1 = pyodide.runPython('a=10');
ref2 = pyodide.runPython('a');
// ...
ref1.destroy();
```
The first call returns `None` and py2js converts `None` ==> `undefined` so `ref1` should be `undefined`. The second call returns `10` (an `int`) and `py2js` converts `int` ==> `number` so `ref2` should javascript `10`. Then `ref1.destroy();` will throw a reference error.

It's clear though how to make a correct example that probably does what you claim.
Thanks @hoodmane for the correction. Yes, you're absolutely correct, I was trying to destroy undefined which was causing the error. All of this only applies to custom Python objects anyway so I shouldn't have use an integer as an example either. The following code actually correctly illustrates the issue using a function as the python object:

```html
<!DOCTYPE html>
<html>
  <head>
      <script type="text/javascript">
          // set the pyodide files URL (packages.json, pyodide.asm.data etc)
          window.languagePluginUrl = 'https://cdn.jsdelivr.net/pyodide/v0.16.1/full/';
      </script>
      <script src="https://cdn.jsdelivr.net/pyodide/v0.16.1/full/pyodide.js"></script>
  </head>
  <body>
    Pyodide test page <br>
    Open your browser console to see pyodide output
    <script type="text/javascript">
          languagePluginLoader.then(function () {
            ref1 = pyodide.runPython('a=lambda x: 2*x; a');
            ref2 = pyodide.runPython('a');

            console.log('first try:', ref2(10)); // prints 20

            // manually decrement the python reference count
            // this was the only option before FinalizationRegistry was available
            // ref2 should still be enough to keep the python object around
            ref1.destroy();

            // force python garbage collection
            pyodide.runPython(`
import gc
gc.collect()
            `);

            console.log('second try:', ref2(20)); // results in error: Object has already been destroyed
          });
    </script>
  </body>
</html>
```

Resolving patch:
diff --git a/conftest.py b/conftest.py
--- a/conftest.py
+++ b/conftest.py
@@ -318,7 +318,7 @@ def get_driver(self):
         options = Options()
         options.add_argument("--headless")
         options.add_argument("--no-sandbox")
-
+        options.add_argument("--js-flags=--expose-gc")
         return Chrome(options=options)
 
 


Test patch:
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
@@ -366,7 +366,7 @@ def __del__(self):
             assert sys.getrefcount(f) == 3
             assert testCallListener() == 7
             assert sys.getrefcount(f) == 3
-            assert testRemoveListener(f)
+            assert testRemoveListener(proxy)
             assert sys.getrefcount(f) == 3
             proxy.destroy()
             assert sys.getrefcount(f) == 2
diff --git a/src/tests/test_pyproxy.py b/src/tests/test_pyproxy.py
--- a/src/tests/test_pyproxy.py
+++ b/src/tests/test_pyproxy.py
@@ -95,8 +95,8 @@ def pyfunc(*args, **kwargs):
 
         result.push([getRefCount(), 3])
         pyodide.runPython(`
-            window.jsfunc(pyfunc) # re-used existing PyProxy
-            window.jsfunc(pyfunc) # re-used existing PyProxy
+            window.jsfunc(pyfunc) # create new PyProxy
+            window.jsfunc(pyfunc) # create new PyProxy
         `)
 
         // the refcount should be 3 because:
@@ -104,7 +104,7 @@ def pyfunc(*args, **kwargs):
         // 1. pyfunc exists
         // 2. one reference from PyProxy to pyfunc is alive
         // 3. pyfunc is referenced from the sys.getrefcount()-test
-        result.push([getRefCount(), 3]);
+        result.push([getRefCount(), 5]);
         return result;
         """
     )
@@ -461,6 +461,142 @@ def __len__(self):
     )
 
 
+def test_pyproxy_gc(selenium):
+    if selenium.browser != "chrome":
+        pytest.skip("No gc exposed")
+
+    # Two ways to trigger garbage collection in Chrome:
+    # 1. options.add_argument("--js-flags=--expose-gc") in conftest, and use
+    #    gc() in javascript.
+    # 2. selenium.driver.execute_cdp_cmd("HeapProfiler.collectGarbage", {})
+    #
+    # Unclear how to trigger gc in Firefox. Possible to do this by navigating to
+    # "about:memory" and then triggering a button press to the "GC" button, but
+    # that seems too annoying.
+
+    selenium.run_js(
+        """
+        window.x = new FinalizationRegistry((val) => { window.val = val; });
+        x.register({}, 77);
+        gc();
+        """
+    )
+    assert selenium.run_js("return window.val;") == 77
+
+    selenium.run_js(
+        """
+        window.res = new Map();
+
+        let d = pyodide.runPython(`
+            from js import res
+            def get_ref_count(x):
+                res[x] = sys.getrefcount(d)
+                return res[x]
+
+            import sys
+            class Test:
+                def __del__(self):
+                    res["destructor_ran"] = True
+
+                def get(self):
+                    return 7
+
+            d = Test()
+            get_ref_count(0)
+            d
+        `);
+        let get_ref_count = pyodide.globals.get("get_ref_count");
+        get_ref_count(1);
+        d.get();
+        get_ref_count(2);
+        d.get();
+        """
+    )
+    selenium.driver.execute_cdp_cmd("HeapProfiler.collectGarbage", {})
+
+    selenium.run(
+        """
+        get_ref_count(3)
+        del d
+        """
+    )
+    selenium.driver.execute_cdp_cmd("HeapProfiler.collectGarbage", {})
+    a = selenium.run_js("return Array.from(res.entries());")
+    assert dict(a) == {0: 2, 1: 3, 2: 4, 3: 2, "destructor_ran": True}
+
+
+def test_pyproxy_gc_destroy(selenium):
+    if selenium.browser != "chrome":
+        pytest.skip("No gc exposed")
+
+    selenium.run_js(
+        """
+        window.res = new Map();
+        let d = pyodide.runPython(`
+            from js import res
+            def get_ref_count(x):
+                res[x] = sys.getrefcount(d)
+                return res[x]
+            import sys
+            class Test:
+                def __del__(self):
+                    res["destructor_ran"] = True
+
+                def get(self):
+                    return 7
+
+            d = Test()
+            get_ref_count(0)
+            d
+        `);
+        let get_ref_count = pyodide.globals.get("get_ref_count");
+        get_ref_count(1);
+        d.get();
+        get_ref_count(2);
+        d.get();
+        get_ref_count(3);
+        d.destroy();
+        get_ref_count(4);
+        gc();
+        get_ref_count(5);
+        """
+    )
+    selenium.driver.execute_cdp_cmd("HeapProfiler.collectGarbage", {})
+    selenium.run(
+        """
+        get_ref_count(6)
+        del d
+        """
+    )
+    a = selenium.run_js("return Array.from(res.entries());")
+    assert dict(a) == {
+        0: 2,
+        1: 3,
+        2: 4,
+        3: 5,
+        4: 4,
+        5: 4,
+        6: 2,
+        "destructor_ran": True,
+    }
+
+
+def test_pyproxy_copy(selenium):
+    result = selenium.run_js(
+        """
+        let result = [];
+        let a = pyodide.runPython(`d = { 1 : 2}; d`);
+        let b = pyodide.runPython(`d`);
+        result.push(a.get(1));
+        a.destroy();
+        result.push(b.get(1));
+        return result;
+        """
+    )
+    assert result[0] == 2
+    assert result[1] == 2
+
+
 def test_errors(selenium):
     selenium.run_js(
         """
diff --git a/src/tests/test_python.py b/src/tests/test_python.py
--- a/src/tests/test_python.py
+++ b/src/tests/test_python.py
@@ -42,14 +42,6 @@ def test_globals_get_multiple(selenium):
     selenium.run_js("pyodide.globals.get('v')")
 
 
-def test_globals_get_same(selenium):
-    """See #382"""
-    selenium.run("def func(): return 42")
-    assert selenium.run_js(
-        "return pyodide.globals.get('func') == pyodide.globals.get('func')"
-    )
-
-
 def test_open_url(selenium, httpserver):
     httpserver.expect_request("/data").respond_with_data(
         b"HELLO", content_type="text/text", headers={"Access-Control-Allow-Origin": "*"}