Skip to content

Commit c8b0f61

Browse files
Merge pull request #323 from Distributive-Network/Xmader/feat/WtfPM
Detect hanging timers with the WTFPythonMonkey tool & Fix bugs found by WTFPythonMonkey
2 parents e31cc94 + b044443 commit c8b0f61

File tree

7 files changed

+183
-12
lines changed

7 files changed

+183
-12
lines changed

include/PyEventLoop.hh

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
#define PythonMonkey_PyEventLoop_
1313

1414
#include <Python.h>
15+
#include <jsapi.h>
1516
#include <vector>
1617
#include <utility>
1718
#include <atomic>
@@ -35,7 +36,7 @@ public:
3536
public:
3637
explicit AsyncHandle(PyObject *handle) : _handle(handle) {};
3738
AsyncHandle(const AsyncHandle &old) = delete; // forbid copy-initialization
38-
AsyncHandle(AsyncHandle &&old) : _handle(std::exchange(old._handle, nullptr)), _refed(old._refed.exchange(false)) {}; // clear the moved-from object
39+
AsyncHandle(AsyncHandle &&old) : _handle(std::exchange(old._handle, nullptr)), _refed(old._refed.exchange(false)), _debugInfo(std::exchange(old._debugInfo, nullptr)) {}; // clear the moved-from object
3940
~AsyncHandle() {
4041
if (Py_IsInitialized()) { // the Python runtime has already been finalized when `_timeoutIdMap` is cleared at exit
4142
Py_XDECREF(_handle);
@@ -126,9 +127,27 @@ public:
126127
PyEventLoop::_locker->decCounter();
127128
}
128129
}
130+
131+
/**
132+
* @brief Set the debug info object for WTFPythonMonkey tool
133+
*/
134+
inline void setDebugInfo(PyObject *obj) {
135+
_debugInfo = obj;
136+
}
137+
inline PyObject *getDebugInfo() {
138+
return _debugInfo;
139+
}
140+
141+
/**
142+
* @brief Get an iterator for the `AsyncHandle`s of all timers
143+
*/
144+
static inline auto &getAllTimers() {
145+
return _timeoutIdMap;
146+
}
129147
protected:
130148
PyObject *_handle;
131149
std::atomic_bool _refed = false;
150+
PyObject *_debugInfo = nullptr;
132151
};
133152

134153
/**

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ declare function internalBinding(namespace: "utils"): {
3232
getProxyDetails<T extends object>(proxy: T): undefined | [target: T, handler: ProxyHandler<T>];
3333
};
3434

35+
declare type TimerDebugInfo = object;
36+
3537
declare function internalBinding(namespace: "timers"): {
3638
/**
3739
* internal binding helper for the `setTimeout`/`setInterval` global functions
@@ -41,7 +43,7 @@ declare function internalBinding(namespace: "timers"): {
4143
* @param repeat The call is to `setInterval` if true
4244
* @return timeoutId
4345
*/
44-
enqueueWithDelay(handler: Function, delaySeconds: number, repeat: boolean): number;
46+
enqueueWithDelay(handler: Function, delaySeconds: number, repeat: boolean, debugInfo?: TimerDebugInfo): number;
4547

4648
/**
4749
* internal binding helper for the `clearTimeout` global function
@@ -62,6 +64,16 @@ declare function internalBinding(namespace: "timers"): {
6264
* internal binding helper for unref'ing the timer
6365
*/
6466
timerRemoveRef(timeoutId: number): void;
67+
68+
/**
69+
* Retrieve debug info inside the timer for the WTFPythonMonkey tool
70+
*/
71+
getDebugInfo(timeoutId: number): TimerDebugInfo;
72+
73+
/**
74+
* Retrieve the debug info for all timers that are still ref'ed
75+
*/
76+
getAllRefedTimersDebugInfo(): TimerDebugInfo[];
6577
};
6678

6779
export = internalBinding;

python/pythonmonkey/builtin_modules/timers.js

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ class Timeout
9696
}
9797

