Skip to content

Commit 3e4ce63

Browse files
committed
feat(event-loop): event-loop shield that blocks until all async jobs finish
await pm.wait()
1 parent 0273996 commit 3e4ce63

File tree

4 files changed

+77
-0
lines changed

4 files changed

+77
-0
lines changed

include/PyEventLoop.hh

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
#include <Python.h>
1616
#include <vector>
1717
#include <utility>
18+
#include <atomic>
1819

1920
struct PyEventLoop {
2021
public:
@@ -179,6 +180,48 @@ public:
179180
*/
180181
static PyEventLoop getMainLoop();
181182

183+
struct Lock {
184+
public:
185+
explicit Lock() {
186+
PyObject *asyncio = PyImport_ImportModule("asyncio");
187+
_queueIsEmpty = PyObject_CallMethod(asyncio, "Event", NULL); // _queueIsEmpty = asyncio.Event()
188+
Py_DECREF(asyncio);
189+
};
190+
~Lock() {
191+
Py_DECREF(_queueIsEmpty);
192+
}
193+
194+
/**
195+
* @brief Increment the counter for the number of our job functions in the Python event-loop
196+
*/
197+
inline void incCounter() {
198+
_counter++;
199+
Py_XDECREF(PyObject_CallMethod(_queueIsEmpty, "clear", NULL)); // _queueIsEmpty.clear()
200+
}
201+
202+
/**
203+
* @brief Decrement the counter for the number of our job functions in the Python event-loop
204+
*/
205+
inline void decCounter() {
206+
_counter--;
207+
if (_counter == 0) { // no job queueing
208+
// Notify that the queue is empty and awake (unblock) the event-loop shield
209+
Py_XDECREF(PyObject_CallMethod(_queueIsEmpty, "set", NULL)); // _queueIsEmpty.set()
210+
} else if (_counter < 0) { // something went wrong
211+
PyErr_SetString(PyExc_RuntimeError, "Event-loop job counter went below zero.");
212+
}
213+
}
214+
215+
/**
216+
* @brief An `asyncio.Event` instance to notify that there are no queueing asynchronous jobs
217+
* @see https://docs.python.org/3/library/asyncio-sync.html#asyncio.Event
218+
*/
219+
PyObject *_queueIsEmpty = nullptr;
220+
protected:
221+
std::atomic_int _counter = 0;
222+
};
223+
224+
static inline PyEventLoop::Lock *_locker;
182225
protected:
183226
PyObject *_loop;
184227

python/pythonmonkey/pythonmonkey.pyi

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ def eval(code: str, evalOpts: EvalOptions = {}, /) -> _typing.Any:
2222
JavaScript evaluator in Python
2323
"""
2424

25+
def wait() -> _typing.Awaitable[None]:
26+
"""
27+
Block until all asynchronous jobs (Promise/setTimeout/etc.) finish.
28+
29+
```py
30+
await pm.wait()
31+
```
32+
33+
This is the event-loop shield that protects the loop from being prematurely terminated.
34+
"""
35+
2536
def isCompilableUnit(code: str) -> bool:
2637
"""
2738
Hint if a string might be compilable Javascript without actual evaluation

src/PyEventLoop.cc

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22

33
#include <Python.h>
44

5+
/**
6+
* @brief Wrapper to decrement the counter of queueing event-loop jobs after the job finishes
7+
*/
58
static PyObject *eventLoopJobWrapper(PyObject *jobFn, PyObject *Py_UNUSED(_)) {
69
PyObject *ret = PyObject_CallObject(jobFn, NULL); // jobFn()
710
Py_XDECREF(ret); // don't care about its return value
11+
PyEventLoop::_locker->decCounter();
812
Py_RETURN_NONE;
913
}
1014
static PyMethodDef jobWrapperDef = {"eventLoopJobWrapper", eventLoopJobWrapper, METH_NOARGS, NULL};
1115

1216
PyEventLoop::AsyncHandle PyEventLoop::enqueue(PyObject *jobFn) {
17+
PyEventLoop::_locker->incCounter();
1318
PyObject *wrapper = PyCFunction_New(&jobWrapperDef, jobFn);
1419
// Enqueue job to the Python event-loop
1520
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_soon
@@ -18,6 +23,7 @@ PyEventLoop::AsyncHandle PyEventLoop::enqueue(PyObject *jobFn) {
1823
}
1924

2025
PyEventLoop::AsyncHandle PyEventLoop::enqueueWithDelay(PyObject *jobFn, double delaySeconds) {
26+
PyEventLoop::_locker->incCounter();
2127
PyObject *wrapper = PyCFunction_New(&jobWrapperDef, jobFn);
2228
// Schedule job to the Python event-loop
2329
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_later
@@ -131,6 +137,14 @@ PyEventLoop PyEventLoop::getRunningLoop() {
131137
}
132138

133139
void PyEventLoop::AsyncHandle::cancel() {
140+
PyObject *scheduled = PyObject_GetAttrString(_handle, "_scheduled"); // this attribute only exists on asyncio.TimerHandle returned by loop.call_later
141+
// NULL if no such attribute (on a strict asyncio.Handle returned by loop.call_soon)
142+
bool finishedOrCanceled = scheduled && scheduled == Py_False; // the job function has already been executed or canceled
143+
if (!finishedOrCanceled) {
144+
PyEventLoop::_locker->decCounter();
145+
}
146+
Py_XDECREF(scheduled);
147+
134148
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.Handle.cancel
135149
PyObject *ret = PyObject_CallMethod(_handle, "cancel", NULL); // returns None
136150
Py_XDECREF(ret);

src/modules/pythonmonkey/pythonmonkey.cc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,15 @@ PyMODINIT_FUNC PyInit_pythonmonkey(void)
404404
return NULL;
405405
}
406406

407+
// Initialize event-loop shield
408+
PyEventLoop::_locker = new PyEventLoop::Lock();
409+
PyObject *waiter = PyObject_GetAttrString(PyEventLoop::_locker->_queueIsEmpty /* instance of asyncio.Event*/, "wait");
410+
if (!waiter || PyModule_AddObject(pyModule, "wait", waiter) < 0) {
411+
Py_XDECREF(waiter);
412+
Py_DECREF(pyModule);
413+
return NULL;
414+
}
415+
407416
PyObject *internalBindingPy = getInternalBindingPyFn(GLOBAL_CX);
408417
if (PyModule_AddObject(pyModule, "internalBinding", internalBindingPy) < 0) {
409418
Py_DECREF(internalBindingPy);

0 commit comments

Comments
 (0)