pynect 0.2.7 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +68 -27
  2. package/SECURITY.md +6 -6
  3. package/dist/cli/codegen.d.ts +3 -0
  4. package/dist/cli/codegen.d.ts.map +1 -1
  5. package/dist/cli/codegen.js +15 -3
  6. package/dist/cli/codegen.js.map +1 -1
  7. package/dist/cli/sync.d.ts.map +1 -1
  8. package/dist/cli/sync.js +38 -19
  9. package/dist/cli/sync.js.map +1 -1
  10. package/dist/config/project.d.ts +8 -52
  11. package/dist/config/project.d.ts.map +1 -1
  12. package/dist/config/project.js +74 -121
  13. package/dist/config/project.js.map +1 -1
  14. package/dist/core/isolated-worker.js +4 -4
  15. package/dist/core/isolated-worker.js.map +1 -1
  16. package/dist/core/py-proxy.d.ts +2 -1
  17. package/dist/core/py-proxy.d.ts.map +1 -1
  18. package/dist/core/py-proxy.js +55 -7
  19. package/dist/core/py-proxy.js.map +1 -1
  20. package/dist/core/py-runtime.d.ts +1 -5
  21. package/dist/core/py-runtime.d.ts.map +1 -1
  22. package/dist/core/py-runtime.js +36 -100
  23. package/dist/core/py-runtime.js.map +1 -1
  24. package/dist/core/pynect-callback.d.ts +16 -0
  25. package/dist/core/pynect-callback.d.ts.map +1 -0
  26. package/dist/core/pynect-callback.js +52 -0
  27. package/dist/core/pynect-callback.js.map +1 -0
  28. package/dist/env/project-environment.d.ts +9 -30
  29. package/dist/env/project-environment.d.ts.map +1 -1
  30. package/dist/env/project-environment.js +32 -217
  31. package/dist/env/project-environment.js.map +1 -1
  32. package/dist/env/python.d.ts +16 -0
  33. package/dist/env/python.d.ts.map +1 -0
  34. package/dist/env/python.js +9 -0
  35. package/dist/env/python.js.map +1 -0
  36. package/dist/index.d.ts +8 -24
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5 -31
  39. package/dist/index.js.map +1 -1
  40. package/dist/native/native-addon-loader.d.ts.map +1 -1
  41. package/dist/native/native-addon-loader.js +15 -5
  42. package/dist/native/native-addon-loader.js.map +1 -1
  43. package/dist/types/addon.d.ts +6 -0
  44. package/dist/types/addon.d.ts.map +1 -1
  45. package/dist/types/addon.js.map +1 -1
  46. package/dist/types/config.d.ts +18 -85
  47. package/dist/types/config.d.ts.map +1 -1
  48. package/dist/types/config.js +17 -15
  49. package/dist/types/config.js.map +1 -1
  50. package/dist/types/index.d.ts +1 -1
  51. package/dist/types/index.d.ts.map +1 -1
  52. package/dist/types/python.d.ts +2 -0
  53. package/dist/types/python.d.ts.map +1 -1
  54. package/package.json +3 -4
  55. package/prebuilds/darwin-arm64/pynect-prebuild.json +1 -1
  56. package/prebuilds/darwin-arm64/pynect_addon.node +0 -0
  57. package/prebuilds/darwin-x64/pynect-prebuild.json +1 -1
  58. package/prebuilds/darwin-x64/pynect_addon.node +0 -0
  59. package/prebuilds/linux-arm64/pynect-prebuild.json +1 -1
  60. package/prebuilds/linux-arm64/pynect_addon.node +0 -0
  61. package/prebuilds/linux-x64/pynect-prebuild.json +1 -1
  62. package/prebuilds/linux-x64/pynect_addon.node +0 -0
  63. package/prebuilds/win32-arm64/pynect-prebuild.json +1 -1
  64. package/prebuilds/win32-arm64/pynect_addon.node +0 -0
  65. package/prebuilds/win32-x64/pynect-prebuild.json +1 -1
  66. package/prebuilds/win32-x64/pynect_addon.node +0 -0
  67. package/python/pynect_runtime.py +1 -1
  68. package/python/type_inspector.py +12 -0
  69. package/scripts/build-native.js +4 -3
  70. package/scripts/postinstall.js +2 -2
  71. package/scripts/python.js +33 -0
  72. package/scripts/smoke-python-boundary.js +72 -21
  73. package/src/native/callback_bridge.h +11 -0
  74. package/src/native/native-addon-loader.ts +13 -4
  75. package/src/native/pynect_addon.cpp +346 -4
  76. package/src/native/python_bridge.cpp +12 -0
  77. package/src/native/python_bridge.h +7 -0
  78. package/src/native/thread_pool.cpp +62 -55
  79. package/src/native/thread_pool.h +13 -0
  80. package/src/native/type_converter.h +2 -0
  81. package/dist/env/executables.d.ts +0 -3
  82. package/dist/env/executables.d.ts.map +0 -1
  83. package/dist/env/executables.js +0 -14
  84. package/dist/env/executables.js.map +0 -1
  85. package/dist/env/package-installer.d.ts +0 -232
  86. package/dist/env/package-installer.d.ts.map +0 -1
  87. package/dist/env/package-installer.js +0 -615
  88. package/dist/env/package-installer.js.map +0 -1
  89. package/dist/env/pyfig-adapter.d.ts +0 -38
  90. package/dist/env/pyfig-adapter.d.ts.map +0 -1
  91. package/dist/env/pyfig-adapter.js +0 -10
  92. package/dist/env/pyfig-adapter.js.map +0 -1
  93. package/dist/env/python-detector.d.ts +0 -48
  94. package/dist/env/python-detector.d.ts.map +0 -1
  95. package/dist/env/python-detector.js +0 -182
  96. package/dist/env/python-detector.js.map +0 -1
  97. package/dist/env/python-provisioner.d.ts +0 -16
  98. package/dist/env/python-provisioner.d.ts.map +0 -1
  99. package/dist/env/python-provisioner.js +0 -42
  100. package/dist/env/python-provisioner.js.map +0 -1
  101. package/dist/env/venv-manager.d.ts +0 -56
  102. package/dist/env/venv-manager.d.ts.map +0 -1
  103. package/dist/env/venv-manager.js +0 -160
  104. package/dist/env/venv-manager.js.map +0 -1
  105. package/scripts/pyfig.js +0 -142
  106. package/scripts/python-provisioner.js +0 -23
