Skip to content

Commit 9a3a36a

Browse files
Merge pull request #294 from Distributive-Network/Xmader/feat/timers-unref
Feat: Node.js-style timers
2 parents 6ecd084 + c50b8a0 commit 9a3a36a

File tree

6 files changed

+239
-26
lines changed

6 files changed

+239
-26
lines changed

include/PyEventLoop.hh

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,21 @@ public:
3232
* @see https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.Handle
3333
*/
3434
struct AsyncHandle {
35+
using id_t = uint32_t;
36+
using id_ptr_pair = std::pair<id_t, AsyncHandle *>;
3537
public:
3638
explicit AsyncHandle(PyObject *handle) : _handle(handle) {};
3739
AsyncHandle(const AsyncHandle &old) = delete; // forbid copy-initialization
38-
AsyncHandle(AsyncHandle &&old) : _handle(std::exchange(old._handle, nullptr)) {}; // clear the moved-from object
40+
AsyncHandle(AsyncHandle &&old) : _handle(std::exchange(old._handle, nullptr)), _refed(old._refed.exchange(false)) {}; // clear the moved-from object
3941
~AsyncHandle() {
4042
if (Py_IsInitialized()) { // the Python runtime has already been finalized when `_timeoutIdMap` is cleared at exit
4143
Py_XDECREF(_handle);
4244
}
4345
}
46+
static inline id_ptr_pair newEmpty() {
47+
auto handle = AsyncHandle(Py_None);
48+
return AsyncHandle::getUniqueIdAndPtr(std::move(handle));
49+
}
4450

4551
/**
4652
* @brief Cancel the scheduled event-loop job.
@@ -52,18 +58,23 @@ public:
5258
* @brief Get the unique `timeoutID` for JS `setTimeout`/`clearTimeout` methods
5359
* @see https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#return_value
5460
*/
55-
static inline uint32_t getUniqueId(AsyncHandle &&handle) {
61+
static inline id_t getUniqueId(AsyncHandle &&handle) {
5662
// TODO (Tom Tang): mutex lock
5763
_timeoutIdMap.push_back(std::move(handle));
5864
return _timeoutIdMap.size() - 1; // the index in `_timeoutIdMap`
5965
}
60-
static inline AsyncHandle *fromId(uint32_t timeoutID) {
66+
static inline AsyncHandle *fromId(id_t timeoutID) {
6167
try {
6268
return &_timeoutIdMap.at(timeoutID);
6369
} catch (...) { // std::out_of_range&
6470
return nullptr; // invalid timeoutID
6571
}
6672
}
73+
static inline id_ptr_pair getUniqueIdAndPtr(AsyncHandle &&handle) {
74+
auto timeoutID = getUniqueId(std::move(handle));
75+
auto ptr = fromId(timeoutID);
76+
return std::make_pair(timeoutID, ptr);
77+
}
6778

6879
/**
6980
* @brief Get the underlying `asyncio.Handle` Python object
@@ -72,8 +83,44 @@ public:
7283
Py_INCREF(_handle); // otherwise the object would be GC-ed as the AsyncHandle destructor decreases the reference count
7384
return _handle;
7485
}
86+
87+
/**
88+
* @brief Replace the underlying `asyncio.Handle` Python object with the provided value
89+
* @return the old `asyncio.Handle` object
90+
*/
91+
inline PyObject *swap(PyObject *newHandleObject) {
92+
return std::exchange(_handle, newHandleObject);
93+
}
94+
95+
/**
96+
* @brief Getter for if the timer has been ref'ed
97+
*/
98+
inline bool hasRef() {
99+
return _refed;
100+
}
101+
102+
/**
103+
* @brief Ref the timer so that the event-loop won't exit as long as the timer is active
104+
*/
105+
inline void addRef() {
106+
if (!_refed) {
107+
_refed = true;
108+
PyEventLoop::_locker->incCounter();
109+
}
110+
}
111+
112+
/**
113+
* @brief Unref the timer so that the event-loop can exit
114+
*/
115+
inline void removeRef() {
116+
if (_refed) {
117+
_refed = false;
118+
PyEventLoop::_locker->decCounter();
119+
}
120+
}
75121
protected:
76122
PyObject *_handle;
123+
std::atomic_bool _refed = false;
77124
};
78125

79126
/**
@@ -86,9 +133,9 @@ public:
86133
* @brief Schedule a job to the Python event-loop, with the given delay
87134
* @param jobFn - The JS event-loop job converted to a Python function
88135
* @param delaySeconds - The job function will be called after the given number of seconds
89-
* @return a AsyncHandle, the value can be safely ignored
136+
* @return the timeoutId and a pointer to the AsyncHandle
90137
*/
91-
AsyncHandle enqueueWithDelay(PyObject *jobFn, double delaySeconds);
138+
[[nodiscard]] AsyncHandle::id_ptr_pair enqueueWithDelay(PyObject *jobFn, double delaySeconds);
92139

93140
/**
94141
* @brief C++ wrapper for Python `asyncio.Future` class

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ declare function internalBinding(namespace: "timers"): {
4343
* internal binding helper for the `clearTimeout` global function
4444
*/
4545
cancelByTimeoutId(timeoutId: number): void;
46+
47+
/**
48+
* internal binding helper for if a timer object has been ref'ed
49+
*/
50+
timerHasRef(timeoutId: number): boolean;
51+
52+
/**
53+
* internal binding helper for ref'ing the timer
54+
*/
55+
timerAddRef(timeoutId: number): void;
56+
57+
/**
58+
* internal binding helper for unref'ing the timer
59+
*/
60+
timerRemoveRef(timeoutId: number): void;
4661
};
4762

4863
export = internalBinding;

python/pythonmonkey/builtin_modules/timers.js

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,85 @@ const internalBinding = require('internal-binding');
77

88
const {
99
enqueueWithDelay,
10-
cancelByTimeoutId
10+
cancelByTimeoutId,
11+
timerHasRef,
12+
timerAddRef,
13+
timerRemoveRef,
1114
} = internalBinding('timers');
1215

16+
/**
17+
* Implement Node.js-style `timeoutId` class returned from setTimeout() and setInterval()
18+
* @see https://nodejs.org/api/timers.html#class-timeout
19+
*/
20+
class Timeout
21+
{
22+
/** @type {number} an integer */
23+
#numericId;
24+
25+
/**
26+
* @param {number} numericId
27+
*/
28+
constructor(numericId)
29+
{
30+
this.#numericId = numericId;
31+
}
32+
33+
/**
34+
* If `true`, the `Timeout` object will keep the event-loop active.
35+
* @returns {boolean}
36+
*/
37+
hasRef()
38+
{
39+
return timerHasRef(this.#numericId);
40+
}
41+
42+
/**
43+
* When called, requests that the event-loop **not exit** so long as the `Timeout` is active.
44+
*
45+
* By default, all `Timeout` objects are "ref'ed", making it normally unnecessary to call `timeout.ref()` unless `timeout.unref()` had been called previously.
46+
*/
47+
ref()
48+
{
49+
timerAddRef(this.#numericId);
50+
return this; // allow chaining
51+
}
52+
53+
/**
54+
* When called, the active `Timeout` object will not require the event-loop to remain active.
55+
* If there is no other activity keeping the event-loop running, the process may exit before the `Timeout` object's callback is invoked.
56+
*/
57+
unref()
58+
{
59+
timerRemoveRef(this.#numericId);
60+
return this; // allow chaining
61+
}
62+
63+
/**
64+
* @returns a number that can be used to reference this timeout
65+
*/
66+
[Symbol.toPrimitive]()
67+
{
68+
return this.#numericId;
69+
}
70+
71+
/**
72+
* Cancels the timeout.
73+
* @deprecated legacy Node.js API. Use `clearTimeout()` instead
74+
*/
75+
close()
76+
{
77+
return clearTimeout(this);
78+
}
79+
}
80+
1381
/**
1482
* Implement the `setTimeout` global function
1583
* @see https://developer.mozilla.org/en-US/docs/Web/API/setTimeout and
1684
* @see https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-settimeout
1785
* @param {Function | string} handler
1886
* @param {number} delayMs timeout milliseconds, use value of 0 if this is omitted
1987
* @param {any[]} args additional arguments to be passed to the `handler`
20-
* @return {number} timeoutId
88+
* @return {Timeout} timeoutId
2189
*/
2290
function setTimeout(handler, delayMs = 0, ...args)
2391
{
@@ -41,25 +109,28 @@ function setTimeout(handler, delayMs = 0, ...args)
41109
delayMs = 0; // as spec-ed
42110
const delaySeconds = delayMs / 1000; // convert ms to s
43111

44-
return enqueueWithDelay(boundHandler, delaySeconds);
112+
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds));
45113
}
46114

47115
/**
48116
* Implement the `clearTimeout` global function
49117
* @see https://developer.mozilla.org/en-US/docs/Web/API/clearTimeout and
50118
* @see https://html.spec.whatwg.org/multipage/timers-and-user-prompts.html#dom-cleartimeout
51-
* @param {number} timeoutId
119+
* @param {Timeout | number} timeoutId
52120
* @return {void}
53121
*/
54122
function clearTimeout(timeoutId)
55123
{
56-
// silently does nothing when an invalid timeoutId (should be an int32 value) is passed in
57-
if (!Number.isInteger(timeoutId))
124+
// silently does nothing when an invalid timeoutId (should be a Timeout instance or an int32 value) is passed in
125+
if (!(timeoutId instanceof Timeout) && !Number.isInteger(timeoutId))
58126
return;
59127

60-
return cancelByTimeoutId(timeoutId);
128+
return cancelByTimeoutId(Number(timeoutId));
61129
}
62130

131+
// expose the `Timeout` class
132+
setTimeout.Timeout = Timeout;
133+
63134
if (!globalThis.setTimeout)
64135
globalThis.setTimeout = setTimeout;
65136
if (!globalThis.clearTimeout)

src/PyEventLoop.cc

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,47 @@ static PyObject *eventLoopJobWrapper(PyObject *jobFn, PyObject *Py_UNUSED(_)) {
1515
Py_RETURN_NONE;
1616
}
1717
}
18-
static PyMethodDef jobWrapperDef = {"eventLoopJobWrapper", eventLoopJobWrapper, METH_NOARGS, NULL};
18+
static PyMethodDef loopJobWrapperDef = {"eventLoopJobWrapper", eventLoopJobWrapper, METH_NOARGS, NULL};
19+
20+
/**
21+
* @brief Wrapper to remove the reference of the timer after the job finishes
22+
*/
23+
static PyObject *timerJobWrapper(PyObject *jobFn, PyObject *handlerPtr) {
24+
auto handle = (PyEventLoop::AsyncHandle *)PyLong_AsVoidPtr(handlerPtr);
25+
PyObject *ret = PyObject_CallObject(jobFn, NULL); // jobFn()
26+
Py_XDECREF(ret); // don't care about its return value
27+
handle->removeRef();
28+
if (PyErr_Occurred()) {
29+
return NULL;
30+
} else {
31+
Py_RETURN_NONE;
32+
}
33+
}
34+
static PyMethodDef timerJobWrapperDef = {"timerJobWrapper", timerJobWrapper, METH_O, NULL};
1935

2036
PyEventLoop::AsyncHandle PyEventLoop::enqueue(PyObject *jobFn) {
2137
PyEventLoop::_locker->incCounter();
22-
PyObject *wrapper = PyCFunction_New(&jobWrapperDef, jobFn);
38+
PyObject *wrapper = PyCFunction_New(&loopJobWrapperDef, jobFn);
2339
// Enqueue job to the Python event-loop
2440
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_soon
2541
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_soon_threadsafe", "O", wrapper); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
2642
return PyEventLoop::AsyncHandle(asyncHandle);
2743
}
2844

29-
PyEventLoop::AsyncHandle PyEventLoop::enqueueWithDelay(PyObject *jobFn, double delaySeconds) {
30-
PyEventLoop::_locker->incCounter();
31-
PyObject *wrapper = PyCFunction_New(&jobWrapperDef, jobFn);
45+
PyEventLoop::AsyncHandle::id_ptr_pair PyEventLoop::enqueueWithDelay(PyObject *jobFn, double delaySeconds) {
46+
auto handler = PyEventLoop::AsyncHandle::newEmpty();
47+
PyObject *wrapper = PyCFunction_New(&timerJobWrapperDef, jobFn);
48+
PyObject *handlerPtr = PyLong_FromVoidPtr(handler.second);
3249
// Schedule job to the Python event-loop
3350
// https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.call_later
34-
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_later", "dO", delaySeconds, wrapper); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
51+
PyObject *asyncHandle = PyObject_CallMethod(_loop, "call_later", "dOO", delaySeconds, wrapper, handlerPtr); // https://docs.python.org/3/c-api/arg.html#c.Py_BuildValue
3552
if (asyncHandle == nullptr) {
3653
PyErr_Print(); // RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one
54+
return handler;
3755
}
38-
return PyEventLoop::AsyncHandle(asyncHandle);
56+
handler.second->swap(asyncHandle);
57+
handler.second->addRef();
58+
return handler;
3959
}
4060

4161
PyEventLoop::Future PyEventLoop::createFuture() {
@@ -145,7 +165,7 @@ void PyEventLoop::AsyncHandle::cancel() {
145165
// NULL if no such attribute (on a strict asyncio.Handle returned by loop.call_soon)
146166
bool finishedOrCanceled = scheduled && scheduled == Py_False; // the job function has already been executed or canceled
147167
if (!finishedOrCanceled) {
148-
PyEventLoop::_locker->decCounter();
168+
removeRef(); // automatically unref at finish
149169
}
150170
Py_XDECREF(scheduled);
151171

src/internalBinding/timers.cc

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010

1111
#include <jsapi.h>
1212

13+
using AsyncHandle = PyEventLoop::AsyncHandle;
14+
1315
/**
1416
* See function declarations in python/pythonmonkey/builtin_modules/internal-binding.d.ts :
1517
* `declare function internalBinding(namespace: "timers")`
@@ -29,15 +31,14 @@ static bool enqueueWithDelay(JSContext *cx, unsigned argc, JS::Value *vp) {
2931
// Schedule job to the running Python event-loop
3032
PyEventLoop loop = PyEventLoop::getRunningLoop();
3133
if (!loop.initialized()) return false;
32-
PyEventLoop::AsyncHandle handle = loop.enqueueWithDelay(job, delaySeconds);
34+
PyEventLoop::AsyncHandle::id_ptr_pair handler = loop.enqueueWithDelay(job, delaySeconds);
3335

3436
// Return the `timeoutID` to use in `clearTimeout`
35-
args.rval().setNumber(PyEventLoop::AsyncHandle::getUniqueId(std::move(handle)));
37+
args.rval().setNumber(handler.first);
3638
return true;
3739
}
3840

3941
static bool cancelByTimeoutId(JSContext *cx, unsigned argc, JS::Value *vp) {
40-
using AsyncHandle = PyEventLoop::AsyncHandle;
4142
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
4243
double timeoutID = args.get(0).toNumber();
4344

@@ -53,8 +54,51 @@ static bool cancelByTimeoutId(JSContext *cx, unsigned argc, JS::Value *vp) {
5354
return true;
5455
}
5556

57+
static bool timerHasRef(JSContext *cx, unsigned argc, JS::Value *vp) {
58+
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
59+
double timeoutID = args.get(0).toNumber();
60+
61+
// Retrieve the AsyncHandle by `timeoutID`
62+
AsyncHandle *handle = AsyncHandle::fromId((uint32_t)timeoutID);
63+
if (!handle) return false; // error no such timeoutID
64+
65+
args.rval().setBoolean(handle->hasRef());
66+
return true;
67+
}
68+
69+
static bool timerAddRef(JSContext *cx, unsigned argc, JS::Value *vp) {
70+
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
71+
double timeoutID = args.get(0).toNumber();
72+
73+
// Retrieve the AsyncHandle by `timeoutID`
74+
AsyncHandle *handle = AsyncHandle::fromId((uint32_t)timeoutID);
75+
if (!handle) return false; // error no such timeoutID
76+
77+
handle->addRef();
78+
79+
args.rval().setUndefined();
80+
return true;
81+
}
82+
83+
static bool timerRemoveRef(JSContext *cx, unsigned argc, JS::Value *vp) {
84+
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
85+
double timeoutID = args.get(0).toNumber();
86+
87+
// Retrieve the AsyncHandle by `timeoutID`
88+
AsyncHandle *handle = AsyncHandle::fromId((uint32_t)timeoutID);
89+
if (!handle) return false; // error no such timeoutID
90+
91+
handle->removeRef();
92+
93+
args.rval().setUndefined();
94+
return true;
95+
}
96+
5697
JSFunctionSpec InternalBinding::timers[] = {
5798
JS_FN("enqueueWithDelay", enqueueWithDelay, /* nargs */ 2, 0),
5899
JS_FN("cancelByTimeoutId", cancelByTimeoutId, 1, 0),
100+
JS_FN("timerHasRef", timerHasRef, 1, 0),
101+
JS_FN("timerAddRef", timerAddRef, 1, 0),
102+
JS_FN("timerRemoveRef", timerRemoveRef, 1, 0),
59103
JS_FS_END
60104
};

0 commit comments

Comments
 (0)