node-gtk 2.0.0 → 2.1.0

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.
package/README.md CHANGED
@@ -66,6 +66,48 @@ process.exit(app.run([]));
66
66
  <img src="./img/hello-world.png" style="width: 290px; height: auto;"/>
67
67
  </p>
68
68
 
69
+ #### ES modules
70
+
71
+ The example above is CommonJS. node-gtk also works under ES modules, but with one
72
+ behavioral difference around the blocking main-loop calls (`GLib.MainLoop.run`,
73
+ `Gio`/`Gtk.Application.run`, `Gtk.main`).
74
+
75
+ Under ESM, a module's top-level body runs as a V8 *microtask*. If a blocking
76
+ loop call ran synchronously from there, it would nest inside V8's microtask
77
+ drain and starve every `Promise`/`async` continuation (so `await fetch(...)`,
78
+ `fs/promises`, etc. would never settle) for the entire lifetime of the loop —
79
+ see [#442](https://github.com/romgrk/node-gtk/issues/442). To avoid this,
80
+ node-gtk defers the blocking call to the next event-loop tick when it detects it
81
+ is being invoked from within a microtask. Two consequences for ESM code:
82
+
83
+ - The run call **returns immediately** instead of blocking until quit, so any
84
+ code placed *after* it executes *before* the loop. Make the run call the last
85
+ statement, and do cleanup/exit from your quit/`close-request` handler.
86
+ - Its **return value is not available** (e.g. the application's exit status), so
87
+ don't wrap it like `process.exit(app.run([]))` — call `app.run([])` on its own
88
+ and exit from the handler instead.
89
+
90
+ Under CommonJS (and inside signal callbacks) nothing changes: the run calls block
91
+ synchronously and return their value exactly as before.
92
+
93
+ > **Design note (may be reconsidered).** Two approaches were considered for #442:
94
+ >
95
+ > - **Transparent auto-defer (chosen).** node-gtk detects the microtask context
96
+ > and defers the blocking call automatically, so existing ESM code keeps
97
+ > working with no changes. The cost is the leaky behavior above: under ESM the
98
+ > run call no longer blocks, which is surprising and silently breaks idioms
99
+ > like `process.exit(app.run([]))`.
100
+ > - **Explicit async API (alternative).** Keep the blocking calls strictly
101
+ > synchronous and add awaitable variants (e.g. `await loop.runAsync()`),
102
+ > leaving ESM users to opt in. This has clean, predictable semantics and no
103
+ > hidden return-immediately behavior, but it does *not* transparently fix
104
+ > existing ESM code — users must change their code, and plain `loop.run()`
105
+ > would still starve microtasks under ESM (or would need to throw/warn).
106
+ >
107
+ > The transparent approach was chosen to fix existing code out of the box, but
108
+ > given the leaky semantics this trade-off may be revisited — possibly moving to
109
+ > (or also offering) the explicit async API.
110
+
69
111
  You can also easily create custom applications:
70
112
 
71
113
  [A web browser (using WebKit2GTK)](./examples/browser.js)
package/lib/loop.js CHANGED
@@ -6,6 +6,7 @@ const internal = require('./native.js')
6
6
 
7
7
  module.exports = {
8
8
  start,
9
+ runLoopEntry,
9
10
  }
10
11
 
11
12
 
@@ -32,6 +33,39 @@ function start() {
32
33
  }
33
34
 
34
35
 
36
+ /**
37
+ * Runs a blocking main-loop entry point (e.g. GLib.MainLoop.run,
38
+ * Gio.Application.run, Gtk.main) so that Promise/async continuations keep
39
+ * draining while it blocks.
40
+ *
41
+ * Under ES modules the top-level body executes as a V8 microtask, so a blocking
42
+ * call made directly from it nests inside V8's microtask drain. V8 refuses
43
+ * nested microtask checkpoints, so any pending Promise/async continuation is
44
+ * starved for the entire lifetime of the loop. Deferring the blocking call to a
45
+ * macrotask lets the module's top-level microtask return first, so the queue
46
+ * drains and the loop integration takes over from a clean (non-nested) state.
47
+ *
48
+ * Under CommonJS (and inside signal callbacks) we are not in a microtask, so we
49
+ * run synchronously and preserve the original blocking semantics exactly.
50
+ *
51
+ * https://github.com/romgrk/node-gtk/issues/442
52
+ *
53
+ * Returns the native call's result when run synchronously (so e.g.
54
+ * `const status = app.run()` keeps working under CommonJS); returns undefined
55
+ * when deferred, since the result is not yet available.
56
+ *
57
+ * @param {Function} run - performs the blocking native call
58
+ * @returns {*} the native return value, or undefined when deferred
59
+ */
60
+ function runLoopEntry(run) {
61
+ if (internal.IsRunningMicrotasks()) {
62
+ setImmediate(run)
63
+ return undefined
64
+ }
65
+ return run()
66
+ }
67
+
68
+
35
69
  // Helpers
36
70
 
37
71
  function wrappedLoopFunction(fn) {
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const internal = require('../native.js')
6
+ const { runLoopEntry } = require('../loop.js')
6
7
 
7
8
  exports.apply = (GLib) => {
8
9
 
@@ -10,17 +11,19 @@ exports.apply = (GLib) => {
10
11
  GLib.MainLoop.prototype._quit = GLib.MainLoop.prototype.quit
11
12
 
12
13
  GLib.MainLoop.prototype.run = function run() {
13
- /* Run before we enter the loop otherwise pending microtasks
14
- * are not run */
15
- process._tickCallback()
16
-
17
14
  const loopStack = internal.GetLoopStack()
18
-
19
15
  loopStack.push(() => this.quit())
20
- this._run()
21
- if (this._userQuit)
22
- loopStack.pop()
23
- delete this._userQuit
16
+
17
+ return runLoopEntry(() => {
18
+ /* Run before we enter the loop otherwise pending microtasks
19
+ * are not run */
20
+ process._tickCallback()
21
+
22
+ this._run()
23
+ if (this._userQuit)
24
+ loopStack.pop()
25
+ delete this._userQuit
26
+ })
24
27
  }
25
28
  GLib.MainLoop.prototype.quit = function quit() {
26
29
  this._userQuit = true
@@ -0,0 +1,26 @@
1
+ /*
2
+ * Gio-2.0.js
3
+ */
4
+
5
+ const { runLoopEntry } = require('../loop.js')
6
+
7
+ exports.apply = (Gio) => {
8
+
9
+ Gio.Application.prototype._run = Gio.Application.prototype.run
10
+
11
+ /* g_application_run() blocks until the application quits. Under ES modules
12
+ * this would starve Promise/async continuations; runLoopEntry() defers the
13
+ * blocking call when needed so they keep draining. See loop.js / #442.
14
+ *
15
+ * Note: when deferred (ESM), the exit status is not available synchronously,
16
+ * so `app.run()` returns undefined instead of the status code in that case. */
17
+ Gio.Application.prototype.run = function run(...args) {
18
+ return runLoopEntry(() => {
19
+ /* Run before we enter the loop otherwise pending microtasks
20
+ * are not run */
21
+ process._tickCallback()
22
+
23
+ return this._run(...args)
24
+ })
25
+ }
26
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  const internal = require('../native.js')
6
+ const { runLoopEntry } = require('../loop.js')
6
7
 
7
8
  /**
8
9
  * @typedef {Object} Dimension
@@ -27,10 +28,6 @@ exports.apply = (Gtk) => {
27
28
  let userCallingQuit = false
28
29
 
29
30
  Gtk.main = function main() {
30
- /* Run before we enter the loop otherwise pending microtasks
31
- * are not run */
32
- process._tickCallback()
33
-
34
31
  const loopStack = internal.GetLoopStack()
35
32
 
36
33
  /*
@@ -42,17 +39,25 @@ exports.apply = (Gtk) => {
42
39
 
43
40
  loopStack.push(originalQuit)
44
41
 
45
- originalMain()
42
+ /* runLoopEntry() defers the blocking call under ES modules so pending
43
+ * Promise/async continuations keep draining. See loop.js / #442. */
44
+ return runLoopEntry(() => {
45
+ /* Run before we enter the loop otherwise pending microtasks
46
+ * are not run */
47
+ process._tickCallback()
46
48
 
47
- if (userCallingQuit) {
48
- loopStack.pop()
49
- }
49
+ originalMain()
50
50
 
51
- userCallingQuit = false
51
+ if (userCallingQuit) {
52
+ loopStack.pop()
53
+ }
52
54
 
53
- if (Gtk.mainLevel() === 0) {
54
- placeholderIntervalID = clearInterval(placeholderIntervalID)
55
- }
55
+ userCallingQuit = false
56
+
57
+ if (Gtk.mainLevel() === 0) {
58
+ placeholderIntervalID = clearInterval(placeholderIntervalID)
59
+ }
60
+ })
56
61
  }
57
62
 
58
63
  Gtk.mainQuit = function mainQuit() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-gtk",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "GNOME Gtk+ bindings for NodeJS",
5
5
  "main": "lib/index.js",
6
6
  "bin": {
package/src/closure.cc CHANGED
@@ -113,9 +113,19 @@ void Closure::Execute(GICallableInfo *info, guint signal_id,
113
113
  LoadGIArgumentFromPointer(&type_info, &argument);
114
114
  ownership = kNone;
115
115
  } else if (direction == GI_DIRECTION_OUT) {
116
- // Pure out: there is no meaningful incoming value.
117
- memset(&argument, 0, sizeof(argument));
118
- ownership = kNone;
116
+ if (g_arg_info_is_caller_allocates(&arg_info)) {
117
+ // Caller-allocated out (e.g. a GdkRectangle in
118
+ // GtkOverlay::get-child-position): the GValue already holds a
119
+ // pointer to caller (GLib/GTK) memory. Pass a live wrapper so the
120
+ // handler fills it in place — it is NOT written back from the
121
+ // return value (see the output loop below). #444.
122
+ ownership = kNone;
123
+ } else {
124
+ // Callee-allocated out: no meaningful incoming value; the handler
125
+ // supplies it through the return value.
126
+ memset(&argument, 0, sizeof(argument));
127
+ ownership = kNone;
128
+ }
119
129
  }
120
130
 
121
131
  js_args[i - 1] = GIArgumentToV8(&type_info, &argument, -1, ownership);
@@ -156,11 +166,14 @@ void Closure::Execute(GICallableInfo *info, guint signal_id,
156
166
  GIArgInfo out_arg_info;
157
167
  GITypeInfo out_type_info;
158
168
 
169
+ // Caller-allocated out structs are filled in place via the wrapper
170
+ // passed to the handler, so they are not part of the return value.
159
171
  int n_outputs = (g_return_value != NULL) ? 1 : 0;
160
172
  for (guint i = 1; i < n_param_values; i++) {
161
173
  g_callable_info_load_arg(info, i - 1, &out_arg_info);
162
174
  GIDirection d = g_arg_info_get_direction(&out_arg_info);
163
- if (d == GI_DIRECTION_OUT || d == GI_DIRECTION_INOUT)
175
+ if (d == GI_DIRECTION_INOUT ||
176
+ (d == GI_DIRECTION_OUT && !g_arg_info_is_caller_allocates(&out_arg_info)))
164
177
  n_outputs++;
165
178
  }
166
179
 
@@ -184,6 +197,10 @@ void Closure::Execute(GICallableInfo *info, guint signal_id,
184
197
  GIDirection d = g_arg_info_get_direction(&out_arg_info);
185
198
  if (d != GI_DIRECTION_OUT && d != GI_DIRECTION_INOUT)
186
199
  continue;
200
+ // Caller-allocated out structs were filled in place by the handler
201
+ // via their wrapper; nothing to write back from the return value.
202
+ if (d == GI_DIRECTION_OUT && g_arg_info_is_caller_allocates(&out_arg_info))
203
+ continue;
187
204
 
188
205
  g_arg_info_load_type(&out_arg_info, &out_type_info);
189
206
 
package/src/function.cc CHANGED
@@ -481,12 +481,15 @@ Local<Value> FunctionCall (
481
481
  param.data.v_pointer = CaptureTransferContainerElements(
482
482
  &type_info, callable_arg_values[i].v_pointer);
483
483
  }
484
- // For a transfer-full IN boxed (or array of boxed) the callee
485
- // frees the memory; hand it a copy so the JS wrapper's own
486
- // memory isn't double-freed when it's finalized (#409).
484
+ // For a transfer-full IN argument the callee takes ownership.
485
+ // Boxed: hand it a copy so the JS wrapper's own memory isn't
486
+ // double-freed when finalized (#409). GObject: add the reference
487
+ // the callee will own, so it isn't finalized out from under the
488
+ // callee once the wrapper is GC'd (#439).
487
489
  else if (direction == GI_DIRECTION_IN
488
490
  && g_arg_info_get_ownership_transfer(&arg_info) == GI_TRANSFER_EVERYTHING) {
489
491
  CopyBoxedForTransferFullIn(&type_info, &callable_arg_values[i], param.length);
492
+ RefObjectForTransferFullIn(&type_info, &callable_arg_values[i]);
490
493
  }
491
494
  }
492
495
 
package/src/gi.cc CHANGED
@@ -380,6 +380,10 @@ NAN_METHOD(StartLoop) {
380
380
  GNodeJS::StartLoop ();
381
381
  }
382
382
 
383
+ NAN_METHOD(IsRunningMicrotasks) {
384
+ info.GetReturnValue().Set(Nan::New<Boolean>(GNodeJS::IsRunningMicrotasks ()));
385
+ }
386
+
383
387
  NAN_METHOD(GetBaseClass) {
384
388
  auto tpl = GNodeJS::GetBaseClassTemplate ();
385
389
  auto fn = Nan::GetFunction (tpl).ToLocalChecked();
@@ -425,6 +429,7 @@ void InitModule(Local<Object> exports, Local<Value> module, void *priv) {
425
429
  Nan::Export(exports, "ObjectPropertyGetter", ObjectPropertyGetter);
426
430
  Nan::Export(exports, "ObjectPropertySetter", ObjectPropertySetter);
427
431
  Nan::Export(exports, "StartLoop", StartLoop);
432
+ Nan::Export(exports, "IsRunningMicrotasks", IsRunningMicrotasks);
428
433
  Nan::Export(exports, "GetLoopStack", GetLoopStack);
429
434
  Nan::Export(exports, "RegisterClass", RegisterClass);
430
435
  Nan::Export(exports, "RegisterVFunc", RegisterVFunc);
package/src/gobject.cc CHANGED
@@ -34,7 +34,6 @@ namespace GNodeJS {
34
34
  static Nan::Persistent<FunctionTemplate> baseTemplate;
35
35
 
36
36
 
37
- static void GObjectDestroyed(const Nan::WeakCallbackInfo<GObject> &data);
38
37
  static MaybeLocal<FunctionTemplate> GetClassTemplate(GType gtype);
39
38
  static MaybeLocal<Function> GetClass(GType gtype);
40
39
  static void StoreVFunc(GType gtype, Callback *callback);
@@ -100,21 +99,66 @@ out:
100
99
  return gobject;
101
100
  }
102
101
 
102
+ struct GObjectWrapper;
103
+ static void GObjectDestroyedFirstPass(const v8::WeakCallbackInfo<GObjectWrapper> &data);
104
+ static void GObjectDestroyedSecondPass(const v8::WeakCallbackInfo<GObjectWrapper> &data);
105
+ static void GObjectFinalized(gpointer data, GObject *where_the_object_was);
106
+
107
+ struct GObjectWrapper {
108
+ Nan::Persistent<Object> persistent;
109
+ GObject *gobject;
110
+ /* Set to true the moment SetWeak is called. Between that point and the
111
+ * destroy callback actually running, the V8 handle is weak (and, once GC
112
+ * reclaims it, dead). If ToggleNotify fires in that window (because native
113
+ * code adjusts the refcount), touching the persistent crashes. Guard every
114
+ * persistent access with this flag. */
115
+ bool dying = false;
116
+ /* Set in the first-pass weak callback, i.e. the instant GC reclaims the
117
+ * wrapper, before any JS/GTK code resumes. While this is true the
118
+ * persistent is dead but the qdata still points here until the second-pass
119
+ * callback runs; WrapperFromGObject must build a fresh wrapper rather than
120
+ * resurrect this one. */
121
+ bool collected = false;
122
+ };
123
+
103
124
  static void ToggleNotify(gpointer user_data, GObject *gobject, gboolean toggle_down) {
104
125
  void *data = g_object_get_qdata (gobject, GNodeJS::object_quark());
105
126
 
106
127
  g_assert (data != NULL);
107
128
 
108
- auto *persistent = (Nan::Persistent<Object> *) data;
129
+ auto *wrapper = (GObjectWrapper *) data;
130
+
131
+ /* The V8 handle has already been reclaimed by GC (collected) — it is dead
132
+ * and can be made neither weak nor strong. If the object is marshalled
133
+ * again, WrapperFromGObject builds a fresh wrapper. */
134
+ if (wrapper->collected)
135
+ return;
109
136
 
110
137
  if (toggle_down) {
111
- /* We're dropping from 2 refs to 1 ref. We are the last holder. Make
112
- * sure that that our weak ref is installed. */
113
- persistent->SetWeak (gobject, GObjectDestroyed, v8::WeakCallbackType::kParameter);
138
+ /* We're dropping from 2 refs to 1 ref: we are the last holder, so the
139
+ * wrapper may be collected. Install the weak ref (unless it already is). */
140
+ if (wrapper->dying)
141
+ return;
142
+ wrapper->dying = true;
143
+ /* Two-pass weak callback: the first pass runs *during* GC (before any
144
+ * JS/GTK code resumes) and only flips a flag, so WrapperFromGObject can
145
+ * tell a reclaimed wrapper from a live one and never marshals a dead
146
+ * handle to JS. All GObject teardown happens in the second pass — a
147
+ * first-pass callback may not call into GObject. */
148
+ wrapper->persistent.v8::PersistentBase<Object>::SetWeak (
149
+ wrapper, GObjectDestroyedFirstPass, v8::WeakCallbackType::kParameter);
114
150
  } else {
115
- /* We're going from 1 ref to 2 refs. We can't let our wrapper be
116
- * collected, so make sure that our reference is persistent */
117
- persistent->ClearWeak ();
151
+ /* We're going from 1 ref to 2 refs: something other than us now holds
152
+ * the object, so the wrapper must stay alive (strong) until that ref is
153
+ * dropped again. Reviving here is essential — without it a wrapper that
154
+ * went weak once (e.g. a freshly constructed object at refcount 1) would
155
+ * never become strong again when GTK takes ownership, and GC could then
156
+ * collect a wrapper whose GObject is still in use (notably a subclassed
157
+ * widget owned by GTK, losing its overridden vfuncs and instance state). */
158
+ if (!wrapper->dying)
159
+ return;
160
+ wrapper->dying = false;
161
+ wrapper->persistent.ClearWeak ();
118
162
  }
119
163
  }
120
164
 
@@ -123,8 +167,10 @@ static void AssociateGObject(Local<Object> object, GObject *gobject, GType gtype
123
167
 
124
168
  SET_OBJECT_GTYPE(object, gtype);
125
169
 
126
- auto *persistent = new Nan::Persistent<Object>(object);
127
- g_object_set_qdata (gobject, GNodeJS::object_quark(), persistent);
170
+ auto *wrapper = new GObjectWrapper();
171
+ wrapper->gobject = gobject;
172
+ wrapper->persistent.Reset(object);
173
+ g_object_set_qdata (gobject, GNodeJS::object_quark(), wrapper);
128
174
 
129
175
  // Because we can't sink floating ref and add toggle ref at the same time,
130
176
  // first sink the floating ref, add the toggle ref, and then release the
@@ -132,6 +178,18 @@ static void AssociateGObject(Local<Object> object, GObject *gobject, GType gtype
132
178
  g_object_ref_sink (gobject);
133
179
  g_object_add_toggle_ref (gobject, ToggleNotify, NULL);
134
180
  g_object_unref (gobject);
181
+
182
+ // The toggle ref above is supposed to keep the GObject alive for as long as
183
+ // the wrapper exists. A weak ref guards against the case where it doesn't —
184
+ // e.g. a JS-subclassed instance whose refcount is driven to 0 from the GTK
185
+ // side while we still hold the toggle ref: GObjectFinalized then clears the
186
+ // dangling pointer so the destroy callbacks never touch freed memory.
187
+ g_object_weak_ref (gobject, GObjectFinalized, wrapper);
188
+ }
189
+
190
+ static void GObjectFinalized(gpointer data, GObject *where_the_object_was) {
191
+ auto *wrapper = (GObjectWrapper *) data;
192
+ wrapper->gobject = NULL;
135
193
  }
136
194
 
137
195
  static void GObjectConstructor(const FunctionCallbackInfo<Value> &info) {
@@ -183,18 +241,71 @@ static void GObjectConstructor(const FunctionCallbackInfo<Value> &info) {
183
241
  }
184
242
  }
185
243
 
186
- static void GObjectDestroyed(const Nan::WeakCallbackInfo<GObject> &data) {
187
- GObject *gobject = data.GetParameter ();
244
+ static void GObjectDestroyedFirstPass(const v8::WeakCallbackInfo<GObjectWrapper> &data) {
245
+ GObjectWrapper *wrapper = data.GetParameter ();
246
+
247
+ /* This runs *during* GC, where it is not legal to call into V8 (beyond
248
+ * resetting the handle, which the two-pass contract requires) or into
249
+ * GObject — the GObject is not safe to touch here, and doing so crashes in
250
+ * g_type_check_instance_is_fundamentally_a. So only flip a flag and reset
251
+ * the handle; the real teardown is deferred to the second pass.
252
+ *
253
+ * The flag lets WrapperFromGObject distinguish a reclaimed wrapper (whose
254
+ * persistent is now dead) from a live one during the window before the
255
+ * second pass nulls the qdata, so it builds a fresh wrapper instead of
256
+ * handing the dead handle to JS — which crashed on first property access. */
257
+ wrapper->collected = true;
258
+ wrapper->persistent.Reset ();
259
+
260
+ data.SetSecondPassCallback (GObjectDestroyedSecondPass);
261
+ }
188
262
 
189
- void *type_data = g_object_get_qdata (gobject, GNodeJS::object_quark());
190
- auto *persistent = (Nan::Persistent<Object> *) type_data;
191
- delete persistent;
263
+ static gboolean GObjectTeardownIdle(gpointer data) {
264
+ GObjectWrapper *wrapper = (GObjectWrapper *) data;
265
+ GObject *gobject = wrapper->gobject;
266
+
267
+ /* If the GObject was already finalized out from under us, GObjectFinalized
268
+ * cleared the pointer; there is nothing left to detach or unref. */
269
+ if (gobject != NULL) {
270
+ /* Drop the weak ref first so removing the toggle ref (which may finalize
271
+ * the object) doesn't re-enter GObjectFinalized. */
272
+ g_object_weak_unref (gobject, GObjectFinalized, wrapper);
273
+
274
+ /* Only detach the qdata if it still points at us — WrapperFromGObject
275
+ * may have resurrected this GObject with a fresh wrapper while we were
276
+ * pending, and we must not clobber it. */
277
+ if (g_object_get_qdata (gobject, GNodeJS::object_quark()) == wrapper)
278
+ g_object_set_qdata (gobject, GNodeJS::object_quark(), NULL);
279
+
280
+ /* Dropping the last toggle ref disposes the object, and GTK's dispose
281
+ * synchronously emits signals (e.g. ::destroy) into still-connected
282
+ * node-gtk closures — i.e. it re-enters arbitrary JS. That is only legal
283
+ * here because we run from a GLib idle on the main loop, not from the GC
284
+ * second-pass callback that scheduled us (see GObjectDestroyedSecondPass). */
285
+ g_object_remove_toggle_ref (gobject, &ToggleNotify, NULL);
286
+ }
192
287
 
193
- /* We're destroying the wrapper object, so make sure to clear out
194
- * the qdata that points back to us. */
195
- g_object_set_qdata (gobject, GNodeJS::object_quark(), NULL);
288
+ delete wrapper;
289
+ return G_SOURCE_REMOVE;
290
+ }
196
291
 
197
- g_object_remove_toggle_ref (gobject, &ToggleNotify, NULL);
292
+ static void GObjectDestroyedSecondPass(const v8::WeakCallbackInfo<GObjectWrapper> &data) {
293
+ GObjectWrapper *wrapper = data.GetParameter ();
294
+
295
+ /* Defer the actual teardown to a main-loop idle instead of running it here.
296
+ * This callback fires from V8's InvokeSecondPassPhantomCallbacks *during* a
297
+ * garbage collection. Dropping the toggle ref can take the GObject's refcount
298
+ * to zero, and GTK's dispose then emits signals into node-gtk closures,
299
+ * re-entering JS (Nan::Call) — which crashes when invoked mid-GC. Running the
300
+ * teardown from a GLib idle moves the ref drop (and any disposal/signal
301
+ * emission it triggers) to a point where calling into JS is safe again.
302
+ *
303
+ * The GObject stays alive across the window because we still hold the toggle
304
+ * ref; the wrapper (with its now-reset persistent and collected=true) is kept
305
+ * until the idle deletes it. WrapperFromGObject already handles a resurrected
306
+ * GObject during this window by building a fresh wrapper, and the idle's
307
+ * qdata check above won't clobber it. */
308
+ g_idle_add (GObjectTeardownIdle, wrapper);
198
309
  }
199
310
 
200
311
  static void GObjectClassDestroyed(const Nan::WeakCallbackInfo<GType> &info) {
@@ -657,10 +768,15 @@ Local<Value> WrapperFromGObject(GObject *gobject) {
657
768
  void *data = g_object_get_qdata (gobject, GNodeJS::object_quark());
658
769
 
659
770
  if (data) {
660
- /* Easy case: we already have an object. */
661
- auto *persistent = (Nan::Persistent<Object> *) data;
662
- auto obj = New<Object> (*persistent);
663
- return obj;
771
+ auto *wrapper = (GObjectWrapper *) data;
772
+ /* Reuse the existing wrapper unless GC has already reclaimed it (its
773
+ * persistent is dead and only awaiting the second-pass teardown). In
774
+ * that case fall through and build a fresh wrapper; the stale one's
775
+ * second pass is guarded so it won't clobber the new qdata. */
776
+ if (!wrapper->collected) {
777
+ auto obj = New<Object> (wrapper->persistent);
778
+ return obj;
779
+ }
664
780
  }
665
781
 
666
782
  GType gtype = G_OBJECT_TYPE(gobject);
package/src/loop.cc CHANGED
@@ -26,6 +26,8 @@ static Nan::Persistent<Array> loopStack(Nan::New<Array> ());
26
26
  struct uv_loop_source {
27
27
  GSource source;
28
28
  uv_loop_t *loop;
29
+ gpointer fd_tag;
30
+ bool fd_polled;
29
31
  };
30
32
 
31
33
  static gboolean loop_source_prepare (GSource *base, int *timeout) {
@@ -45,6 +47,24 @@ static gboolean loop_source_prepare (GSource *base, int *timeout) {
45
47
 
46
48
  bool loop_alive = uv_loop_alive (source->loop);
47
49
 
50
+ /* Toggle whether GLib polls uv's backend fd. When the loop is dead we must
51
+ * stop polling it: an *unref'd* but still-active uv handle (e.g. the async
52
+ * eventfd left signaling by emscripten/WASM runtimes such as web-tree-sitter,
53
+ * or by worker_threads) keeps the backend epoll fd perpetually readable.
54
+ * uv_loop_alive() reports the loop dead (unref'd handles don't count), so we
55
+ * intend to sleep here, but a polled-and-ready fd makes GLib's poll() return
56
+ * immediately every iteration -> loop_source_dispatch() busy-spins at 100%
57
+ * CPU, starving GTK. Masking the fd lets GLib actually block until a GTK
58
+ * source wakes us; we restore polling as soon as the loop is alive again. */
59
+ #if !OS_WINDOWS
60
+ if (source->fd_tag != NULL && loop_alive != source->fd_polled) {
61
+ g_source_modify_unix_fd (&source->source, source->fd_tag,
62
+ loop_alive ? (GIOCondition) (G_IO_IN | G_IO_OUT | G_IO_ERR)
63
+ : (GIOCondition) 0);
64
+ source->fd_polled = loop_alive;
65
+ }
66
+ #endif
67
+
48
68
  /* If the loop is dead, we can simply sleep forever until a GTK+ source
49
69
  * (presumably) wakes us back up again. */
50
70
  if (!loop_alive)
@@ -92,13 +112,15 @@ static GSourceFuncs uv_loop_source_funcs = {
92
112
  static GSource *loop_source_new (uv_loop_t *loop) {
93
113
  struct uv_loop_source *source = (struct uv_loop_source *) g_source_new (&uv_loop_source_funcs, sizeof (*source));
94
114
  source->loop = loop;
115
+ source->fd_tag = NULL;
116
+ source->fd_polled = true;
95
117
  #if OS_WINDOWS
96
118
  // FIXME
97
119
  // https://github.com/nodejs/node/issues/36015
98
120
  #else
99
- g_source_add_unix_fd (&source->source,
100
- uv_backend_fd (loop),
101
- (GIOCondition) (G_IO_IN | G_IO_OUT | G_IO_ERR));
121
+ source->fd_tag = g_source_add_unix_fd (&source->source,
122
+ uv_backend_fd (loop),
123
+ (GIOCondition) (G_IO_IN | G_IO_OUT | G_IO_ERR));
102
124
  #endif
103
125
  return &source->source;
104
126
  }
@@ -145,6 +167,20 @@ void CallMicrotaskHandlers () {
145
167
  #endif
146
168
  }
147
169
 
170
+ bool IsRunningMicrotasks() {
171
+ /* True while V8 is draining the microtask queue. Under ES modules the
172
+ * top-level body executes as a microtask, so a synchronous blocking call
173
+ * (e.g. g_main_loop_run) made from it nests inside this drain. V8 refuses
174
+ * nested microtask checkpoints, so any Promise/async continuation queued
175
+ * by user code is stuck until the blocking call returns. Callers use this
176
+ * to detect that situation and defer the blocking call to a macrotask so
177
+ * the module's top-level microtask can return and the queue can drain.
178
+ *
179
+ * - https://github.com/romgrk/node-gtk/issues/442
180
+ */
181
+ return MicrotasksScope::IsRunningMicrotasks(Isolate::GetCurrent());
182
+ }
183
+
148
184
  void StartLoop() {
149
185
  GSource *source = loop_source_new (uv_default_loop ());
150
186
  g_source_attach (source, NULL);
package/src/loop.h CHANGED
@@ -12,6 +12,8 @@ namespace GNodeJS {
12
12
 
13
13
  void StartLoop();
14
14
 
15
+ bool IsRunningMicrotasks();
16
+
15
17
  void QuitLoopStack();
16
18
 
17
19
  Local<Array> GetLoopStack();
package/src/value.cc CHANGED
@@ -1650,6 +1650,30 @@ void CopyBoxedForTransferFullIn (GITypeInfo *type_info, GIArgument *arg, long le
1650
1650
  }
1651
1651
  }
1652
1652
 
1653
+ void RefObjectForTransferFullIn (GITypeInfo *type_info, GIArgument *arg) {
1654
+ if (arg->v_pointer == NULL)
1655
+ return;
1656
+
1657
+ if (g_type_info_get_tag(type_info) != GI_TYPE_TAG_INTERFACE)
1658
+ return;
1659
+
1660
+ GIBaseInfo *iface = g_type_info_get_interface(type_info);
1661
+ GIInfoType itype = g_base_info_get_type(iface);
1662
+
1663
+ // The callee owns one reference after the call (transfer-full). node-gtk
1664
+ // keeps only its toggle ref, so without this the callee would effectively
1665
+ // "own" the toggle ref: the refcount never accounts for the new owner, no
1666
+ // toggle-up fires, the wrapper stays weak, and once GC collects it the
1667
+ // destroy callback finalizes the object while the callee still uses it
1668
+ // (e.g. a controller passed to gtk_widget_add_controller). The G_IS_OBJECT
1669
+ // guard skips boxed/fundamental interface types (handled elsewhere).
1670
+ if ((itype == GI_INFO_TYPE_OBJECT || itype == GI_INFO_TYPE_INTERFACE)
1671
+ && G_IS_OBJECT(arg->v_pointer))
1672
+ g_object_ref(arg->v_pointer);
1673
+
1674
+ g_base_info_unref(iface);
1675
+ }
1676
+
1653
1677
 
1654
1678
  /*
1655
1679
  * GValue conversion functions
package/src/value.h CHANGED
@@ -46,6 +46,12 @@ void FreeTransferContainerElements (GITypeInfo *type_info, gpointer capt
46
46
  // See #409.
47
47
  void CopyBoxedForTransferFullIn (GITypeInfo *type_info, GIArgument *arg, long length);
48
48
 
49
+ // For a transfer-full IN GObject argument the callee takes ownership of one
50
+ // reference. node-gtk only holds a toggle ref on the wrapper, so add the
51
+ // reference the callee expects — otherwise the object is finalized out from
52
+ // under the callee once the wrapper is GC'd. See #439.
53
+ void RefObjectForTransferFullIn (GITypeInfo *type_info, GIArgument *arg);
54
+
49
55
  bool CanConvertV8ToGIArgument (GITypeInfo *type_info, Local<Value> value, bool may_be_null);
50
56
 
51
57
  bool V8ToGValue(GValue *gvalue, Local<Value> value, ResourceOwnership ownership = kNone);