Skip to content

Commit 9903362

Browse files
authored
Merge pull request #311 from Distributive-Network/Xmader/feat/timers-setInterval
Implement `setInterval` functions & Fix timers memory bug
2 parents 64ba391 + 0df0007 commit 9903362

File tree

9 files changed

+219
-48
lines changed

9 files changed

+219
-48
lines changed

include/PyEventLoop.hh

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ public:
3232
*/
3333
struct AsyncHandle {
3434
using id_t = uint32_t;
35-
using id_ptr_pair = std::pair<id_t, AsyncHandle *>;
3635
public:
3736
explicit AsyncHandle(PyObject *handle) : _handle(handle) {};
3837
AsyncHandle(const AsyncHandle &old) = delete; // forbid copy-initialization
@@ -42,16 +41,29 @@ public:
4241
Py_XDECREF(_handle);
4342
}
4443
}
45-
static inline id_ptr_pair newEmpty() {
44+
45+
/**
46+
* @brief Create a new `AsyncHandle` without an associated `asyncio.Handle` Python object
47+
* @return the timeoutId
48+
*/
49+
static inline id_t newEmpty() {
4650
auto handle = AsyncHandle(Py_None);
47-
return AsyncHandle::getUniqueIdAndPtr(std::move(handle));
51+
return AsyncHandle::getUniqueId(std::move(handle));
4852
}
4953

5054
/**
5155
* @brief Cancel the scheduled event-loop job.
5256
* If the job has already been canceled or executed, this method has no effect.
5357
*/
5458
void cancel();
59+
/**
60+
* @return true if the job has been cancelled.
61+
*/
62+
bool cancelled();
63+
/**
64+
* @return true if the job function has already been executed or cancelled.
65+
*/
66+
bool _finishedOrCancelled();
5567

5668
/**
5769
* @brief Get the unique `timeoutID` for JS `setTimeout`/`clearTimeout` methods
@@ -69,11 +81,6 @@ public:
6981
return nullptr; // invalid timeoutID
7082
}
7183
}
72-
static inline id_ptr_pair getUniqueIdAndPtr(AsyncHandle &&handle) {
73-
auto timeoutID = getUniqueId(std::move(handle));
74-
auto ptr = fromId(timeoutID);
75-
return std::make_pair(timeoutID, ptr);
76-
}
7784

7885
/**
7986
* @brief Get the underlying `asyncio.Handle` Python object
@@ -104,7 +111,9 @@ public:
104111
inline void addRef() {
105112
if (!_refed) {
106113
_refed = true;
107-
PyEventLoop::_locker->incCounter();
114+
if (!_finishedOrCancelled()) { // noop if the timer is finished or canceled
115+
PyEventLoop::_locker->incCounter();
116+
}
108117
}
109118
}
110119

@@ -132,9 +141,10 @@ public:
132141
* @brief Schedule a job to the Python event-loop, with the given delay
133142
* @param jobFn - The JS event-loop job converted to a Python function
134143
* @param delaySeconds - The job function will be called after the given number of seconds
135-
* @return the timeoutId and a pointer to the AsyncHandle
144+
* @param repeat - If true, the job will be executed repeatedly on a fixed interval
145+
* @return the timeoutId
136146
*/
137-
[[nodiscard]] AsyncHandle::id_ptr_pair enqueueWithDelay(PyObject *jobFn, double delaySeconds);
147+
[[nodiscard]] AsyncHandle::id_t enqueueWithDelay(PyObject *jobFn, double delaySeconds, bool repeat);
138148

139149
/**
140150
* @brief C++ wrapper for Python `asyncio.Future` class

python/pythonmonkey/builtin_modules/internal-binding.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,14 @@ declare function internalBinding(namespace: "utils"): {
3434

3535
declare function internalBinding(namespace: "timers"): {
3636
/**
37-
* internal binding helper for the `setTimeout` global function
37+
* internal binding helper for the `setTimeout`/`setInterval` global functions
3838
*
3939
* **UNSAFE**, does not perform argument type checks
40+
*
41+
* @param repeat The call is to `setInterval` if true
4042
* @return timeoutId
4143
*/
42-
enqueueWithDelay(handler: Function, delaySeconds: number): number;
44+
enqueueWithDelay(handler: Function, delaySeconds: number, repeat: boolean): number;
4345

4446
/**
4547
* internal binding helper for the `clearTimeout` global function

python/pythonmonkey/builtin_modules/timers.js

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const {
1515
timerRemoveRef,
1616
} = internalBinding('timers');
1717

18+
const { DOMException } = require('dom-exception');
19+
1820
/**
1921
* Implement Node.js-style `timeoutId` class returned from setTimeout() and setInterval()
2022
* @see https://nodejs.org/api/timers.html#class-timeout
@@ -63,11 +65,16 @@ class Timeout
6365
}
6466

6567
/**
66-
* @returns a number that can be used to reference this timeout
68+
* Sets the timer's start time to the current time,
69+
* and reschedules the timer to call its callback at the previously specified duration adjusted to the current time.
70+
*
71+
* Using this on a timer that has already called its callback will reactivate the timer.
6772
*/
68-
[Symbol.toPrimitive]()
73+
refresh()
6974
{
70-
return this.#numericId;
75+
throw new DOMException('Timeout.refresh() method is not supported by PythonMonkey.', 'NotSupportedError');
76+
// TODO: Do we really need to closely resemble the Node.js API?
77+
// This one is not easy to implement since we need to memorize the callback function and delay parameters in every `setTimeout`/`setInterval` call.
7178
}
7279

7380
/**
@@ -78,18 +85,23 @@ class Timeout
7885
{
7986
return clearTimeout(this);
8087
}
88+
89+
/**
90+
* @returns a number that can be used to reference this timeout
91+
*/
92+
[Symbol.toPrimitive]()
93+
{
94+
return this.#numericId;
95+
}
8196
}
8297

