Fix #219: Add threading support with mcrfpy.lock() context manager
Enables background Python threads to safely modify UI objects by
synchronizing with the render loop at frame boundaries.
Implementation:
- FrameLock class provides mutex/condvar synchronization
- GIL released during window.display() allowing background threads to run
- Safe window opens between frames for synchronized UI updates
- mcrfpy.lock() context manager blocks until safe window, then executes
- Main thread detection: lock() is a no-op when called from callbacks
or script initialization (already synchronized)
Usage:
import threading
import mcrfpy
def background_worker():
with mcrfpy.lock(): # Blocks until safe
player.x = new_x # Safe to modify UI
threading.Thread(target=background_worker).start()
The lock works transparently from any context - background threads get
actual synchronization, main thread calls (callbacks, init) get no-op.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
14a6520593
commit
257e52327b
5 changed files with 269 additions and 1 deletions
100
src/PyLock.cpp
Normal file
100
src/PyLock.cpp
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
// #219 - Thread synchronization context manager for mcrfpy.lock()
|
||||
#include "PyLock.h"
|
||||
#include "GameEngine.h"
|
||||
#include "Resources.h"
|
||||
|
||||
// Forward declarations
|
||||
static PyObject* PyLockContext_enter(PyLockContextObject* self, PyObject* args);
|
||||
static PyObject* PyLockContext_exit(PyLockContextObject* self, PyObject* args);
|
||||
static void PyLockContext_dealloc(PyLockContextObject* self);
|
||||
static PyObject* PyLockContext_new(PyTypeObject* type, PyObject* args, PyObject* kwds);
|
||||
|
||||
// Context manager methods
|
||||
static PyMethodDef PyLockContext_methods[] = {
|
||||
{"__enter__", (PyCFunction)PyLockContext_enter, METH_NOARGS,
|
||||
"Acquire the frame lock, blocking until safe window opens"},
|
||||
{"__exit__", (PyCFunction)PyLockContext_exit, METH_VARARGS,
|
||||
"Release the frame lock"},
|
||||
{NULL}
|
||||
};
|
||||
|
||||
// Type definition
|
||||
PyTypeObject PyLockContextType = {
|
||||
.ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0},
|
||||
.tp_name = "mcrfpy._LockContext",
|
||||
.tp_basicsize = sizeof(PyLockContextObject),
|
||||
.tp_itemsize = 0,
|
||||
.tp_dealloc = (destructor)PyLockContext_dealloc,
|
||||
.tp_flags = Py_TPFLAGS_DEFAULT,
|
||||
.tp_doc = "Thread synchronization context manager for safe UI updates from background threads",
|
||||
.tp_methods = PyLockContext_methods,
|
||||
.tp_new = PyLockContext_new,
|
||||
};
|
||||
|
||||
static PyObject* PyLockContext_new(PyTypeObject* type, PyObject* args, PyObject* kwds) {
|
||||
PyLockContextObject* self = (PyLockContextObject*)type->tp_alloc(type, 0);
|
||||
if (self) {
|
||||
self->acquired = false;
|
||||
}
|
||||
return (PyObject*)self;
|
||||
}
|
||||
|
||||
static void PyLockContext_dealloc(PyLockContextObject* self) {
|
||||
// Safety: if we're destroyed while holding the lock, release it
|
||||
if (self->acquired && Resources::game) {
|
||||
Resources::game->getFrameLock().release();
|
||||
}
|
||||
Py_TYPE(self)->tp_free((PyObject*)self);
|
||||
}
|
||||
|
||||
static PyObject* PyLockContext_enter(PyLockContextObject* self, PyObject* args) {
|
||||
if (!Resources::game) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Game engine not initialized");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// #219 - If we're on the main thread, no-op (already synchronized)
|
||||
// This allows the same code to work from callbacks, script init, AND background threads
|
||||
if (Resources::game->isMainThread()) {
|
||||
self->acquired = false; // Don't try to release what we didn't acquire
|
||||
Py_INCREF(self);
|
||||
return (PyObject*)self;
|
||||
}
|
||||
|
||||
// Acquire the frame lock - this will block (with GIL released) until safe window
|
||||
Resources::game->getFrameLock().acquire();
|
||||
self->acquired = true;
|
||||
|
||||
// Return self for the `with` statement
|
||||
Py_INCREF(self);
|
||||
return (PyObject*)self;
|
||||
}
|
||||
|
||||
static PyObject* PyLockContext_exit(PyLockContextObject* self, PyObject* args) {
|
||||
// args contains (exc_type, exc_val, exc_tb) - we don't suppress exceptions
|
||||
if (self->acquired && Resources::game) {
|
||||
Resources::game->getFrameLock().release();
|
||||
self->acquired = false;
|
||||
}
|
||||
|
||||
// Return False to not suppress any exceptions
|
||||
Py_RETURN_FALSE;
|
||||
}
|
||||
|
||||
// The lock() function that users call - returns a new context manager
|
||||
PyObject* PyLock::lock(PyObject* self, PyObject* args) {
|
||||
if (!Resources::game) {
|
||||
PyErr_SetString(PyExc_RuntimeError, "Game engine not initialized");
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Create and return a new context manager object
|
||||
return PyObject_CallObject((PyObject*)&PyLockContextType, NULL);
|
||||
}
|
||||
|
||||
int PyLock::init() {
|
||||
if (PyType_Ready(&PyLockContextType) < 0) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue