From 53a2741d8e757bee369a27cf583cda78568a5187 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Steinn=20Eldj=C3=A1rn=20Sigur=C3=B0arson?= Date: Mon, 15 Jun 2026 23:17:21 +0000 Subject: [PATCH] fix(event-loop): release job wrapper ref after scheduling call_soon_threadsafe and call_later take their own reference on the job wrapper; the creation reference was never released, pinning the JSFunctionProxy job closure and the buffers it captured. Add the missing Py_DECREF in enqueue and _enqueueWithDelay. --- src/PyEventLoop.cc | 2 ++ tests/python/test_event_loop.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/PyEventLoop.cc b/src/PyEventLoop.cc index 14b6f003..b25c4696 100644 --- a/src/PyEventLoop.cc +++ b/src/PyEventLoop.cc @@ -74,6 +74,7 @@ PyEventLoop::AsyncHandle PyEventLoop::enqueue(PyObject *jobFn) { // Enqueue job to the Python event-loop // https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_soon PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_soon_threadsafe", "O", wrapper); + Py_DECREF(wrapper); // the Handle owns the wrapper now; release our ref so jobFn is freed after the job runs return PyEventLoop::AsyncHandle(asyncHandle); } @@ -82,6 +83,7 @@ static PyObject *_enqueueWithDelay(PyObject *_loop, PyEventLoop::AsyncHandle::id // Schedule job to the Python event-loop // https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_later PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_later", "dOOIdb", delaySeconds, wrapper, _loop, handleId, delaySeconds, repeat); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue + Py_DECREF(wrapper); // the TimerHandle now owns the wrapper; release our creation ref if (!asyncHandle) { return nullptr; // RuntimeError } diff --git a/tests/python/test_event_loop.py b/tests/python/test_event_loop.py index 2e4215ea..6ba1724b 100644 --- a/tests/python/test_event_loop.py +++ b/tests/python/test_event_loop.py @@ -1,6 +1,7 @@ import pytest import pythonmonkey as pm import asyncio +import gc def test_setTimeout_unref(): @@ -433,3 +434,20 @@ async def async_fn(): # making sure the async_fn is run return True assert asyncio.run(async_fn()) + + +def test_promise_jobs_release_job_wrappers(): + schedule = pm.eval("() => Promise.resolve().then(() => 0)") + awaits = 50 + + async def drive(): + for _ in range(awaits): + await schedule() + return sum(1 for o in gc.get_objects() + if getattr(o, "__name__", "") == "eventLoopJobWrapper") + + leaked = asyncio.run(drive()) + assert leaked == 0, ( + f"{leaked} eventLoopJobWrapper objects retained after {awaits} awaits; " + f"each settled promise job's wrapper must be released once the job has run" + )