8398
/**
84-
* Implement the `setTimeout` global function
85-
* @see https://developer.mozilla.org/en-US/docs/Web/API/setTimeout and
86-
* @see https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
99+
* Normalize the arguments to `setTimeout` or `setInterval`
87100
* @param {Function | string} handler
88-
* @param {number} delayMs timeout milliseconds, use value of 0 if this is omitted
89-
* @param {any[]} args additional arguments to be passed to the `handler`
90-
* @return {Timeout} timeoutId
101+
* @param {number} delayMs timeout milliseconds
102+
* @param {any[]} additionalArgs additional arguments to be passed to the `handler`
91103
*/
92-
function setTimeout(handler, delayMs = 0, ...args)
104+
function _normalizeTimerArgs(handler, delayMs, additionalArgs)
93105
{
94106
// Ensure the first parameter is a function
95107
// We support passing a `code` string to `setTimeout` as the callback function
@@ -102,7 +114,7 @@ function setTimeout(handler, delayMs = 0, ...args)
102114
// Wrap the job function into a bound function with the given additional arguments
103115
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind
104116
/** @type {Function} */
105-
const boundHandler = handler.bind(thisArg, ...args);
117+
const boundHandler = handler.bind(thisArg, ...additionalArgs);
106118

107119
// Get the delay time in seconds
108120
// JS `setTimeout` takes milliseconds, but Python takes seconds
@@ -111,7 +123,22 @@ function setTimeout(handler, delayMs = 0, ...args)
111123
delayMs = 0; // as spec-ed
112124
const delaySeconds = delayMs / 1000; // convert ms to s
113125

114-
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds));
126+
return { boundHandler, delaySeconds };
127+
}
128+
129+
/**
130+
* Implement the `setTimeout` global function
131+
* @see https://developer.mozilla.org/en-US/docs/Web/API/setTimeout and
132+
* @see https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
133+
* @param {Function | string} handler
134+
* @param {number} delayMs timeout milliseconds, use value of 0 if this is omitted
135+
* @param {any[]} args additional arguments to be passed to the `handler`
136+
* @return {Timeout} timeoutId
137+
*/
138+
function setTimeout(handler, delayMs = 0, ...args)
139+
{
140+
const { boundHandler, delaySeconds } = _normalizeTimerArgs(handler, delayMs, args);
141+
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds, false));
115142
}
116143

117144
/**
@@ -130,13 +157,43 @@ function clearTimeout(timeoutId)
130157
return cancelByTimeoutId(Number(timeoutId));
131158
}
132159

160+
/**
161+
* Implement the `setInterval` global function
162+
* @see https://developer.mozilla.org/en-US/docs/Web/API/setInterval and
163+
* @see https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-setinterval
164+
* @param {Function | string} handler
165+
* @param {number} delayMs timeout milliseconds, use value of 0 if this is omitted
166+
* @param {any[]} args additional arguments to be passed to the `handler`
167+
* @return {Timeout} timeoutId
168+
*/
169+
function setInterval(handler, delayMs = 0, ...args)
170+
{
171+
const { boundHandler, delaySeconds } = _normalizeTimerArgs(handler, delayMs, args);
172+
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds, true));
173+
}
174+
175+
/**
176+
* Implement the `clearInterval` global function
177+
* @alias to `clearTimeout`
178+
* @see https://developer.mozilla.org/en-US/docs/Web/API/clearInterval
179+
*/
180+
const clearInterval = clearTimeout;
181+
133182
// expose the `Timeout` class
134183
setTimeout.Timeout = Timeout;
184+
setInterval.Timeout = Timeout;
135185

136186
if (!globalThis.setTimeout)
137187
globalThis.setTimeout = setTimeout;
138188
if (!globalThis.clearTimeout)
139189
globalThis.clearTimeout = clearTimeout;
140190

191+
if (!globalThis.setInterval)
192+
globalThis.setInterval = setInterval;
193+
if (!globalThis.clearInterval)
194+
globalThis.clearInterval = clearInterval;
195+
141196
exports.setTimeout = setTimeout;
142197
exports.clearTimeout = clearTimeout;
198+
exports.setInterval = setInterval;
199+
exports.clearInterval = clearInterval;

python/pythonmonkey/cli/pmjs.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,10 +343,17 @@ def main():
343343
elif o in ("-i", "--interactive"):
344344
forceRepl = True
345345
elif o in ("-e", "--eval"):
346-
pm.eval(a, evalOpts)
346+
async def runEval():
347+
pm.eval(a, evalOpts)
348+
await pm.wait()
349+
asyncio.run(runEval())
347350
enterRepl = False
348351
elif o in ("-p", "--print"):
349-
print(pm.eval(a, evalOpts))
352+
async def runEvalPrint():
353+
ret = pm.eval(a, evalOpts)
354+
pm.eval("ret => console.log(ret)", evalOpts)(ret)
355+
await pm.wait()
356+
asyncio.run(runEvalPrint())
350357
enterRepl = False
351358
elif o in ("-r", "--require"):
352359
globalThis.require(a)

python/pythonmonkey/global.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ declare var btoa: typeof import("base64").btoa;
5656
// Expose `setTimeout`/`clearTimeout` APIs
5757
declare var setTimeout: typeof import("timers").setTimeout;
5858
declare var clearTimeout: typeof import("timers").clearTimeout;
59+
// Expose `setInterval`/`clearInterval` APIs
60+
declare var setInterval: typeof import("timers").setInterval;
61+
declare var clearInterval: typeof import("timers").clearInterval;
5962

6063
// Expose `URL`/`URLSearchParams` APIs
6164
declare var URL: typeof import("url").URL;

src/PyEventLoop.cc

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,37 @@ static PyObject *eventLoopJobWrapper(PyObject *jobFn, PyObject *Py_UNUSED(_)) {
2828
}
2929
static PyMethodDef loopJobWrapperDef = {"eventLoopJobWrapper", eventLoopJobWrapper, METH_NOARGS, NULL};
3030