@@ -19,6 +19,7 @@
19
19
  #include "thread_pool.h"
20
20
  #include "type_converter.h"
21
21
  #include "gil_guard.h"
22
+ #include "callback_bridge.h"
22
23
 
23
24
  #include <memory>
24
25
  #include <string>
@@ -29,6 +30,7 @@
29
30
  #include <iostream>
30
31
  #include <chrono>
31
32
  #include <ctime>
33
+ #include <unordered_map>
32
34
 
33
35
  namespace pynect {
34
36
 
@@ -37,6 +39,38 @@ namespace pynect {
37
39
  static std::unique_ptr<PythonThreadPool> g_threadPool;
38
40
  static bool g_initialized = false;
39
41
  static bool g_includeTracebacks = false;
42
+ static uint32_t g_callbackMaxDepth = 0;
43
+ static std::atomic<uint32_t> g_nextCallbackId{1};
44
+ static std::mutex g_callbacksMutex;
45
+ static thread_local uint32_t g_callbackDepth = 0;
46
+
47
+ struct CallbackState {
48
+ explicit CallbackState(Napi::ThreadSafeFunction callbackDispatcher)
49
+ : dispatcher(std::move(callbackDispatcher)) {}
50
+
51
+ Napi::ThreadSafeFunction dispatcher;
52
+ std::atomic<bool> active{true};
53
+ };
54
+
55
+ struct CallbackCompletion {
56
+ std::atomic<bool> completed{false};
57
+ std::mutex mutex;
58
+ SerializedValue value;
59
+ bool failed = false;
60
+ std::string errorName;
61
+ std::string errorMessage;
62
+ std::string errorStack;
63
+ };
64
+
65
+ struct CallbackInvocationPayload {
66
+ std::shared_ptr<CallbackState> state;
67
+ std::shared_ptr<CallbackCompletion> completion;
68
+ uint32_t depth;
69
+ std::vector<TaskResult> args;
70
+ std::map<std::string, TaskResult> kwargs;
71
+ };
72
+
73
+ static std::unordered_map<uint32_t, std::shared_ptr<CallbackState>> g_callbacks;
40
74
 
41
75
  // Helper: Extract SerializedValue from JS object
42
76
 
@@ -58,6 +92,15 @@ static SerializedValue jsToSerializedValue(const Napi::Object& obj) {
58
92
  } else if (data.IsBuffer()) {
59
93
  Napi::Buffer<uint8_t> buf = data.As<Napi::Buffer<uint8_t>>();
60
94
  sv.binaryData.assign(buf.Data(), buf.Data() + buf.Length());
95
+ } else if (data.IsTypedArray()) {
96
+ Napi::TypedArray array = data.As<Napi::TypedArray>();
97
+ Napi::ArrayBuffer buffer = array.ArrayBuffer();
98
+ auto* begin = static_cast<uint8_t*>(buffer.Data()) + array.ByteOffset();
99
+ sv.binaryData.assign(begin, begin + array.ByteLength());
100
+ } else if (data.IsArrayBuffer()) {
101
+ Napi::ArrayBuffer buffer = data.As<Napi::ArrayBuffer>();
102
+ auto* begin = static_cast<uint8_t*>(buffer.Data());
103
+ sv.binaryData.assign(begin, begin + buffer.ByteLength());
61
104
  }
62
105
  }
63
106
 
@@ -66,6 +109,9 @@ static SerializedValue jsToSerializedValue(const Napi::Object& obj) {
66
109
  if (meta.Has("objectId")) {
67
110
  sv.metadata.objectId = meta.Get("objectId").As<Napi::Number>().Uint32Value();
68
111
  }
112
+ if (meta.Has("callbackId")) {
113
+ sv.metadata.callbackId = meta.Get("callbackId").As<Napi::Number>().Uint32Value();
114
+ }
69
115
  if (meta.Has("dtype") && meta.Get("dtype").IsString()) {
70
116
  sv.metadata.dtype = meta.Get("dtype").As<Napi::String>().Utf8Value();
71
117
  }
@@ -115,6 +161,230 @@ static Napi::Object taskResultToJS(Napi::Env env, const TaskResult& result) {
115
161
  return obj;
116
162
  }
117
163
 
164
+ static void completeCallback(
165
+ const std::shared_ptr<CallbackCompletion>& completion,
166
+ SerializedValue value
167
+ ) {
168
+ {
169
+ std::lock_guard<std::mutex> lock(completion->mutex);
170
+ if (completion->completed.load(std::memory_order_relaxed)) return;
171
+ completion->value = std::move(value);
172
+ completion->completed.store(true, std::memory_order_release);
173
+ }
174
+ if (g_threadPool) g_threadPool->notifyWaiters();
175
+ }
176
+
177
+ static void failCallback(
178
+ const std::shared_ptr<CallbackCompletion>& completion,
179
+ std::string name,
180
+ std::string message,
181
+ std::string stack = {}
182
+ ) {
183
+ {
184
+ std::lock_guard<std::mutex> lock(completion->mutex);
185
+ if (completion->completed.load(std::memory_order_relaxed)) return;
186
+ completion->failed = true;
187
+ completion->errorName = std::move(name);
188
+ completion->errorMessage = std::move(message);
189
+ completion->errorStack = std::move(stack);
190
+ completion->completed.store(true, std::memory_order_release);
191
+ }
192
+ if (g_threadPool) g_threadPool->notifyWaiters();
193
+ }
194
+
195
+ static std::shared_ptr<CallbackState> callbackState(uint32_t callbackId) {
196
+ std::lock_guard<std::mutex> lock(g_callbacksMutex);
197
+ auto found = g_callbacks.find(callbackId);
198
+ if (found == g_callbacks.end() || !found->second->active.load()) return nullptr;
199
+ return found->second;
200
+ }
201
+
202
+ struct CallbackDepthScope {
203
+ explicit CallbackDepthScope(uint32_t depth)
204
+ : previous(g_callbackDepth) {
205
+ g_callbackDepth = depth;
206
+ }
207
+ ~CallbackDepthScope() { g_callbackDepth = previous; }
208
+ uint32_t previous;
209
+ };
210
+
211
+ static constexpr const char* CALLBACK_CAPSULE = "pynect.callback";
212
+
213
+ static void destroyCallbackCapsule(PyObject* capsule) {
214
+ auto* callbackId = static_cast<uint32_t*>(PyCapsule_GetPointer(capsule, CALLBACK_CAPSULE));
215
+ if (!callbackId) PyErr_Clear();
216
+ delete callbackId;
217
+ }
218
+
219
+ static PyObject* invokeJavaScriptCallback(
220
+ PyObject* capsule,
221
+ PyObject* args,
222
+ PyObject* kwargs
223
+ ) {
224
+ auto* callbackId = static_cast<uint32_t*>(PyCapsule_GetPointer(capsule, CALLBACK_CAPSULE));
225
+ if (!callbackId) return nullptr;
226
+ auto state = callbackState(*callbackId);
227
+ if (!state) {
228
+ PyErr_SetString(PyExc_RuntimeError, "Pynect callback has been disposed");
229
+ return nullptr;
230
+ }
231
+
232
+ const uint32_t depth = g_callbackDepth + 1;
233
+ if (g_callbackMaxDepth > 0 && depth > g_callbackMaxDepth) {
234
+ PyErr_Format(
235
+ PyExc_RecursionError,
236
+ "Pynect callback recursion depth %u exceeds the configured maximum of %u",
237
+ depth,
238
+ g_callbackMaxDepth
239
+ );
240
+ return nullptr;
241
+ }
242
+ CallbackDepthScope depthScope(depth);
243
+
244
+ auto completion = std::make_shared<CallbackCompletion>();
245
+ auto* payload = new CallbackInvocationPayload{state, completion, depth, {}, {}};
246
+ const Py_ssize_t argCount = PyTuple_Size(args);
247
+ payload->args.reserve(static_cast<size_t>(argCount));
248
+ for (Py_ssize_t index = 0; index < argCount; ++index) {
249
+ PyObject* value = PyTuple_GetItem(args, index);
250
+ payload->args.push_back(PythonBridge::getInstance().serializeValue(value));
251
+ }
252
+ if (kwargs) {
253
+ PyObject* key = nullptr;
254
+ PyObject* value = nullptr;
255
+ Py_ssize_t position = 0;
256
+ while (PyDict_Next(kwargs, &position, &key, &value)) {
257
+ Py_ssize_t length = 0;
258
+ const char* text = PyUnicode_AsUTF8AndSize(key, &length);
259
+ if (!text) {
260
+ delete payload;
261
+ return nullptr;
262
+ }
263
+ payload->kwargs.emplace(
264
+ std::string(text, static_cast<size_t>(length)),
265
+ PythonBridge::getInstance().serializeValue(value)
266
+ );
267
+ }
268
+ }
269
+
270
+ const napi_status status = state->dispatcher.NonBlockingCall(
271
+ payload,
272
+ [](Napi::Env env, Napi::Function dispatcher, CallbackInvocationPayload* invocation) {
273
+ const auto completion = invocation->completion;
274
+ try {
275
+ Napi::Object request = Napi::Object::New(env);
276
+ request.Set("depth", Napi::Number::New(env, invocation->depth));
277
+ Napi::Array argsArray = Napi::Array::New(env, invocation->args.size());
278
+ for (size_t index = 0; index < invocation->args.size(); ++index) {
279
+ argsArray.Set(index, taskResultToJS(env, invocation->args[index]));
280
+ }
281
+ request.Set("args", argsArray);
282
+ Napi::Object kwargsObject = Napi::Object::New(env);
283
+ for (const auto& [key, value] : invocation->kwargs) {
284
+ kwargsObject.Set(key, taskResultToJS(env, value));
285
+ }
286
+ request.Set("kwargs", kwargsObject);
287
+ request.Set("resolve", Napi::Function::New(
288
+ env,
289
+ [completion](const Napi::CallbackInfo& info) {
290
+ try {
291
+ if (info.Length() < 1 || !info[0].IsObject()) {
292
+ failCallback(completion, "TypeError", "Pynect callback returned an invalid value");
293
+ return;
294
+ }
295
+ completeCallback(completion, jsToSerializedValue(info[0].As<Napi::Object>()));
296
+ } catch (const std::exception& error) {
297
+ failCallback(completion, "TypeError", error.what());
298
+ }
299
+ }
300
+ ));
301
+ request.Set("reject", Napi::Function::New(
302
+ env,
303
+ [completion](const Napi::CallbackInfo& info) {
304
+ std::string name = "Error";
305
+ std::string message = "JavaScript callback failed";
306
+ std::string stack;
307
+ if (info.Length() > 0 && info[0].IsObject()) {
308
+ Napi::Object error = info[0].As<Napi::Object>();
309
+ if (error.Has("name")) name = error.Get("name").ToString().Utf8Value();
310
+ if (error.Has("message")) message = error.Get("message").ToString().Utf8Value();
311
+ if (error.Has("stack")) stack = error.Get("stack").ToString().Utf8Value();
312
+ }
313
+ failCallback(completion, std::move(name), std::move(message), std::move(stack));
314
+ }
315
+ ));
316
+ dispatcher.Call({request});
317
+ } catch (const std::exception& error) {
318
+ failCallback(completion, "Error", error.what());
319
+ }
320
+ delete invocation;
321
+ }
322
+ );
323
+ if (status != napi_ok) {
324
+ delete payload;
325
+ PyErr_SetString(PyExc_RuntimeError, "Unable to schedule the JavaScript callback");
326
+ return nullptr;
327
+ }
328
+
329
+ if (!g_threadPool) {
330
+ failCallback(completion, "RuntimeError", "Pynect thread pool is unavailable");
331
+ } else {
332
+ g_threadPool->pumpUntil(completion->completed);
333
+ }
334
+ if (!completion->completed.load(std::memory_order_acquire)) {
335
+ PyErr_SetString(PyExc_RuntimeError, "Pynect stopped while a callback was running");
336
+ return nullptr;
337
+ }
338
+
339
+ std::lock_guard<std::mutex> lock(completion->mutex);
340
+ if (completion->failed) {
341
+ std::string message = completion->errorName + ": " + completion->errorMessage;
342
+ if (g_includeTracebacks && !completion->errorStack.empty()) {
343
+ message += "\n" + completion->errorStack;
344
+ }
345
+ PyErr_SetString(PyExc_RuntimeError, message.c_str());
346
+ return nullptr;
347
+ }
348
+ return PythonBridge::getInstance().deserializeValue(completion->value);
349
+ }
350
+
351
+ PyObject* createPythonCallback(uint32_t callbackId) {
352
+ if (!callbackState(callbackId)) {
353
+ PyErr_SetString(PyExc_RuntimeError, "Pynect callback is not registered");
354
+ return nullptr;
355
+ }
356
+ auto* storedId = new uint32_t(callbackId);
357
+ PyObject* capsule = PyCapsule_New(storedId, CALLBACK_CAPSULE, destroyCallbackCapsule);
358
+ if (!capsule) {
359
+ delete storedId;
360
+ return nullptr;
361
+ }
362
+ PyRef capsuleRef = PyRef::steal(capsule);
363
+ static PyMethodDef method = {
364
+ "pynect_callback",
365
+ reinterpret_cast<PyCFunction>(invokeJavaScriptCallback),
366
+ METH_VARARGS | METH_KEYWORDS,
367
+ "Invoke a JavaScript callback registered with Pynect."
368
+ };
369
+ return PyCFunction_NewEx(&method, capsule, nullptr);
370
+ }
371
+
372
+ static uint32_t pythonRecursionLimit() {
373
+ GILAcquire gil;
374
+ PyObject* sys = PyImport_ImportModule("sys");
375
+ if (!sys) throw std::runtime_error("Unable to import sys for the recursion limit");
376
+ PyRef sysRef = PyRef::steal(sys);
377
+ PyObject* getter = PyObject_GetAttrString(sys, "getrecursionlimit");
378
+ if (!getter) throw std::runtime_error("sys.getrecursionlimit is unavailable");
379
+ PyRef getterRef = PyRef::steal(getter);
380
+ PyObject* value = PyObject_CallNoArgs(getter);
381
+ if (!value) throw std::runtime_error("Unable to read Python's recursion limit");
382
+ PyRef valueRef = PyRef::steal(value);
383
+ const long limit = PyLong_AsLong(value);
384
+ if (limit <= 0) throw std::runtime_error("Python returned an invalid recursion limit");
385
+ return static_cast<uint32_t>(limit);
386
+ }
387
+
118
388
  // Exported Functions
119
389
 
120
390
  /**
@@ -156,6 +426,9 @@ static Napi::Value InitializePython(const Napi::CallbackInfo& info) {
156
426
  uint32_t maxQueueSize = opts.Has("maxQueueSize") ?
157
427
  opts.Get("maxQueueSize").As<Napi::Number>().Uint32Value() : 1000;
158
428
 
429
+ uint32_t callbackMaxDepth = opts.Has("callbackMaxDepth") ?
430
+ opts.Get("callbackMaxDepth").As<Napi::Number>().Uint32Value() : 0;
431
+
159
432
  g_includeTracebacks = opts.Has("includeTracebacks") &&
160
433
  opts.Get("includeTracebacks").ToBoolean().Value();
161
434
 
@@ -192,6 +465,7 @@ static Napi::Value InitializePython(const Napi::CallbackInfo& info) {
192
465
  pythonPathExtra,
193
466
  envVars
194
467
  );
468
+ g_callbackMaxDepth = callbackMaxDepth > 0 ? callbackMaxDepth : pythonRecursionLimit();
195
469
 
196
470
  // Initialize thread pool
197
471
  g_threadPool = std::make_unique<PythonThreadPool>(threadPoolSize, maxQueueSize);
@@ -225,6 +499,16 @@ static Napi::Value FinalizePython(const Napi::CallbackInfo& info) {
225
499
  "Cannot finalize Python while calls are active or queued"
226
500
  );
227
501
  }
502
+ std::vector<std::shared_ptr<CallbackState>> callbacks;
503
+ {
504
+ std::lock_guard<std::mutex> lock(g_callbacksMutex);
505
+ for (auto& [id, callback] : g_callbacks) {
506
+ callback->active.store(false);
507
+ callbacks.push_back(callback);
508
+ }
509
+ g_callbacks.clear();
510
+ }
511
+ for (auto& callback : callbacks) callback->dispatcher.Release();
228
512
  if (g_threadPool) {
229
513
  g_threadPool->shutdown();
230
514
  g_threadPool.reset();
@@ -247,6 +531,51 @@ static Napi::Value IsInitialized(const Napi::CallbackInfo& info) {
247
531
  return Napi::Boolean::New(info.Env(), g_initialized);
248
532
  }
249
533
 
534
+ static Napi::Value RegisterCallback(const Napi::CallbackInfo& info) {
535
+ Napi::Env env = info.Env();
536
+ if (!g_initialized || info.Length() < 1 || !info[0].IsFunction()) {
537
+ Napi::TypeError::New(env, "registerCallback expects a function on an initialized runtime")
538
+ .ThrowAsJavaScriptException();
539
+ return env.Undefined();
540
+ }
541
+ const uint32_t callbackId = g_nextCallbackId.fetch_add(1, std::memory_order_relaxed);
542
+ Napi::ThreadSafeFunction dispatcher = Napi::ThreadSafeFunction::New(
543
+ env,
544
+ info[0].As<Napi::Function>(),
545
+ "PynectJavaScriptCallback",
546
+ 0,
547
+ 1
548
+ );
549
+ dispatcher.Unref(env);
550
+ auto state = std::make_shared<CallbackState>(std::move(dispatcher));
551
+ {
552
+ std::lock_guard<std::mutex> lock(g_callbacksMutex);
553
+ g_callbacks.emplace(callbackId, state);
554
+ }
555
+ return Napi::Number::New(env, callbackId);
556
+ }
557
+
558
+ static Napi::Value DisposeCallback(const Napi::CallbackInfo& info) {
559
+ Napi::Env env = info.Env();
560
+ if (info.Length() < 1 || !info[0].IsNumber()) {
561
+ Napi::TypeError::New(env, "disposeCallback expects a callback ID")
562
+ .ThrowAsJavaScriptException();
563
+ return env.Undefined();
564
+ }
565
+ const uint32_t callbackId = info[0].As<Napi::Number>().Uint32Value();
566
+ std::shared_ptr<CallbackState> state;
567
+ {
568
+ std::lock_guard<std::mutex> lock(g_callbacksMutex);
569
+ auto found = g_callbacks.find(callbackId);
570
+ if (found != g_callbacks.end()) {
571
+ state = found->second;
572
+ g_callbacks.erase(found);
573
+ }
574
+ }
575
+ if (state && state->active.exchange(false)) state->dispatcher.Release();
576
+ return env.Undefined();
577
+ }
578
+
250
579
  // Generic async wrapper
251
580
 
252
581
  /**
@@ -257,7 +586,8 @@ static Napi::Value SubmitTask(
257
586
  Napi::Env env,
258
587
  std::function<TaskResult()> work,
259
588
  uint32_t timeoutMs = 0,
260
- std::string cancelKey = {}
589
+ std::string cancelKey = {},
590
+ uint32_t callbackDepth = 0
261
591
  ) {
262
592
  auto deferred = std::make_shared<Napi::Promise::Deferred>(
263
593
  Napi::Promise::Deferred::New(env)
@@ -269,8 +599,12 @@ static Napi::Value SubmitTask(
269
599
  }
270
600
 
271
601
  // Capture deferred by shared_ptr to safely use in callback
602
+ auto scopedWork = [work = std::move(work), callbackDepth]() mutable -> TaskResult {
603
+ CallbackDepthScope scope(callbackDepth);
604
+ return work();
605
+ };
272
606
  g_threadPool->submit(
273
- std::move(work),
607
+ std::move(scopedWork),
274
608
  [deferred](TaskResult result) {
275
609
  Napi::Env callEnv = deferred->Env();
276
610
  Napi::HandleScope scope(callEnv);
@@ -481,11 +815,14 @@ static Napi::Value CallFunction(const Napi::CallbackInfo& info) {
481
815
  if (optsObj.Has("requestId") && optsObj.Get("requestId").IsString()) {
482
816
  options.requestId = optsObj.Get("requestId").As<Napi::String>().Utf8Value();
483
817
  }
818
+ if (optsObj.Has("callbackDepth") && optsObj.Get("callbackDepth").IsNumber()) {
819
+ options.callbackDepth = optsObj.Get("callbackDepth").As<Napi::Number>().Uint32Value();
820
+ }
484
821
  }
485
822
 
486
823
  return SubmitTask(env, [objectId, path, options]() -> TaskResult {
487
824
  return PythonBridge::getInstance().callFunctionSync(objectId, path, options);
488
- }, options.timeoutMs, options.requestId);
825
+ }, options.timeoutMs, options.requestId, options.callbackDepth);
489
826
  }
490
827
 
491
828
  /**
@@ -516,11 +853,14 @@ static Napi::Value CallMethod(const Napi::CallbackInfo& info) {
516
853
  if (optsObj.Has("requestId") && optsObj.Get("requestId").IsString()) {
517
854
  options.requestId = optsObj.Get("requestId").As<Napi::String>().Utf8Value();
518
855
  }
856
+ if (optsObj.Has("callbackDepth") && optsObj.Get("callbackDepth").IsNumber()) {
857
+ options.callbackDepth = optsObj.Get("callbackDepth").As<Napi::Number>().Uint32Value();
858
+ }
519
859
  }
520
860
 
521
861
  return SubmitTask(env, [objectId, methodName, options]() -> TaskResult {
522
862
  return PythonBridge::getInstance().callMethodSync(objectId, methodName, options);
523
- }, options.timeoutMs, options.requestId);
863
+ }, options.timeoutMs, options.requestId, options.callbackDepth);
524
864
  }
525
865
 
526
866
  /**
@@ -890,6 +1230,8 @@ static Napi::Object Init(Napi::Env env, Napi::Object exports) {
890
1230
  exports.Set("initializePython", Napi::Function::New(env, InitializePython));
891
1231
  exports.Set("finalizePython", Napi::Function::New(env, FinalizePython));
892
1232
  exports.Set("isInitialized", Napi::Function::New(env, IsInitialized));
1233
+ exports.Set("registerCallback", Napi::Function::New(env, RegisterCallback));
1234
+ exports.Set("disposeCallback", Napi::Function::New(env, DisposeCallback));
893
1235
 
894
1236
  // Module import
895
1237
  exports.Set("importModule", Napi::Function::New(env, ImportModule));
@@ -6,6 +6,7 @@
6
6
  #include "python_bridge.h"
7
7
  #include "gil_guard.h"
8
8
  #include "type_converter.h"
9
+ #include "callback_bridge.h"
9
10
 
10
11
  #include <Python.h>
11
12
  #include <sstream>
@@ -688,6 +689,9 @@ PyObject* PythonBridge::_deserialize(const SerializedValue& value) {
688
689
  case SerializedFormat::NONE:
689
690
  Py_RETURN_NONE;
690
691
 
692
+ case SerializedFormat::JS_CALLBACK:
693
+ return createPythonCallback(value.metadata.callbackId);
694
+
691
695
  default:
692
696
  // Try JSON fallback
693
697
  if (!value.jsonData.empty()) {
@@ -697,6 +701,14 @@ PyObject* PythonBridge::_deserialize(const SerializedValue& value) {
697
701
  }
698
702
  }
699
703
 
704
+ TaskResult PythonBridge::serializeValue(PyObject* obj) {
705
+ return _makeSuccessResult(obj);
706
+ }
707
+
708
+ PyObject* PythonBridge::deserializeValue(const SerializedValue& value) {
709
+ return _deserialize(value);
710
+ }
711
+
700
712
  // Code Execution
701
713
 
702
714
  TaskResult PythonBridge::runCodeSync(const std::string& code) {
@@ -27,6 +27,7 @@ struct CallOptions {
27
27
  std::vector<std::pair<std::string, SerializedValue>> kwargs;
28
28
  uint32_t timeoutMs = 0;
29
29
  uint32_t executionTimeoutMs = 0;
30
+ uint32_t callbackDepth = 0;
30
31
  std::string requestId;
31
32
  };
32
33
 
@@ -214,6 +215,12 @@ public:
214
215
  void collectGarbage();
215
216
  size_t getTrackedObjectCount() const;
216
217
 
218
+ /** Serialize a callback argument using the normal Python -> JavaScript rules. */
219
+ TaskResult serializeValue(PyObject* obj);
220
+
221
+ /** Deserialize a JavaScript callback result using the normal argument rules. */
222
+ PyObject* deserializeValue(const SerializedValue& value);
223
+
217
224
  private:
218
225
  PythonBridge() = default;
219
226
  ~PythonBridge() = default;
@@ -178,72 +178,79 @@ void PythonThreadPool::_workerThread(uint32_t threadId) {
178
178
  _stats.currentQueueSize.fetch_sub(1, std::memory_order_relaxed);
179
179
  }
180
180
 
181
- // Check timeout before executing
182
- if (_isTimedOut(task)) {
183
- TaskResult timeoutResult;
184
- timeoutResult.success = false;
185
- timeoutResult.errorType = "TimeoutError";
186
- timeoutResult.errorMessage = "Python task timed out after " +
187
- std::to_string(task.timeoutMs) + "ms in queue";
188
- timeoutResult.errorTraceback = "";
189
- _stats.totalTasksTimedOut.fetch_add(1, std::memory_order_relaxed);
190
- _postResult(task.id, std::move(timeoutResult), std::move(task.callback));
191
- continue;
192
- }
193
-
194
- // Execute the task with GIL held
195
- _stats.activeThreads.fetch_add(1, std::memory_order_relaxed);
196
-
197
- auto startTime = std::chrono::steady_clock::now();
198
- TaskResult result;
199
-
200
- try {
201
- // Acquire GIL - critical: must be released when we're done
181
+ {
202
182
  GILAcquire gil;
203
-
204
- // Check timeout again (task might have waited in queue)
205
- if (task.timeoutMs > 0 && _isTimedOut(task)) {
206
- result.success = false;
207
- result.errorType = "TimeoutError";
208
- result.errorMessage = "Python task timed out";
209
- result.errorTraceback = "";
210
- } else {
211
- // Execute the Python work
212
- result = task.work();
213
- }
214
- } catch (const std::exception& e) {
215
- result.success = false;
216
- result.errorType = "RuntimeError";
217
- result.errorMessage = std::string("C++ exception in worker thread: ") + e.what();
218
- result.errorTraceback = "";
219
- } catch (...) {
220
- result.success = false;
221
- result.errorType = "RuntimeError";
222
- result.errorMessage = "Unknown C++ exception in worker thread";
223
- result.errorTraceback = "";
183
+ _executeTask(std::move(task));
224
184
  }
185
+ }
186
+ }
225
187
 
226
- auto endTime = std::chrono::steady_clock::now();
227
- auto durationUs = std::chrono::duration_cast<std::chrono::microseconds>(endTime - startTime).count();
228
- _stats.totalExecutionTimeUs.fetch_add(static_cast<uint64_t>(durationUs), std::memory_order_relaxed);
188
+ void PythonThreadPool::_executeTask(PythonTask task) {
189
+ _stats.activeThreads.fetch_add(1, std::memory_order_relaxed);
190
+ const auto startTime = std::chrono::steady_clock::now();
191
+ TaskResult result;
229
192
 
230
- if (result.success) {
231
- _stats.totalTasksCompleted.fetch_add(1, std::memory_order_relaxed);
193
+ try {
194
+ if (_isTimedOut(task)) {
195
+ result.success = false;
196
+ result.errorType = "TimeoutError";
197
+ result.errorMessage = "Python task timed out after " +
198
+ std::to_string(task.timeoutMs) + "ms in queue";
199
+ _stats.totalTasksTimedOut.fetch_add(1, std::memory_order_relaxed);
232
200
  } else {
233
- _stats.totalTasksFailed.fetch_add(1, std::memory_order_relaxed);
201
+ result = task.work();
234
202
  }
203
+ } catch (const std::exception& e) {
204
+ result.success = false;
205
+ result.errorType = "RuntimeError";
206
+ result.errorMessage = std::string("C++ exception in worker thread: ") + e.what();
207
+ } catch (...) {
208
+ result.success = false;
209
+ result.errorType = "RuntimeError";
210
+ result.errorMessage = "Unknown C++ exception in worker thread";
211
+ }
235
212
 
236
- _stats.activeThreads.fetch_sub(1, std::memory_order_relaxed);
213
+ const auto durationUs = std::chrono::duration_cast<std::chrono::microseconds>(
214
+ std::chrono::steady_clock::now() - startTime
215
+ ).count();
216
+ _stats.totalExecutionTimeUs.fetch_add(static_cast<uint64_t>(durationUs), std::memory_order_relaxed);
217
+ if (result.success) _stats.totalTasksCompleted.fetch_add(1, std::memory_order_relaxed);
218
+ else _stats.totalTasksFailed.fetch_add(1, std::memory_order_relaxed);
219
+ _stats.activeThreads.fetch_sub(1, std::memory_order_relaxed);
220
+ _postResult(task.id, std::move(result), std::move(task.callback));
237
221
 
238
- // Post result back to Node.js event loop
239
- _postResult(task.id, std::move(result), std::move(task.callback));
222
+ if (_draining.load()) _drainCv.notify_all();
223
+ }
240
224
 
241
- // Signal drain waiters
242
- if (_draining.load()) {
243
- std::lock_guard<std::mutex> lock(_queueMutex);
244
- _drainCv.notify_all();
225
+ void PythonThreadPool::pumpUntil(const std::atomic<bool>& completed) {
226
+ PyThreadState* state = PyEval_SaveThread();
227
+ while (!completed.load(std::memory_order_acquire) && _running.load()) {
228
+ PythonTask task;
229
+ bool hasTask = false;
230
+ {
231
+ std::unique_lock<std::mutex> lock(_queueMutex);
232
+ _queueCv.wait(lock, [this, &completed] {
233
+ return completed.load(std::memory_order_acquire) ||
234
+ !_queue.empty() || !_running.load();
235
+ });
236
+ if (!completed.load(std::memory_order_acquire) && !_queue.empty()) {
237
+ task = std::move(_queue.front());
238
+ _queue.pop_front();
239
+ _stats.currentQueueSize.fetch_sub(1, std::memory_order_relaxed);
240
+ hasTask = true;
241
+ }
242
+ }
243
+ if (hasTask) {
244
+ PyEval_RestoreThread(state);
245
+ _executeTask(std::move(task));
246
+ state = PyEval_SaveThread();
245
247
  }
246
248
  }
249
+ PyEval_RestoreThread(state);
250
+ }
251
+
252
+ void PythonThreadPool::notifyWaiters() {
253
+ _queueCv.notify_all();
247
254
  }
248
255
 
249
256
  // Result Delivery
@@ -144,10 +144,23 @@ public:
144
144
  /** Check if pool is running */
145
145
  bool isRunning() const noexcept { return _running.load(); }
146
146
 
147
+ /**
148
+ * Release the GIL and keep servicing queued Python work until a JavaScript
149
+ * callback completes. This preserves progress for reentrant calls even with
150
+ * a one-thread pool.
151
+ */
152
+ void pumpUntil(const std::atomic<bool>& completed);
153
+
154
+ /** Wake workers that may be waiting for callback completion. */
155
+ void notifyWaiters();
156
+
147
157
  private:
148
158
  /** Worker thread entry point */
149
159
  void _workerThread(uint32_t threadId);
150
160
 
161
+ /** Execute and deliver one task while the caller holds the GIL. */
162
+ void _executeTask(PythonTask task);
163
+
151
164
  /** Post result back to event loop via libuv */
152
165
  void _postResult(uint64_t taskId, TaskResult result, std::function<void(TaskResult)> callback);
153
166
 
@@ -34,6 +34,7 @@ enum class SerializedFormat : uint8_t {
34
34
  PYTHON_REF = 6,
35
35
  BYTES = 7,
36
36
  NONE = 8,
37
+ JS_CALLBACK = 9,
37
38
  };
38
39
 
39
40
  /**
@@ -52,6 +53,7 @@ struct SerializedMetadata {
52
53
 
53
54
  // For PYTHON_REF
54
55
  uint32_t objectId = 0;
56
+ uint32_t callbackId = 0;
55
57
 
56
58
  // General
57
59
  int64_t length = 0;
@@ -1,3 +0,0 @@
1
- export declare function isPythonModuleName(value: string): boolean;
2
- export declare function assertPythonPackageSpec(spec: string): void;
3
- //# sourceMappingURL=executables.d.ts.map