Skip to content

Commit 61c718c

Browse files
committed
Merge pull request #155 from Distributive-Network/Xmader/feat/pmjs-event-loop
pmjs event loop
2 parents 7aadec4 + ca71bea commit 61c718c

File tree

8 files changed

+113
-8
lines changed

8 files changed

+113
-8
lines changed

include/PyEventLoop.hh

Lines changed: 48 additions & 2 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,9 +180,54 @@ public:
179180
*/
180181
static PyEventLoop getMainLoop();
181182

182-
protected:
183-
PyObject *_loop;
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+
// The flag should initially be set as the queue is initially empty
191+
Py_XDECREF(PyObject_CallMethod(_queueIsEmpty, "set", NULL)); // _queueIsEmpty.set()
192+
};
193+
~Lock() {
194+
Py_DECREF(_queueIsEmpty);
195+
}
184196

197+
/**
198+
* @brief Increment the counter for the number of our job functions in the Python event-loop
199+
*/
200+
inline void incCounter() {
201+
_counter++;
202+
Py_XDECREF(PyObject_CallMethod(_queueIsEmpty, "clear", NULL)); // _queueIsEmpty.clear()
203+
}
204+
205+
/**
206+
* @brief Decrement the counter for the number of our job functions in the Python event-loop
207+
*/
208+
inline void decCounter() {
209+
_counter--;
210+
if (_counter == 0) { // no job queueing
211+
// Notify that the queue is empty and awake (unblock) the event-loop shield
212+
Py_XDECREF(PyObject_CallMethod(_queueIsEmpty, "set", NULL)); // _queueIsEmpty.set()
213+
} else if (_counter < 0) { // something went wrong
214+
PyErr_SetString(PyExc_RuntimeError, "Event-loop job counter went below zero.");
215+
}
216+
}
217+
218+
/**
219+
* @brief An `asyncio.Event` instance to notify that there are no queueing asynchronous jobs
220+
* @see https://docs.python.org/3/library/asyncio-sync.html#asyncio.Event
221+
*/
222+
PyObject *_queueIsEmpty = nullptr;
223+
protected:
224+
std::atomic_int _counter = 0;
225+
};
226+
227+
static inline PyEventLoop::Lock *_locker;
228+
229+
PyObject *_loop;
230+
protected:
185231
PyEventLoop() = delete;
186232
PyEventLoop(PyObject *loop) : _loop(loop) {};
187233
private:

peter-jr

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ cd "$runDir"
2121
set -u
2222
set -o pipefail
2323
peter_exit_code=2
24+
defaultTimeout="${defaultTimeout:-15}"
2425

2526
if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then
2627
cat <<EOF
@@ -160,13 +161,14 @@ findTests \
160161
FAIL="$(red FAIL)"
161162
fi
162163
(
164+
timeout=$(egrep '^( *[*#] *timeout: )' "$file" | head -1 | sed -e 's/^\( *[*#] *timeout: \)//')
163165
case "$testType" in
164166
"simple")
165-
"${PMJS}" "$file"
167+
eval timeout -s9 "${timeout:-${defaultTimeout}}" "${PMJS}" \"$file\"
166168
exitCode="$?"
167169
;;
168170
"bash")
169-
bash "$file"
171+
eval timeout -s9 "${timeout:-${defaultTimeout}}" bash \"$file\"
170172
exitCode="$?"
171173
;;
172174
*)

python/pythonmonkey/cli/pmjs.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import sys, os, signal, getopt
77
import readline
8+
import asyncio
89
import pythonmonkey as pm
910
from pythonmonkey.lib import pmdb
1011

@@ -356,8 +357,11 @@ def main():
356357
assert False, "unhandled option"
357358

358359
if (len(args) > 0):
359-
globalInitModule.patchGlobalRequire()
360-
pm.runProgramModule(args[0], args, requirePath)
360+
async def runJS():
361+
globalInitModule.patchGlobalRequire()
362+
pm.runProgramModule(args[0], args, requirePath)
363+
await pm.wait() # blocks until all asynchronous calls finish
364+
asyncio.run(runJS())
361365
elif (enterRepl or forceRepl):
362366
globalInitModule.initReplLibs()
363367
repl()

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: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,36 @@
22

33
#include <Python.h>
44