9898
/**
99-
* Normalize the arguments to `setTimeout` or `setInterval`
99+
* Normalize the arguments to `setTimeout`,`setImmediate` or `setInterval`
100100
* @param {Function | string} handler
101101
* @param {number} delayMs timeout milliseconds
102102
* @param {any[]} additionalArgs additional arguments to be passed to the `handler`
@@ -123,7 +123,19 @@ function _normalizeTimerArgs(handler, delayMs, additionalArgs)
123123
delayMs = 0; // as spec-ed
124124
const delaySeconds = delayMs / 1000; // convert ms to s
125125

126-
return { boundHandler, delaySeconds };
126+
// Populate debug information for the WTFPythonMonkey tool
127+
const stacks = new Error().stack.split('\n');
128+
const timerType = stacks[1]?.match(/^set(Timeout|Immediate|Interval)/)?.[0]; // `setTimeout@...`/`setImmediate@...`/`setInterval@...` is on the second line of stack trace
129+
const debugInfo = {
130+
type: timerType, // "setTimeout", "setImmediate", or "setInterval"
131+
fn: handler,
132+
args: additionalArgs,
133+
startTime: new Date(),
134+
delaySeconds,
135+
stack: stacks.slice(2).join('\n'), // remove the first line `_normalizeTimerArgs@...` and the second line `setTimeout/setImmediate/setInterval@...`
136+
};
137+
138+
return { boundHandler, delaySeconds, debugInfo };
127139
}
128140

129141
/**
@@ -137,8 +149,8 @@ function _normalizeTimerArgs(handler, delayMs, additionalArgs)
137149
*/
138150
function setTimeout(handler, delayMs = 0, ...args)
139151
{
140-
const { boundHandler, delaySeconds } = _normalizeTimerArgs(handler, delayMs, args);
141-
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds, false));
152+
const { boundHandler, delaySeconds, debugInfo } = _normalizeTimerArgs(handler, delayMs, args);
153+
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds, false, debugInfo));
142154
}
143155

144156
/**
@@ -157,6 +169,29 @@ function clearTimeout(timeoutId)
157169
return cancelByTimeoutId(Number(timeoutId));
158170
}
159171

172+
/**
173+
* Implement the `setImmediate` global function
174+
* **NON-STANDARD**, for Node.js compatibility only.
175+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setImmediate and
176+
* @see https://nodejs.org/en/learn/asynchronous-work/understanding-setimmediate
177+
* @param {Function | string} handler
178+
* @param {any[]} args additional arguments to be passed to the `handler`
179+
*/
180+
function setImmediate(handler, ...args)
181+
{
182+
// setImmediate is equal to setTimeout with a 0ms delay
183+
const { boundHandler, debugInfo } = _normalizeTimerArgs(handler, 0, args);
184+
return new Timeout(enqueueWithDelay(boundHandler, 0, false, debugInfo));
185+
}
186+
187+
/**
188+
* Implement the `clearImmediate` global function
189+
* **NON-STANDARD**, for Node.js compatibility only.
190+
* @alias to `clearTimeout`
191+
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/clearImmediate
192+
*/
193+
const clearImmediate = clearTimeout;
194+
160195
/**
161196
* Implement the `setInterval` global function
162197
* @see https://developer.mozilla.org/en-US/docs/Web/API/setInterval and
@@ -168,8 +203,8 @@ function clearTimeout(timeoutId)
168203
*/
169204
function setInterval(handler, delayMs = 0, ...args)
170205
{
171-
const { boundHandler, delaySeconds } = _normalizeTimerArgs(handler, delayMs, args);
172-
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds, true));
206+
const { boundHandler, delaySeconds, debugInfo } = _normalizeTimerArgs(handler, delayMs, args);
207+
return new Timeout(enqueueWithDelay(boundHandler, delaySeconds, true, debugInfo));
173208
}
174209