31+
static PyObject *_enqueueWithDelay(PyObject *_loop, PyEventLoop::AsyncHandle::id_t handleId, PyObject *jobFn, double delaySeconds, bool repeat);
32+
3133
/**
3234
* @brief Wrapper to remove the reference of the timer after the job finishes
3335
*/
34-
static PyObject *timerJobWrapper(PyObject *jobFn, PyObject *handlerPtr) {
35-
auto handle = (PyEventLoop::AsyncHandle *)PyLong_AsVoidPtr(handlerPtr);
36+
static PyObject *timerJobWrapper(PyObject *jobFn, PyObject *args) {
37+
PyObject *_loop = PyTuple_GetItem(args, 0);
38+
PyEventLoop::AsyncHandle::id_t handleId = PyLong_AsLong(PyTuple_GetItem(args, 1));
39+
double delaySeconds = PyFloat_AsDouble(PyTuple_GetItem(args, 2));
40+
bool repeat = (bool)PyLong_AsLong(PyTuple_GetItem(args, 3));
41+
auto handle = PyEventLoop::AsyncHandle::fromId(handleId);
42+
3643
PyObject *ret = PyObject_CallObject(jobFn, NULL); // jobFn()
3744
Py_XDECREF(ret); // don't care about its return value
38-
handle->removeRef();
39-
if (PyErr_Occurred()) {
45+
46+
PyObject *errType, *errValue, *traceback; // we can't call any Python code unless the error indicator is clear
47+
PyErr_Fetch(&errType, &errValue, &traceback);
48+
if (repeat && !handle->cancelled()) {
49+
_enqueueWithDelay(_loop, handleId, jobFn, delaySeconds, repeat);
50+
} else {
51+
handle->removeRef();
52+
}
53+
54+
if (errType != NULL) { // PyErr_Occurred()
55+
PyErr_Restore(errType, errValue, traceback);
4056
return NULL;
4157
} else {
4258
Py_RETURN_NONE;
4359
}
4460
}
45-
static PyMethodDef timerJobWrapperDef = {"timerJobWrapper", timerJobWrapper, METH_O, NULL};
61+
static PyMethodDef timerJobWrapperDef = {"timerJobWrapper", timerJobWrapper, METH_VARARGS, NULL};
4662

4763
PyEventLoop::AsyncHandle PyEventLoop::enqueue(PyObject *jobFn) {
4864
PyEventLoop::_locker->incCounter();
@@ -53,20 +69,28 @@ PyEventLoop::AsyncHandle PyEventLoop::enqueue(PyObject *jobFn) {
5369
return PyEventLoop::AsyncHandle(asyncHandle);
5470
}
5571

56-
PyEventLoop::AsyncHandle::id_ptr_pair PyEventLoop::enqueueWithDelay(PyObject *jobFn, double delaySeconds) {
57-
auto handler = PyEventLoop::AsyncHandle::newEmpty();
72+
static PyObject *_enqueueWithDelay(PyObject *_loop, PyEventLoop::AsyncHandle::id_t handleId, PyObject *jobFn, double delaySeconds, bool repeat) {
5873
PyObject *wrapper = PyCFunction_New(&timerJobWrapperDef, jobFn);
59-
PyObject *handlerPtr = PyLong_FromVoidPtr(handler.second);
6074
// Schedule job to the Python event-loop
6175
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_later
62-
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_later", "dOO", delaySeconds, wrapper, handlerPtr); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
63-
if (asyncHandle == nullptr) {
76+
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
77+
if (!asyncHandle) {
78+
return nullptr; // RuntimeError
79+
}
80+
81+
auto handle = PyEventLoop::AsyncHandle::fromId(handleId);
82+
Py_XDECREF(handle->swap(asyncHandle));
83+
handle->addRef();
84+
85+
return asyncHandle;
86+
}
87+
88+
PyEventLoop::AsyncHandle::id_t PyEventLoop::enqueueWithDelay(PyObject *jobFn, double delaySeconds, bool repeat) {
89+
auto handleId = PyEventLoop::AsyncHandle::newEmpty();
90+
if (!_enqueueWithDelay(_loop, handleId, jobFn, delaySeconds, repeat)) {
6491
PyErr_Print(); // RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one
65-
return handler;
6692
}
67-
handler.second->swap(asyncHandle);
68-
handler.second->addRef();
69-
return handler;
93+
return handleId;
7094
}
7195

7296
PyEventLoop::Future PyEventLoop::createFuture() {
@@ -172,19 +196,31 @@ PyEventLoop PyEventLoop::getRunningLoop() {
172196
}
173197

174198
void PyEventLoop::AsyncHandle::cancel() {
175-
PyObject *scheduled = PyObject_GetAttrString(_handle, "_scheduled"); // this attribute only exists on asyncio.TimerHandle returned by loop.call_later
176-
// NULL if no such attribute (on a strict asyncio.Handle returned by loop.call_soon)
177-
bool finishedOrCanceled = scheduled && scheduled == Py_False; // the job function has already been executed or canceled
178-
if (!finishedOrCanceled) {
199+
if (!_finishedOrCancelled()) {
179200
removeRef(); // automatically unref at finish
180201
}
181-
Py_XDECREF(scheduled);
182202

183203
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.Handle.cancel
184204
PyObject *ret = PyObject_CallMethod(_handle, "cancel", NULL); // returns None
185205
Py_XDECREF(ret);
186206
}
187207

208+
bool PyEventLoop::AsyncHandle::cancelled() {
209+
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.Handle.cancelled
210+
PyObject *ret = PyObject_CallMethod(_handle, "cancelled", NULL); // returns Python bool
211+
bool cancelled = ret == Py_True;
212+
Py_XDECREF(ret);
213+
return cancelled;
214+
}
215+
216+
bool PyEventLoop::AsyncHandle::_finishedOrCancelled() {
217+
PyObject *scheduled = PyObject_GetAttrString(_handle, "_scheduled"); // this attribute only exists on asyncio.TimerHandle returned by loop.call_later
218+
// NULL if no such attribute (on a strict asyncio.Handle returned by loop.call_soon)
219+
bool notScheduled = scheduled && scheduled == Py_False; // not scheduled means the job function has already been executed or canceled
220+
Py_XDECREF(scheduled);
221+
return notScheduled;
222+
}
223+
188224
void PyEventLoop::Future::setResult(PyObject *result) {
189225
// https://docs.python.org/3/library/asyncio-future.html#asyncio.Future.set_result
190226
PyObject *ret = PyObject_CallMethod(_future, "set_result", "O", result); // returns None

src/internalBinding/timers.cc

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,19 @@ static bool enqueueWithDelay(JSContext *cx, unsigned argc, JS::Value *vp) {
2323
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
2424
JS::HandleValue jobArgVal = args.get(0);
2525
double delaySeconds = args.get(1).toNumber();
26+
bool repeat = args.get(2).toBoolean();
2627

2728
// Convert to a Python function
2829
JS::RootedValue jobArg(cx, jobArgVal);
2930
PyObject *job = pyTypeFactory(cx, jobArg)->getPyObject();
3031
// Schedule job to the running Python event-loop
3132
PyEventLoop loop = PyEventLoop::getRunningLoop();
3233
if (!loop.initialized()) return false;
33-
PyEventLoop::AsyncHandle::id_ptr_pair handler = loop.enqueueWithDelay(job, delaySeconds);
34+
PyEventLoop::AsyncHandle::id_t handleId = loop.enqueueWithDelay(job, delaySeconds, repeat);
35+
Py_DECREF(job);
3436

3537
// Return the `timeoutID` to use in `clearTimeout`
36-
args.rval().setNumber(handler.first);
38+
args.rval().setNumber(handleId);
3739
return true;
3840
}
3941

0 commit comments

Comments
 (0)