5+
/**
6+
* @brief Wrapper to decrement the counter of queueing event-loop jobs after the job finishes
7+
*/
8+
static PyObject *eventLoopJobWrapper(PyObject *jobFn, PyObject *Py_UNUSED(_)) {
9+
PyObject *ret = PyObject_CallObject(jobFn, NULL); // jobFn()
10+
Py_XDECREF(ret); // don't care about its return value
11+
PyEventLoop::_locker->decCounter();
12+
if (PyErr_Occurred()) {
13+
return NULL;
14+
} else {
15+
Py_RETURN_NONE;
16+
}
17+
}
18+
static PyMethodDef jobWrapperDef = {"eventLoopJobWrapper", eventLoopJobWrapper, METH_NOARGS, NULL};
19+
520
PyEventLoop::AsyncHandle PyEventLoop::enqueue(PyObject *jobFn) {
21+
PyEventLoop::_locker->incCounter();
22+
PyObject *wrapper = PyCFunction_New(&jobWrapperDef, jobFn);
623
// Enqueue job to the Python event-loop
724
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_soon
8-
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_soon_threadsafe", "O", jobFn); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
25+
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_soon_threadsafe", "O", wrapper); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
926
return PyEventLoop::AsyncHandle(asyncHandle);
1027
}
1128

1229
PyEventLoop::AsyncHandle PyEventLoop::enqueueWithDelay(PyObject *jobFn, double delaySeconds) {
30+
PyEventLoop::_locker->incCounter();
31+
PyObject *wrapper = PyCFunction_New(&jobWrapperDef, jobFn);
1332
// Schedule job to the Python event-loop
1433
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_later
15-
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_later", "dO", delaySeconds, jobFn); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
34+
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_later", "dO", delaySeconds, wrapper); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
1635
if (asyncHandle == nullptr) {
1736
PyErr_Print(); // RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one
1837
}
@@ -122,6 +141,14 @@ PyEventLoop PyEventLoop::getRunningLoop() {
122141
}
123142

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

src/modules/pythonmonkey/pythonmonkey.cc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,17 @@ static PyObject *eval(PyObject *self, PyObject *args) {
268268
}
269269
}
270270

271+
static PyObject *waitForEventLoop(PyObject *Py_UNUSED(self), PyObject *Py_UNUSED(_)) {
272+
PyObject *waiter = PyEventLoop::_locker->_queueIsEmpty; // instance of asyncio.Event
273+
274+
// Making sure it's attached to the current event-loop
275+
PyEventLoop loop = PyEventLoop::getRunningLoop();
276+
if (!loop.initialized()) return NULL;
277+
PyObject_SetAttrString(waiter, "_loop", loop._loop);
278+
279+
return PyObject_CallMethod(waiter, "wait", NULL);
280+
}
281+
271282
static PyObject *isCompilableUnit(PyObject *self, PyObject *args) {
272283
StrType *buffer = new StrType(PyTuple_GetItem(args, 0));
273284
const char *bufferUtf8;
@@ -289,6 +300,7 @@ static PyObject *isCompilableUnit(PyObject *self, PyObject *args) {
289300

290301
PyMethodDef PythonMonkeyMethods[] = {
291302
{"eval", eval, METH_VARARGS, "Javascript evaluator in Python"},
303+
{"wait", waitForEventLoop, METH_NOARGS, "The event-loop shield. Blocks until all asynchronous jobs finish."},
292304
{"isCompilableUnit", isCompilableUnit, METH_VARARGS, "Hint if a string might be compilable Javascript"},
293305
{"collect", collect, METH_VARARGS, "Calls the spidermonkey garbage collector"},
294306
{NULL, NULL, 0, NULL}
@@ -404,6 +416,9 @@ PyMODINIT_FUNC PyInit_pythonmonkey(void)
404416
return NULL;
405417
}
406418

419+
// Initialize event-loop shield
420+
PyEventLoop::_locker = new PyEventLoop::Lock();
421+
407422
PyObject *internalBindingPy = getInternalBindingPyFn(GLOBAL_CX);
408423
if (PyModule_AddObject(pyModule, "internalBinding", internalBindingPy) < 0) {
409424
Py_DECREF(internalBindingPy);
File renamed without changes.

0 commit comments

Comments
 (0)