175210
/**
@@ -181,19 +216,27 @@ const clearInterval = clearTimeout;
181216

182217
// expose the `Timeout` class
183218
setTimeout.Timeout = Timeout;
219+
setImmediate.Timeout = Timeout;
184220
setInterval.Timeout = Timeout;
185221

186222
if (!globalThis.setTimeout)
187223
globalThis.setTimeout = setTimeout;
188224
if (!globalThis.clearTimeout)
189225
globalThis.clearTimeout = clearTimeout;
190226

227+
if (!globalThis.setImmediate)
228+
globalThis.setImmediate = setImmediate;
229+
if (!globalThis.clearImmediate)
230+
globalThis.clearImmediate = clearImmediate;
231+
191232
if (!globalThis.setInterval)
192233
globalThis.setInterval = setInterval;
193234
if (!globalThis.clearInterval)
194235
globalThis.clearInterval = clearInterval;
195236

196237
exports.setTimeout = setTimeout;
197238
exports.clearTimeout = clearTimeout;
239+
exports.setImmediate = setImmediate;
240+
exports.clearImmediate = clearImmediate;
198241
exports.setInterval = setInterval;
199242
exports.clearInterval = clearInterval;

python/pythonmonkey/cli/pmjs.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import readline
1212
import asyncio
1313
import pythonmonkey as pm
14-
from pythonmonkey.lib import pmdb
14+
from pythonmonkey.lib import pmdb, wtfpm
1515

1616
globalThis = pm.eval("globalThis")
1717
evalOpts = {'filename': __file__, 'fromPythonFrame': True, 'strict': False} # type: pm.EvalOptions
@@ -296,6 +296,7 @@ def usage():
296296
-v, --version print PythonMonkey version
297297
--use-strict evaluate -e, -p, and REPL code in strict mode
298298
--inspect enable pmdb, a gdb-like JavaScript debugger interface
299+
--wtf enable WTFPythonMonkey, a tool that can detect hanging timers when Ctrl-C is hit
299300
300301
Environment variables:
301302
TZ specify the timezone configuration
@@ -333,14 +334,15 @@ def main():
333334

334335
try:
335336
opts, args = getopt.getopt(sys.argv[1:], "hie:p:r:v", ["help", "eval=", "print=",
336-
"require=", "version", "interactive", "use-strict", "inspect"])
337+
"require=", "version", "interactive", "use-strict", "inspect", "wtf"])
337338
except getopt.GetoptError as err:
338339
# print help information and exit:
339340
print(err) # will print something like "option -a not recognized"
340341
usage()
341342
sys.exit(2)
342343
output = None
343344
verbose = False
345+
enableWTF = False
344346
for o, a in opts:
345347
if o in ("-v", "--version"):
346348
print(pm.__version__)
@@ -369,6 +371,8 @@ async def runEvalPrint():
369371
globalThis.require(a)
370372
elif o in ("--inspect"):
371373
pmdb.enable()
374+
elif o in ("--wtf"):
375+
enableWTF = True
372376
else:
373377
assert False, "unhandled option"
374378

@@ -377,7 +381,12 @@ async def runJS():
377381
globalInitModule.patchGlobalRequire()
378382
pm.runProgramModule(args[0], args, requirePath)
379383
await pm.wait() # blocks until all asynchronous calls finish
380-
asyncio.run(runJS())
384+
try:
385+
asyncio.run(runJS())
386+
except KeyboardInterrupt:
387+
print() # silently going to end the program instead of printing out the Python traceback
388+
if enableWTF:
389+
wtfpm.printTimersDebugInfo()
381390
elif (enterRepl or forceRepl):
382391
async def runREPL():
383392
globalInitModule.initReplLibs()

python/pythonmonkey/lib/wtfpm.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# @file WTFPythonMonkey - A tool that detects any hanging setTimeout/setInterval timers when Ctrl-C is hit
2+
# @author Tom Tang <[email protected]>
3+
# @date April 2024
4+
# @copyright Copyright (c) 2024 Distributive Corp.
5+
6+
import pythonmonkey as pm
7+
8+
9+
def printTimersDebugInfo():
10+
pm.eval("""(require) => {
11+
const internalBinding = require('internal-binding');
12+
const { getAllRefedTimersDebugInfo: getDebugInfo } = internalBinding('timers');
13+
console.log(getDebugInfo())
14+
console.log(new Date())
15+
}""")(pm.createRequire(__file__))
16+
17+
18+
class WTF:
19+
"""
20+
WTFPythonMonkey to use as a Python context manager (`with`-statement)
21+
22+
Usage:
23+
```py
24+
from pythonmonkey.lib.wtfpm import WTF
25+
26+
with WTF():
27+
# the main entry point for the program utilizes PythonMonkey event-loop
28+
asyncio.run(pythonmonkey_main())
29+
```
30+
"""
31+
32+
def __enter__(self):
33+
pass
34+
35+
def __exit__(self, errType, errValue, traceback):
36+
if errType is None: # no exception
37+
return
38+
elif issubclass(errType, KeyboardInterrupt): # except KeyboardInterrupt:
39+
printTimersDebugInfo()
40+
return True # exception suppressed
41+
else: # other exceptions
42+
return False

src/PyEventLoop.cc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,16 @@ static PyObject *timerJobWrapper(PyObject *jobFn, PyObject *args) {
3838
PyEventLoop::AsyncHandle::id_t handleId = PyLong_AsLong(PyTuple_GetItem(args, 1));
3939
double delaySeconds = PyFloat_AsDouble(PyTuple_GetItem(args, 2));
4040
bool repeat = (bool)PyLong_AsLong(PyTuple_GetItem(args, 3));
41-
auto handle = PyEventLoop::AsyncHandle::fromId(handleId);
4241

4342
PyObject *ret = PyObject_CallObject(jobFn, NULL); // jobFn()
4443
Py_XDECREF(ret); // don't care about its return value
4544

4645
PyObject *errType, *errValue, *traceback; // we can't call any Python code unless the error indicator is clear
4746
PyErr_Fetch(&errType, &errValue, &traceback);
47+
// Making sure a `AsyncHandle::fromId` call is close to its `handle`'s use.
48+
// We need to ensure the memory block doesn't move for reallocation before we can use the pointer,
49+
// as we could have multiple new `setTimeout` calls to expand the `_timeoutIdMap` vector while running the job function in parallel.
50+
auto handle = PyEventLoop::AsyncHandle::fromId(handleId);
4851
if (repeat && !handle->cancelled()) {
4952
_enqueueWithDelay(_loop, handleId, jobFn, delaySeconds, repeat);
5053
} else {
@@ -119,6 +122,7 @@ PyEventLoop::Future PyEventLoop::ensureFuture(PyObject *awaitable) {
119122
Py_DECREF(args);
120123
Py_DECREF(kwargs);
121124

125+
Py_INCREF(futureObj); // needs to be kept alive as `PyEventLoop::Future` will decrease the reference count in its destructor
122126
return PyEventLoop::Future(futureObj);
123127
}
124128

src/internalBinding/timers.cc

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88

99
#include "include/internalBinding.hh"
1010
#include "include/pyTypeFactory.hh"
11+
#include "include/jsTypeFactory.hh"
1112
#include "include/PyEventLoop.hh"
13+
#include "include/setSpiderMonkeyException.hh"
1214

1315
#include <jsapi.h>
16+
#include <js/Array.h>
1417

1518
using AsyncHandle = PyEventLoop::AsyncHandle;
1619

@@ -24,6 +27,7 @@ static bool enqueueWithDelay(JSContext *cx, unsigned argc, JS::Value *vp) {
2427
JS::HandleValue jobArgVal = args.get(0);
2528
double delaySeconds = args.get(1).toNumber();
2629
bool repeat = args.get(2).toBoolean();
30+
JS::HandleValue debugInfo = args.get(3);
2731

2832
// Convert to a Python function
2933
JS::RootedValue jobArg(cx, jobArgVal);
@@ -34,6 +38,10 @@ static bool enqueueWithDelay(JSContext *cx, unsigned argc, JS::Value *vp) {
3438
PyEventLoop::AsyncHandle::id_t handleId = loop.enqueueWithDelay(job, delaySeconds, repeat);
3539
Py_DECREF(job);
3640

41+
// Set debug info for the WTFPythonMonkey tool
42+
auto handle = PyEventLoop::AsyncHandle::fromId(handleId);
43+
handle->setDebugInfo(pyTypeFactory(cx, debugInfo));
44+
3745
// Return the `timeoutID` to use in `clearTimeout`
3846
args.rval().setNumber(handleId);
3947
return true;
@@ -95,11 +103,45 @@ static bool timerRemoveRef(JSContext *cx, unsigned argc, JS::Value *vp) {
95103
return true;
96104
}
97105

106+
static bool getDebugInfo(JSContext *cx, unsigned argc, JS::Value *vp) {
107+
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
108+
double timeoutID = args.get(0).toNumber();
109+
110+
// Retrieve the AsyncHandle by `timeoutID`
111+
AsyncHandle *handle = AsyncHandle::fromId((uint32_t)timeoutID);
112+
if (!handle) return false; // error no such timeoutID
113+
114+
JS::Value debugInfo = jsTypeFactory(cx, handle->getDebugInfo());
115+
args.rval().set(debugInfo);
116+
return true;
117+
}
118+
119+
static bool getAllRefedTimersDebugInfo(JSContext *cx, unsigned argc, JS::Value *vp) {
120+
JS::CallArgs args = JS::CallArgsFromVp(argc, vp);
121+
122+
JS::RootedVector<JS::Value> results(cx);
123+
for (AsyncHandle &timer: AsyncHandle::getAllTimers()) {
124+
if (!timer.hasRef()) continue; // we only need ref'ed timers
125+
126+
JS::Value debugInfo = jsTypeFactory(cx, timer.getDebugInfo());
127+
if (!results.append(debugInfo)) {
128+
// out of memory
129+
setSpiderMonkeyException(cx);
130+
return false;
131+
}
132+
}
133+
134+
args.rval().setObjectOrNull(JS::NewArrayObject(cx, results));
135+
return true;
136+
}
137+
98138
JSFunctionSpec InternalBinding::timers[] = {
99139
JS_FN("enqueueWithDelay", enqueueWithDelay, /* nargs */ 2, 0),
100140
JS_FN("cancelByTimeoutId", cancelByTimeoutId, 1, 0),
101141
JS_FN("timerHasRef", timerHasRef, 1, 0),
102142
JS_FN("timerAddRef", timerAddRef, 1, 0),
103143
JS_FN("timerRemoveRef", timerRemoveRef, 1, 0),
144+
JS_FN("getDebugInfo", getDebugInfo, 1, 0),
145+
JS_FN("getAllRefedTimersDebugInfo", getAllRefedTimersDebugInfo, 1, 0),
104146
JS_FS_END
105147
};

0 commit comments

Comments
 (0)