@gjsify/node-gi 0.13.0 → 0.20.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/src/calls.cc ADDED
@@ -0,0 +1,1425 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // GI invocation: JS callbacks (ffi closures), GError -> GLib.Error, the shared invoke core + call entry points.
3
+
4
+ #include "common.h"
5
+
6
+ namespace nodegi {
7
+
8
+ // Shared invocation core: marshal the JS args into a GIArgument vector (the
9
+ // instance prepended for methods), call gi_function_info_invoke, marshal the
10
+ // return + any OUT/INOUT values. IN is passed by value; OUT/INOUT route through
11
+ // a per-arg storage slot whose address the invoker hands the C callee. The JS
12
+ // return follows the GJS convention (see InvokeFunctionInfo).
13
+ // ---- callbacks (JS function -> GI callback via an ffi closure) ----
14
+ //
15
+ // A JS function passed where a GI callback is expected (e.g. GLib.timeout_add's
16
+ // GSourceFunc) is wrapped in an ffi closure created by girepository
17
+ // (gi_callable_info_create_closure). When the C library invokes it, the
18
+ // trampoline marshals the C args to JS, calls the function, and marshals the
19
+ // return back. The function's associated user_data slot carries the wrapper
20
+ // pointer and the destroy-notify slot frees it (scope = notified); call-scope
21
+ // closures are freed by the caller after the invoke; async closures free
22
+ // themselves after the first call. Reference: refs/node-gtk src/callback.cc.
23
+ struct NodeGiCallback {
24
+ napi_env env;
25
+ napi_ref jsFn;
26
+ GICallableInfo* info; // the callback type (owned)
27
+ ffi_cif cif;
28
+ ffi_closure* closure; // from gi_callable_info_create_closure
29
+ gpointer native; // executable trampoline address
30
+ GIScopeType scope;
31
+ // scope=async only: counted in the auto-pump's in-flight keep-alive (the
32
+ // pending completion keeps the Node process alive, like in-flight Node I/O).
33
+ // Released exactly once — at the trampoline's single invocation, or at free
34
+ // if the callback never fired (an error path).
35
+ bool pumpCounted = false;
36
+ };
37
+
38
+ static void NodeGiCallbackPumpRelease(NodeGiCallback* cb) {
39
+ if (cb != nullptr && cb->pumpCounted) {
40
+ cb->pumpCounted = false;
41
+ NodeGiPumpAsyncEnd();
42
+ }
43
+ }
44
+
45
+ static void NodeGiCallbackFree(NodeGiCallback* cb) {
46
+ if (cb == nullptr) return;
47
+ NodeGiCallbackPumpRelease(cb); // never fired (error/teardown path)
48
+ if (cb->jsFn != nullptr) napi_delete_reference(cb->env, cb->jsFn);
49
+ if (cb->closure != nullptr) gi_callable_info_destroy_closure(cb->info, cb->closure);
50
+ if (cb->info != nullptr) gi_base_info_unref(cb->info);
51
+ delete cb;
52
+ }
53
+
54
+ // GDestroyNotify for scope=notified callbacks; user_data is the NodeGiCallback*.
55
+ static void NodeGiCallbackDestroyNotify(gpointer user_data) {
56
+ NodeGiCallbackFree(static_cast<NodeGiCallback*>(user_data));
57
+ }
58
+
59
+ // One-shot GSourceFunc that frees a scope=async callback AFTER its single
60
+ // invocation. A GAsyncReadyCallback fires exactly once and has no destroy-notify,
61
+ // so the trampoline schedules this on the main loop once the call returns —
62
+ // freeing it inline would destroy the ffi closure's own executable trampoline
63
+ // while it is still on the call stack (UB). See NodeGiCallbackTrampoline tail.
64
+ static gboolean NodeGiCallbackFreeIdle(gpointer user_data) {
65
+ NodeGiCallbackFree(static_cast<NodeGiCallback*>(user_data));
66
+ return G_SOURCE_REMOVE;
67
+ }
68
+
69
+ // girepository-2.0's closure/destroy index getters are out-param + gboolean;
70
+ // return the index or -1 when the arg has no associated slot.
71
+ static int ArgClosureIndex(GIArgInfo* ai) {
72
+ unsigned int idx = 0;
73
+ return gi_arg_info_get_closure_index(ai, &idx) ? static_cast<int>(idx) : -1;
74
+ }
75
+ static int ArgDestroyIndex(GIArgInfo* ai) {
76
+ unsigned int idx = 0;
77
+ return gi_arg_info_get_destroy_index(ai, &idx) ? static_cast<int>(idx) : -1;
78
+ }
79
+
80
+ // Depth of a synchronous emitSignal() in progress on the (main) thread. While
81
+ // > 0, a signal-handler exception must PROPAGATE to the JS emit() caller (the
82
+ // node-gi contract — see test/signals.test.mjs "a handler exception propagates
83
+ // out of emit"). At depth 0 the closure was dispatched from the GLib main loop (a
84
+ // GTK signal, a notify::, a template handler) where there is NO JS caller to
85
+ // catch it, so it is surfaced + cleared (below) to keep the loop alive instead of
86
+ // wedging libuv on a never-cleared pending exception.
87
+ int g_syncEmitDepth = 0;
88
+
89
+ // Nesting depth of loop-dispatched C->JS callbacks (see common.h). Incremented
90
+ // around every loop-dispatched napi_make_callback (signal closures, GI
91
+ // callbacks, vfuncs) so the cross-runtime microtask checkpoint
92
+ // (NodeGiMaybeDrainMicrotasks) fires only at the OUTERMOST callback boundary.
93
+ int g_loopDispatchDepth = 0;
94
+
95
+ // Surface (log) + clear a pending JS exception left by a C-invoked callback so it
96
+ // cannot wedge the GLib/libuv main loop. A signal handler / idle / timeout that
97
+ // throws is reported to stderr (message + stack) and swallowed — mirroring how
98
+ // GJS reports an uncaught signal-handler exception (gjs_log_exception) instead of
99
+ // crashing the loop. Uses g_printerr, NOT g_warning, so a G_DEBUG=fatal-warnings
100
+ // test environment cannot turn a reported handler bug into an abort. No-op when
101
+ // nothing is pending. Returns true if it cleared an exception.
102
+ bool SurfacePendingException(napi_env env, const char* context) {
103
+ bool pending = false;
104
+ if (napi_is_exception_pending(env, &pending) != napi_ok || !pending) return false;
105
+ napi_value ex = nullptr;
106
+ if (napi_get_and_clear_last_exception(env, &ex) != napi_ok || ex == nullptr) return false;
107
+
108
+ // Prefer Error.stack (message + trace); fall back to String(ex).
109
+ std::string text;
110
+ napi_value field = nullptr;
111
+ napi_valuetype vt = napi_undefined;
112
+ if (napi_get_named_property(env, ex, "stack", &field) == napi_ok &&
113
+ napi_typeof(env, field, &vt) == napi_ok && vt == napi_string) {
114
+ size_t len = 0;
115
+ if (napi_get_value_string_utf8(env, field, nullptr, 0, &len) == napi_ok) {
116
+ text.resize(len);
117
+ napi_get_value_string_utf8(env, field, text.data(), len + 1, &len);
118
+ }
119
+ }
120
+ if (text.empty()) {
121
+ napi_value str = nullptr;
122
+ if (napi_coerce_to_string(env, ex, &str) == napi_ok) {
123
+ size_t len = 0;
124
+ if (napi_get_value_string_utf8(env, str, nullptr, 0, &len) == napi_ok) {
125
+ text.resize(len);
126
+ napi_get_value_string_utf8(env, str, text.data(), len + 1, &len);
127
+ }
128
+ }
129
+ }
130
+ g_printerr("\n(node-gi) Unhandled exception in %s:\n%s\n", context,
131
+ text.empty() ? "<no message>" : text.c_str());
132
+
133
+ // Building `text` above can itself run user JS — a throwing `stack` getter
134
+ // (napi_get_named_property) or a throwing `toString` (napi_coerce_to_string) —
135
+ // which leaves a NEW pending exception. If we returned now the caller would be
136
+ // env-dirty and could wedge the loop exactly like the original throw. Re-check
137
+ // and clear so the env is guaranteed clean on return.
138
+ bool stillPending = false;
139
+ if (napi_is_exception_pending(env, &stillPending) == napi_ok && stillPending) {
140
+ napi_value ignored = nullptr;
141
+ napi_get_and_clear_last_exception(env, &ignored);
142
+ }
143
+ return true;
144
+ }
145
+
146
+ // RAII: pin g_syncEmitDepth to 0 for the duration of a LOOP-DISPATCHED callback,
147
+ // restoring the prior value on scope exit. g_syncEmitDepth marks "a synchronous
148
+ // emitSignal() is on the stack" — but a synchronous handler may re-enter the GLib
149
+ // loop (a nested loop.run() / g_main_context_iteration, or a blocking GIO call that
150
+ // pumps the context). Any closure the NESTED loop dispatches (a notify:: from
151
+ // g_object_set inside a timeout, a GTK signal) is genuinely loop-dispatched and has
152
+ // no JS emit() caller to catch its throw, yet the global counter would still read
153
+ // > 0 and JsClosureMarshal would leave the exception PENDING → DrainMicrotasks
154
+ // early-returns → the libuv↔GLib pump wedges. Resetting to 0 across the loop-
155
+ // dispatch boundary makes those closures evaluate at depth 0 (surface + clear),
156
+ // while the OUTER synchronous emit() — resumed once the nested loop returns and the
157
+ // prior depth is restored — still propagates its own handler's throw.
158
+ struct SyncEmitDepthReset {
159
+ int saved;
160
+ SyncEmitDepthReset() : saved(g_syncEmitDepth) { g_syncEmitDepth = 0; }
161
+ ~SyncEmitDepthReset() { g_syncEmitDepth = saved; }
162
+ };
163
+
164
+ // The ffi closure entry point: marshal C args -> JS, call the JS fn, marshal the
165
+ // JS return -> C. Runs on the main thread (the GLib loop the bridge pumps).
166
+ static void NodeGiCallbackTrampoline(ffi_cif* /*cif*/, void* result, void** args,
167
+ gpointer user_data) {
168
+ NodeGiCallback* cb = static_cast<NodeGiCallback*>(user_data);
169
+ napi_env env = cb->env;
170
+ // scope=async fires exactly once — the in-flight completion has arrived, so
171
+ // drop its auto-pump keep-alive ref (whether or not JS can still be entered).
172
+ NodeGiCallbackPumpRelease(cb);
173
+ // ENV-TEARDOWN GATE: an idle/timeout/async callback (or a destroy-notify-driven
174
+ // one) can fire while the env may no longer enter JS (env cleanup, worker
175
+ // terminate). Re-entering N-API then aborts via node-addon-api's noexcept throw
176
+ // path — degrade to a no-op with a zeroed return slot instead (see toggle.cc).
177
+ if (!NodeGiJsAvailable(env)) {
178
+ if (result != nullptr) static_cast<GIArgument*>(result)->v_uint64 = 0;
179
+ if (NodeGiToggleDebugEnabled())
180
+ NodeGiToggleDebugLog("GI callback skipped: JS unavailable on env %p (teardown/terminate)",
181
+ static_cast<void*>(env));
182
+ // The probe is also false when a JS exception is PENDING on a live env. Keep
183
+ // the pre-existing scope semantics: a loop-dispatched (non-CALL) callback
184
+ // surfaces + clears so the pump stays alive; a CALL-scope callback leaves it
185
+ // pending for the JS invoke caller. At real teardown Surface... is a no-op.
186
+ if (cb->scope != GI_SCOPE_TYPE_CALL) {
187
+ SurfacePendingException(env, "GI callback (idle/timeout/async)");
188
+ }
189
+ // scope=async frees exactly once via the trampoline (see below) — keep that
190
+ // contract even when the JS dispatch is skipped, so the closure is not leaked.
191
+ if (cb->scope == GI_SCOPE_TYPE_ASYNC) {
192
+ g_idle_add_full(G_PRIORITY_DEFAULT, NodeGiCallbackFreeIdle, cb, nullptr);
193
+ }
194
+ return;
195
+ }
196
+ Napi::Env napiEnv(env);
197
+ Napi::HandleScope scope(napiEnv);
198
+ // JS dispatched from a pump-driven context iteration (and every microtask
199
+ // continuation napi_make_callback runs at its boundary) must not inherit the
200
+ // pump's in-iteration flag — a blocking loop.run() it starts needs the
201
+ // UvLoopSource co-pump live. No-op when the flag is already clear.
202
+ NodeGiPumpJsDispatchScope pumpWindow;
203
+ // An idle/timeout/async (NOTIFIED/ASYNC scope) callback is dispatched FROM the
204
+ // GLib loop. Pin the synchronous-emit depth to 0 so a signal this callback emits
205
+ // (e.g. a notify:: from g_object_set) is treated as loop-dispatched even when a
206
+ // synchronous emitSignal() is suspended higher on the stack across a nested loop.
207
+ // A CALL-scope callback runs synchronously inside a JS-initiated GI invoke, so it
208
+ // keeps the ambient depth (its exception must reach that JS caller).
209
+ std::unique_ptr<SyncEmitDepthReset> depthReset;
210
+ if (cb->scope != GI_SCOPE_TYPE_CALL) depthReset = std::make_unique<SyncEmitDepthReset>();
211
+
212
+ GICallableInfo* ci = cb->info;
213
+ unsigned int n = gi_callable_info_get_n_args(ci);
214
+ std::vector<napi_value> jsArgs;
215
+ jsArgs.reserve(n);
216
+ bool ok = true;
217
+ for (unsigned int i = 0; i < n; i++) {
218
+ GIArgInfo* ai = gi_callable_info_get_arg(ci, i);
219
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
220
+ // ffi hands each argument's storage as args[i]; reinterpret as a GIArgument
221
+ // union (a user_data/void arg marshals to undefined, which JS ignores).
222
+ Napi::Value v = GIArgumentToJs(napiEnv, ti, static_cast<GIArgument*>(args[i]), GI_TRANSFER_NOTHING);
223
+ gi_base_info_unref(ti);
224
+ gi_base_info_unref(ai);
225
+ if (napiEnv.IsExceptionPending()) {
226
+ ok = false;
227
+ break;
228
+ }
229
+ jsArgs.push_back(v);
230
+ }
231
+
232
+ // Zero the result slot first (it is >= ffi_arg wide; narrow returns leave the
233
+ // upper bytes indeterminate otherwise).
234
+ if (result != nullptr) static_cast<GIArgument*>(result)->v_uint64 = 0;
235
+
236
+ GITypeInfo* retType = gi_callable_info_get_return_type(ci);
237
+ if (ok) {
238
+ napi_value fn = nullptr;
239
+ if (napi_get_reference_value(env, cb->jsFn, &fn) == napi_ok && fn != nullptr) {
240
+ napi_value global = nullptr;
241
+ napi_get_global(env, &global);
242
+ napi_value ret = nullptr;
243
+ // napi_make_callback drains nextTick/microtasks around the call (Node; on
244
+ // Bun/Deno the checkpoint is run by NodeGiMaybeDrainMicrotasks below).
245
+ g_loopDispatchDepth++;
246
+ napi_status st = napi_make_callback(env, nullptr, global, fn, jsArgs.size(), jsArgs.data(), &ret);
247
+ g_loopDispatchDepth--;
248
+ if (st == napi_ok && result != nullptr) {
249
+ GITypeTag rtag = gi_type_info_get_tag(retType);
250
+ if (rtag == GI_TYPE_TAG_UTF8 || rtag == GI_TYPE_TAG_FILENAME) {
251
+ // Hand the caller an owned copy — a JsToGIArgument string would point
252
+ // into a std::string that dies with this frame.
253
+ Napi::Value rv(env, ret);
254
+ static_cast<GIArgument*>(result)->v_string =
255
+ rv.IsString() ? g_strdup(rv.As<Napi::String>().Utf8Value().c_str()) : nullptr;
256
+ } else if (rtag != GI_TYPE_TAG_VOID) {
257
+ std::string held;
258
+ JsToGIArgument(napiEnv, Napi::Value(env, ret), retType, static_cast<GIArgument*>(result),
259
+ &held);
260
+ }
261
+ }
262
+ }
263
+ }
264
+ gi_base_info_unref(retType);
265
+ // A CALL-scope callback (e.g. a list `foreach`) runs synchronously inside a
266
+ // JS-initiated GI invoke, so a pending exception must propagate to that JS
267
+ // caller — leave it to surface at the invoke's N-API boundary. A NOTIFIED/ASYNC
268
+ // callback (idle/timeout/GAsyncReadyCallback) is dispatched FROM the GLib loop
269
+ // with no JS caller to catch it; a left-pending exception would wedge the
270
+ // libuv↔GLib pump (DrainMicrotasks stops draining while one is pending). Surface
271
+ // + clear it so the loop keeps running (GJS logs an uncaught callback exception
272
+ // rather than stalling the loop).
273
+ if (napiEnv.IsExceptionPending() && cb->scope != GI_SCOPE_TYPE_CALL) {
274
+ SurfacePendingException(env, "GI callback (idle/timeout/async)");
275
+ }
276
+
277
+ // Cross-runtime microtask checkpoint (Bun/Deno — no-op on Node, see loop.cc):
278
+ // a loop-dispatched (NOTIFIED/ASYNC) callback with no dispatch nesting is the
279
+ // outermost C→JS boundary, so promise continuations it queued (a GLib-timeout
280
+ // handler resolving an awaited Promise — the async-DBus-reply chain) drain
281
+ // NOW, matching Node's napi_make_callback checkpoint and GJS. A CALL-scope
282
+ // callback runs under a live JS caller whose stack unwind drains normally.
283
+ if (cb->scope != GI_SCOPE_TYPE_CALL && g_loopDispatchDepth == 0) {
284
+ NodeGiMaybeDrainMicrotasks(env);
285
+ }
286
+
287
+ // scope=async (e.g. a GAsyncReadyCallback) fires EXACTLY once and carries no
288
+ // destroy-notify, so it is the trampoline's job to free it. It is NOT in
289
+ // callScope (so it survived the invoke), and freeing it inline would destroy
290
+ // this ffi closure's own executable trampoline mid-call — so defer the free to
291
+ // the next main-loop iteration, when the closure is no longer on the stack.
292
+ if (cb->scope == GI_SCOPE_TYPE_ASYNC) {
293
+ g_idle_add_full(G_PRIORITY_DEFAULT, NodeGiCallbackFreeIdle, cb, nullptr);
294
+ }
295
+ }
296
+
297
+ // Create an ffi closure wrapping a JS function for a GI callback-typed arg.
298
+ static NodeGiCallback* CreateCallback(Napi::Env env, Napi::Function fn, GICallableInfo* callbackInfo,
299
+ GIScopeType scope) {
300
+ NodeGiCallback* cb = new NodeGiCallback();
301
+ cb->env = env;
302
+ cb->info = reinterpret_cast<GICallableInfo*>(gi_base_info_ref(callbackInfo));
303
+ cb->scope = scope;
304
+ // An async-scope callback is a one-shot in-flight operation (GAsyncReadyCallback
305
+ // et al): count it in the auto-pump keep-alive so the Node process stays alive
306
+ // until the completion dispatches (no-op off-Node / on non-owner envs).
307
+ if (scope == GI_SCOPE_TYPE_ASYNC) cb->pumpCounted = NodeGiPumpAsyncBegin(env);
308
+ napi_create_reference(env, fn, 1, &cb->jsFn);
309
+ cb->closure = gi_callable_info_create_closure(callbackInfo, &cb->cif, NodeGiCallbackTrampoline, cb);
310
+ cb->native = gi_callable_info_get_closure_native_address(callbackInfo, cb->closure);
311
+ if (cb->native == nullptr) cb->native = cb->closure;
312
+ return cb;
313
+ }
314
+
315
+ // Whether `type` is a supported OUT/INOUT marshalling type for this milestone:
316
+ // fundamentals (numbers/bool), strings (utf8/filename), GObject/interface +
317
+ // enums/flags, struct/boxed/union (wrapped as boxed handles on return), and the
318
+ // containers (arrays, GList/GSList/GHashTable). GError (its own roadmap PR) is
319
+ // deferred: a clear error rather than silent mis-handling. *why receives a short
320
+ // type label on refusal. Declared in common.h — shared with the vfunc chain-up
321
+ // path (class.cc CallParentVfunc), which routes each OUT/INOUT vfunc arg the same
322
+ // way this function-invoke path does.
323
+ bool IsSupportedOutType(GITypeInfo* type, std::string* why) {
324
+ switch (gi_type_info_get_tag(type)) {
325
+ case GI_TYPE_TAG_BOOLEAN:
326
+ case GI_TYPE_TAG_INT8:
327
+ case GI_TYPE_TAG_UINT8:
328
+ case GI_TYPE_TAG_INT16:
329
+ case GI_TYPE_TAG_UINT16:
330
+ case GI_TYPE_TAG_INT32:
331
+ case GI_TYPE_TAG_UINT32:
332
+ case GI_TYPE_TAG_INT64:
333
+ case GI_TYPE_TAG_UINT64:
334
+ case GI_TYPE_TAG_FLOAT:
335
+ case GI_TYPE_TAG_DOUBLE:
336
+ case GI_TYPE_TAG_UTF8:
337
+ case GI_TYPE_TAG_FILENAME:
338
+ return true;
339
+ case GI_TYPE_TAG_INTERFACE: {
340
+ // struct/union OUT are read back via WrapBoxed (a boxed handle); enum/flags
341
+ // as numbers; object/interface as GObject wrappers. Field ACCESS on the
342
+ // returned struct is a later PR, but the OUT marshalling itself is here.
343
+ GIBaseInfo* iface = gi_type_info_get_interface(type);
344
+ bool ok = iface != nullptr && (GI_IS_OBJECT_INFO(iface) || GI_IS_INTERFACE_INFO(iface) ||
345
+ GI_IS_ENUM_INFO(iface) || GI_IS_FLAGS_INFO(iface) ||
346
+ GI_IS_STRUCT_INFO(iface) || GI_IS_UNION_INFO(iface));
347
+ if (!ok && why != nullptr) *why = "interface";
348
+ if (iface != nullptr) gi_base_info_unref(iface);
349
+ return ok;
350
+ }
351
+ case GI_TYPE_TAG_ARRAY:
352
+ case GI_TYPE_TAG_GLIST:
353
+ case GI_TYPE_TAG_GSLIST:
354
+ case GI_TYPE_TAG_GHASH:
355
+ // Container OUT/return — arrays (incl. GStrv / byte arrays), GList/GSList,
356
+ // and string-keyed GHashTable. Element-type support is checked here so an
357
+ // exotic combo defers cleanly before the invoke.
358
+ return IsSupportedContainerType(type, why);
359
+ default:
360
+ if (why != nullptr)
361
+ *why = "type tag " + std::to_string(static_cast<int>(gi_type_info_get_tag(type)));
362
+ return false;
363
+ }
364
+ }
365
+
366
+ // ---- GError -> GLib.Error ----
367
+ //
368
+ // A failed GI invoke yields a GError (domain quark + code + message). GJS surfaces
369
+ // it as a real `GLib.Error` (an Error subclass carrying `.domain`/`.code`/
370
+ // `.message` + a `.matches(domain, code)` method). The GLib.Error CLASS itself is
371
+ // owned by L1 (gi.js) so its `matches()` can resolve an error-enum object to its
372
+ // domain; the engine just reports the GError's fields to an L1-registered builder
373
+ // function (stored in this env's instance data — see NodeGiEnvData) and throws
374
+ // what it returns.
375
+ //
376
+ // Read a GError's domain (quark name + numeric quark), code and message, FREE it,
377
+ // and throw the JS error the L1 builder produces (instanceof GLib.Error). Falls
378
+ // back to a plain JS Error when no builder is registered for this env. `context`
379
+ // is the "Ns.method" display name, used only in the fallback message (a real
380
+ // GLib.Error carries the bare GError message, matching GJS).
381
+ void ThrowGError(Napi::Env env, GError* error, const std::string& context) {
382
+ // Read everything out of the GError BEFORE g_error_free.
383
+ GQuark domainQuark = error != nullptr ? error->domain : 0;
384
+ const char* domainName = domainQuark != 0 ? g_quark_to_string(domainQuark) : nullptr;
385
+ std::string domain = domainName != nullptr ? domainName : "";
386
+ int code = error != nullptr ? error->code : 0;
387
+ std::string message =
388
+ (error != nullptr && error->message != nullptr) ? error->message : "invocation failed";
389
+ if (error != nullptr) g_error_free(error);
390
+
391
+ // Prefer this env's L1 GLib.Error builder (instanceof GLib.Error + matches()).
392
+ NodeGiEnvData* d = EnvData(env);
393
+ if (d != nullptr && d->errorBuilder != nullptr) {
394
+ napi_value builder = nullptr;
395
+ if (napi_get_reference_value(env, d->errorBuilder, &builder) == napi_ok &&
396
+ builder != nullptr) {
397
+ napi_value undef = nullptr;
398
+ napi_get_undefined(env, &undef);
399
+ napi_value bargs[4] = {nullptr, nullptr, nullptr, nullptr};
400
+ napi_create_string_utf8(env, domain.c_str(), NAPI_AUTO_LENGTH, &bargs[0]);
401
+ napi_create_uint32(env, static_cast<uint32_t>(domainQuark), &bargs[1]);
402
+ napi_create_int32(env, code, &bargs[2]);
403
+ napi_create_string_utf8(env, message.c_str(), NAPI_AUTO_LENGTH, &bargs[3]);
404
+ napi_value errObj = nullptr;
405
+ napi_status st = napi_call_function(env, undef, builder, 4, bargs, &errObj);
406
+ if (st == napi_ok && errObj != nullptr) {
407
+ napi_throw(env, errObj);
408
+ return;
409
+ }
410
+ // The builder itself threw — let that exception surface rather than masking it.
411
+ bool pending = false;
412
+ if (napi_is_exception_pending(env, &pending) == napi_ok && pending) return;
413
+ }
414
+ }
415
+ Napi::Error::New(env, "Calling " + context + ": " + message).ThrowAsJavaScriptException();
416
+ }
417
+
418
+ static Napi::Value InvokeFunctionInfo(Napi::Env env, GIFunctionInfo* func, gpointer instance,
419
+ Napi::Array args, const std::string& displayName) {
420
+ GICallableInfo* callable = reinterpret_cast<GICallableInfo*>(func);
421
+ unsigned int n_args = gi_callable_info_get_n_args(callable);
422
+ bool isMethod = instance != nullptr;
423
+ size_t offset = isMethod ? 1 : 0;
424
+
425
+ // Per-argument dense in/out positions, mirroring gi_callable_info_invoke's own
426
+ // walk: the instance takes in-position 0; every IN/INOUT arg takes the next
427
+ // in-position; every OUT/INOUT arg takes the next out-position. The in_args and
428
+ // out_args arrays the invoker reads are dense (no holes), so we cannot index by
429
+ // raw arg index once OUT args interleave — these maps bridge that gap. For an
430
+ // all-IN callable inPos[i] == offset + i, so the IN-only path is unchanged.
431
+ std::vector<int> inPos(n_args, -1);
432
+ std::vector<int> outPos(n_args, -1);
433
+ std::vector<GIDirection> dirs(n_args);
434
+ size_t nIn = offset;
435
+ size_t nOut = 0;
436
+ for (unsigned int i = 0; i < n_args; i++) {
437
+ GIArgInfo* ai = gi_callable_info_get_arg(callable, i);
438
+ GIDirection d = gi_arg_info_get_direction(ai);
439
+ gi_base_info_unref(ai);
440
+ dirs[i] = d;
441
+ if (d == GI_DIRECTION_IN || d == GI_DIRECTION_INOUT) inPos[i] = static_cast<int>(nIn++);
442
+ if (d == GI_DIRECTION_OUT || d == GI_DIRECTION_INOUT) outPos[i] = static_cast<int>(nOut++);
443
+ }
444
+
445
+ std::vector<GIArgument> in_args(nIn);
446
+ std::vector<GIArgument> out_args(nOut);
447
+ // Stable per-arg storage the invoker writes OUT/INOUT values into (its address
448
+ // is handed to the C callee). Sized once to n_args so the addresses never move.
449
+ std::vector<GIArgument> slots(n_args);
450
+ std::vector<std::string> held(n_args);
451
+ bool instanceRefTaken = false; // the transfer-full-instance ref below, undone on early bail
452
+ if (isMethod) {
453
+ in_args[0].v_pointer = instance;
454
+ // A transfer-full INSTANCE parameter (rare but load-bearing:
455
+ // g_dbus_method_invocation_return_value/_return_error/… — "this method will
456
+ // take ownership of @invocation") CONSUMES one ref of the instance. Our JS
457
+ // wrapper keeps its own ref for the handle's lifetime (its finalizer unrefs),
458
+ // so hand the callee a ref of its own — exactly what gjs's arg-cache does for
459
+ // the instance argument — otherwise the wrapper's later unref double-frees.
460
+ // Only a GObject instance can be safely ref'd here; the transfer-full-instance
461
+ // methods in the base typelibs are all GObject methods (a boxed instance
462
+ // method reaching this path keeps the previous behaviour).
463
+ if (gi_callable_info_get_instance_ownership_transfer(callable) == GI_TRANSFER_EVERYTHING) {
464
+ // gi_base_info_get_container returns a borrowed ref — no unref.
465
+ GIBaseInfo* container = gi_base_info_get_container(reinterpret_cast<GIBaseInfo*>(func));
466
+ if (container != nullptr && GI_IS_OBJECT_INFO(container) &&
467
+ G_IS_OBJECT(static_cast<GObject*>(instance))) {
468
+ g_object_ref(static_cast<GObject*>(instance));
469
+ instanceRefTaken = true;
470
+ }
471
+ }
472
+ }
473
+
474
+ // Pre-scan: the user_data + destroy-notify slots associated with a callback
475
+ // arg are filled from the callback, not consumed from the JS argument list.
476
+ std::vector<bool> skip(n_args, false);
477
+ for (unsigned int i = 0; i < n_args; i++) {
478
+ GIArgInfo* ai = gi_callable_info_get_arg(callable, i);
479
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
480
+ if (gi_type_info_get_tag(ti) == GI_TYPE_TAG_INTERFACE) {
481
+ GIBaseInfo* iface = gi_type_info_get_interface(ti);
482
+ if (iface != nullptr && GI_IS_CALLBACK_INFO(iface)) {
483
+ int ci = ArgClosureIndex(ai);
484
+ int di = ArgDestroyIndex(ai);
485
+ if (ci >= 0 && static_cast<unsigned int>(ci) < n_args) skip[ci] = true;
486
+ if (di >= 0 && static_cast<unsigned int>(di) < n_args) skip[di] = true;
487
+ }
488
+ if (iface != nullptr) gi_base_info_unref(iface);
489
+ }
490
+ gi_base_info_unref(ti);
491
+ gi_base_info_unref(ai);
492
+ }
493
+
494
+ // Pre-scan: array-length args. An array IN/OUT arg — or the return value — may
495
+ // carry a separate introspectable length arg. Mark it skip (not JS-consumed)
496
+ // and flag it so its OUT slot is wired below / its IN value is autofilled from
497
+ // the array's JS length when the array arg is marshalled. This extends the same
498
+ // skip mechanism the callback closure/destroy slots already use.
499
+ std::vector<bool> isLenArg(n_args, false);
500
+ for (unsigned int i = 0; i < n_args; i++) {
501
+ GIArgInfo* ai = gi_callable_info_get_arg(callable, i);
502
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
503
+ if (gi_type_info_get_tag(ti) == GI_TYPE_TAG_ARRAY) {
504
+ unsigned int L = 0;
505
+ if (gi_type_info_get_array_length_index(ti, &L) && L < n_args) {
506
+ skip[L] = true;
507
+ isLenArg[L] = true;
508
+ }
509
+ }
510
+ gi_base_info_unref(ti);
511
+ gi_base_info_unref(ai);
512
+ }
513
+ {
514
+ GITypeInfo* rt = gi_callable_info_get_return_type(callable);
515
+ if (gi_type_info_get_tag(rt) == GI_TYPE_TAG_ARRAY) {
516
+ unsigned int L = 0;
517
+ if (gi_type_info_get_array_length_index(rt, &L) && L < n_args) {
518
+ skip[L] = true;
519
+ isLenArg[L] = true;
520
+ }
521
+ }
522
+ gi_base_info_unref(rt);
523
+ }
524
+
525
+ // Caller-allocates OUT storage (fixed C array or boxed struct): blob != nullptr
526
+ // marks the arg; boxedGType != 0 selects the boxed copy/free path on read-back.
527
+ std::vector<gpointer> callerAllocBlob(n_args, nullptr);
528
+ std::vector<GType> callerAllocGType(n_args, 0);
529
+
530
+ std::vector<InContainer> inContainers; // IN containers to free after the invoke
531
+ // g_strdup'd transfer-full scalar string IN/INOUT args. The callee adopts + frees
532
+ // them only on a SUCCESSFUL invoke; if a later arg fails to marshal (the early
533
+ // !ok return) or the invoke itself fails, the callee never took them → we g_free
534
+ // them on those branches so they don't leak (#658).
535
+ std::vector<gpointer> ownedInStrings;
536
+ std::vector<NodeGiCallback*> created; // all callbacks made this call
537
+ std::vector<NodeGiCallback*> callScope; // scope=call → freed after the invoke
538
+ // JS functions marshalled as GClosure IN-args this call (see common.h). Each
539
+ // carries one ref taken at marshal time (gjs's ref+sink): transfer-none refs are
540
+ // dropped after the invoke, transfer-full refs belong to the callee (dropped
541
+ // only when the callee never ran).
542
+ CreatedClosures createdClosures;
543
+ // JS typed arrays marshalled as fresh GBytes IN-args this call (see common.h).
544
+ // Same release contract as the closures: transfer-none refs are dropped after
545
+ // the invoke (the callee ref'd the GBytes if it kept it), transfer-full refs
546
+ // belong to the callee (dropped only when the callee never ran).
547
+ CreatedBytes createdBytes;
548
+ bool ok = true;
549
+ size_t jsCursor = 0;
550
+ for (unsigned int i = 0; i < n_args && ok; i++) {
551
+ if (skip[i]) {
552
+ // A length arg that is OUT/INOUT: the callee writes the array length into
553
+ // our stable slot, which we read back to size the array. (IN length args
554
+ // are autofilled when their array arg is marshalled, below.) Callback
555
+ // closure/destroy slots (always IN) are filled by the callback handler.
556
+ if (isLenArg[i] && (dirs[i] == GI_DIRECTION_OUT || dirs[i] == GI_DIRECTION_INOUT))
557
+ out_args[outPos[i]].v_pointer = &slots[i];
558
+ // An INOUT length arg is a single `gint*` the callee reads (the in-count) AND
559
+ // writes (the out-count): the in_arg is a POINTER to the same stable slot. The
560
+ // in-count is written into slots[i] when the INOUT array is marshalled below.
561
+ if (isLenArg[i] && dirs[i] == GI_DIRECTION_INOUT)
562
+ in_args[inPos[i]].v_pointer = &slots[i];
563
+ continue;
564
+ }
565
+ GIArgInfo* ai = gi_callable_info_get_arg(callable, i);
566
+ GIDirection dir = dirs[i];
567
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
568
+
569
+ GIBaseInfo* iface = gi_type_info_get_tag(ti) == GI_TYPE_TAG_INTERFACE
570
+ ? gi_type_info_get_interface(ti)
571
+ : nullptr;
572
+ bool isCallback = iface != nullptr && GI_IS_CALLBACK_INFO(iface);
573
+
574
+ // OUT / INOUT (a callback is always IN-direction and handled below).
575
+ if ((dir == GI_DIRECTION_OUT || dir == GI_DIRECTION_INOUT) && !isCallback) {
576
+ std::string why;
577
+ if (gi_arg_info_is_caller_allocates(ai)) {
578
+ // Caller-allocates: the callee fills storage WE provide (a single
579
+ // pointer, not a **). gi_callable_info_invoke passes out_args[k].v_pointer
580
+ // straight through as the arg, so the blob pointer goes there directly.
581
+ // Supported: a fixed-size C array (size = fixed_size × element_size), a
582
+ // boxed struct/union (size = gi_struct/union_info_get_size, incl. the
583
+ // GValue auto-unbox), and a PLAIN non-boxed struct/union (same size
584
+ // source; the filled blob is handed to JS as a g_free-owning
585
+ // field-readable handle — PangoLayout.get_pixel_extents). Reference:
586
+ // refs/gjs/gi/arg-cache.cpp build_arg CALLER_ALLOCATES branch.
587
+ GITypeTag tg = gi_type_info_get_tag(ti);
588
+ size_t size = 0;
589
+ GType boxedGType = 0;
590
+ if (tg == GI_TYPE_TAG_ARRAY && gi_type_info_get_array_type(ti) == GI_ARRAY_TYPE_C) {
591
+ size_t fixed = 0;
592
+ if (gi_type_info_get_array_fixed_size(ti, &fixed) && fixed > 0) {
593
+ GITypeInfo* el = gi_type_info_get_param_type(ti, 0);
594
+ // Only fundamental (non-interface) element types: CElementSize is the
595
+ // true storage size for them. A struct-by-value element array would
596
+ // need gi_struct_info_get_size per element + field-access read-back
597
+ // (a later PR), so leave size=0 to defer it cleanly.
598
+ if (el != nullptr && gi_type_info_get_tag(el) != GI_TYPE_TAG_INTERFACE)
599
+ size = CElementSize(el) * fixed;
600
+ if (el != nullptr) gi_base_info_unref(el);
601
+ }
602
+ } else if (tg == GI_TYPE_TAG_INTERFACE) {
603
+ GIBaseInfo* si = gi_type_info_get_interface(ti);
604
+ if (si != nullptr && GI_IS_STRUCT_INFO(si)) {
605
+ size = gi_struct_info_get_size(reinterpret_cast<GIStructInfo*>(si));
606
+ } else if (si != nullptr && GI_IS_UNION_INFO(si)) {
607
+ size = gi_union_info_get_size(reinterpret_cast<GIUnionInfo*>(si));
608
+ }
609
+ if (si != nullptr) {
610
+ GType gt = gi_registered_type_info_get_g_type(
611
+ reinterpret_cast<GIRegisteredTypeInfo*>(si));
612
+ if (gt != G_TYPE_INVALID && G_TYPE_IS_BOXED(gt)) boxedGType = gt;
613
+ // A FOREIGN struct's layout is module-owned (cairo) — GI cannot fill
614
+ // it here; defer cleanly (its introspected size is meaningless).
615
+ if (GI_IS_STRUCT_INFO(si) &&
616
+ gi_struct_info_is_foreign(reinterpret_cast<GIStructInfo*>(si)))
617
+ size = 0;
618
+ gi_base_info_unref(si);
619
+ }
620
+ // A NON-boxed plain struct (boxedGType stays 0, e.g. PangoRectangle in
621
+ // PangoLayout.get_pixel_extents) keeps its size: the blob is handed to
622
+ // JS as a g_free-owning handle with field access on read-back below —
623
+ // gjs's CallerAllocatesOut (g_malloc0 → fill → wrap → g_free). An
624
+ // opaque struct introspects size 0 and defers via the throw below.
625
+ }
626
+ if (size == 0) {
627
+ Napi::TypeError::New(
628
+ env, displayName + ": caller-allocates OUT parameter type is not yet supported")
629
+ .ThrowAsJavaScriptException();
630
+ ok = false;
631
+ } else {
632
+ gpointer blob = g_malloc0(size);
633
+ callerAllocBlob[i] = blob;
634
+ callerAllocGType[i] = boxedGType;
635
+ out_args[outPos[i]].v_pointer = blob;
636
+ }
637
+ } else if (!IsSupportedOutType(ti, &why)) {
638
+ Napi::TypeError::New(env, displayName + ": OUT " + why +
639
+ " parameters are not yet supported")
640
+ .ThrowAsJavaScriptException();
641
+ ok = false;
642
+ } else if (dir == GI_DIRECTION_INOUT) {
643
+ GITypeTag tg = gi_type_info_get_tag(ti);
644
+ if (tg == GI_TYPE_TAG_ARRAY || tg == GI_TYPE_TAG_GLIST || tg == GI_TYPE_TAG_GSLIST ||
645
+ tg == GI_TYPE_TAG_GHASH) {
646
+ // INOUT container (read-modify-write a caller-built container). The ABI is
647
+ // a single `container**` the callee reads (the in-container) and reassigns
648
+ // (the out-container). We stash the in-container pointer in the stable slot
649
+ // and point BOTH in_args + out_args at &slots[i]; the callee overwrites
650
+ // slots[i].v_pointer with the out-container, which the read-back loop below
651
+ // marshals via ReadOutOrReturn (OUT transfer). OWNERSHIP: JsToInContainer
652
+ // records the ORIGINAL in-container in `inContainers`, so FreeInContainer
653
+ // (after the reads) releases it per the IN transfer — NONE → we free the
654
+ // in-container we built; EVERYTHING/CONTAINER → the callee adopted it, we
655
+ // don't. The out-container is freed (or borrowed) by ReadOutOrReturn per the
656
+ // SAME (OUT) transfer. Verified vs gjs 1.88 (GIMarshallingTests.array_inout,
657
+ // garray/glist/gslist/gptrarray/ghashtable_utf8_none_inout, bytearray_full_inout).
658
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
659
+ jsCursor++;
660
+ if (!IsSupportedContainerType(ti, &why)) {
661
+ Napi::TypeError::New(env, displayName + ": INOUT " + why +
662
+ " parameters are not yet supported")
663
+ .ThrowAsJavaScriptException();
664
+ ok = false;
665
+ } else {
666
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
667
+ gpointer cptr = nullptr;
668
+ long ccount = 0;
669
+ // A nullable INOUT container passed null/undefined → a NULL in-container
670
+ // (count 0), matching gjs (e.g. length_array_utf8_optional_inout(null)
671
+ // → []). A non-nullable arg falls through to JsToInContainer, which
672
+ // throws for a non-array, exactly as before.
673
+ if ((v.IsNull() || v.IsUndefined()) && gi_arg_info_may_be_null(ai)) {
674
+ ok = true;
675
+ } else {
676
+ ok = JsToInContainer(env, v, ti, tr, &cptr, &ccount, &inContainers);
677
+ }
678
+ if (ok) {
679
+ slots[i].v_pointer = cptr;
680
+ in_args[inPos[i]].v_pointer = &slots[i];
681
+ out_args[outPos[i]].v_pointer = &slots[i];
682
+ // The length arg carries the in-count. An INOUT length gets it in ITS
683
+ // slot (the callee reads+writes that slot); a plain IN length gets the
684
+ // value directly in its in_arg.
685
+ if (tg == GI_TYPE_TAG_ARRAY) {
686
+ unsigned int L = 0;
687
+ if (gi_type_info_get_array_length_index(ti, &L) && L < n_args) {
688
+ GIArgInfo* la = gi_callable_info_get_arg(callable, L);
689
+ GITypeInfo* lt = gi_arg_info_get_type_info(la);
690
+ if (dirs[L] == GI_DIRECTION_INOUT) {
691
+ WriteLengthValue(lt, &slots[L], ccount);
692
+ } else if (dirs[L] == GI_DIRECTION_IN && inPos[L] >= 0) {
693
+ WriteLengthValue(lt, &in_args[inPos[L]], ccount);
694
+ }
695
+ gi_base_info_unref(lt);
696
+ gi_base_info_unref(la);
697
+ }
698
+ }
699
+ }
700
+ }
701
+ } else {
702
+ // INOUT scalar: marshal the JS input into the slot (like IN); the
703
+ // invoker hands the slot's address to the callee, which reads + writes.
704
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
705
+ jsCursor++;
706
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
707
+ if (JsToGIArgument(env, v, ti, &slots[i], &held[i], tr, &ownedInStrings)) {
708
+ in_args[inPos[i]].v_pointer = &slots[i];
709
+ out_args[outPos[i]].v_pointer = &slots[i];
710
+ } else {
711
+ ok = false; // JsToGIArgument already threw
712
+ }
713
+ }
714
+ } else {
715
+ // Pure OUT: the callee writes into the slot; no JS arg is consumed.
716
+ out_args[outPos[i]].v_pointer = &slots[i];
717
+ }
718
+ if (iface != nullptr) gi_base_info_unref(iface);
719
+ gi_base_info_unref(ti);
720
+ gi_base_info_unref(ai);
721
+ continue;
722
+ }
723
+
724
+ // IN (including callbacks): consume the next JS argument.
725
+ Napi::Value v = jsCursor < args.Length() ? args.Get(jsCursor) : env.Undefined();
726
+ jsCursor++;
727
+
728
+ if (isCallback) {
729
+ int ci = ArgClosureIndex(ai);
730
+ int di = ArgDestroyIndex(ai);
731
+ if (v.IsFunction()) {
732
+ GIScopeType scopeType = gi_arg_info_get_scope(ai);
733
+ NodeGiCallback* cb =
734
+ CreateCallback(env, v.As<Napi::Function>(), reinterpret_cast<GICallableInfo*>(iface),
735
+ scopeType);
736
+ created.push_back(cb);
737
+ if (scopeType == GI_SCOPE_TYPE_CALL) callScope.push_back(cb);
738
+ in_args[inPos[i]].v_pointer = cb->native;
739
+ if (ci >= 0 && static_cast<unsigned int>(ci) < n_args && inPos[ci] >= 0)
740
+ in_args[inPos[ci]].v_pointer = cb;
741
+ if (di >= 0 && static_cast<unsigned int>(di) < n_args && inPos[di] >= 0)
742
+ in_args[inPos[di]].v_pointer = reinterpret_cast<gpointer>(NodeGiCallbackDestroyNotify);
743
+ } else if (v.IsNull() || v.IsUndefined()) {
744
+ // Null is only valid for a nullable callback (e.g. an optional progress
745
+ // callback). For a NON-nullable callback (g_timeout_add / g_idle_add)
746
+ // passing null would make us hand GLib a NULL GSourceFunc → a GLib-CRITICAL
747
+ // (`assertion 'function != NULL'`) + a dead source. Reject it with a clean
748
+ // JS TypeError up front, matching GJS.
749
+ if (!gi_arg_info_may_be_null(ai)) {
750
+ gi_base_info_unref(iface);
751
+ gi_base_info_unref(ti);
752
+ gi_base_info_unref(ai);
753
+ Napi::TypeError::New(
754
+ env, displayName + ": the callback argument is not nullable (expected a function)")
755
+ .ThrowAsJavaScriptException();
756
+ ok = false;
757
+ break;
758
+ }
759
+ in_args[inPos[i]].v_pointer = nullptr;
760
+ } else {
761
+ gi_base_info_unref(iface);
762
+ gi_base_info_unref(ti);
763
+ gi_base_info_unref(ai);
764
+ Napi::TypeError::New(env, displayName + ": expected a function for the callback argument")
765
+ .ThrowAsJavaScriptException();
766
+ ok = false;
767
+ break;
768
+ }
769
+ } else {
770
+ GITypeTag tg = gi_type_info_get_tag(ti);
771
+ if (tg == GI_TYPE_TAG_ARRAY || tg == GI_TYPE_TAG_GLIST || tg == GI_TYPE_TAG_GSLIST ||
772
+ tg == GI_TYPE_TAG_GHASH) {
773
+ std::string why;
774
+ if (!IsSupportedContainerType(ti, &why)) {
775
+ Napi::TypeError::New(env, displayName + ": IN " + why +
776
+ " parameters are not yet supported")
777
+ .ThrowAsJavaScriptException();
778
+ ok = false;
779
+ } else {
780
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
781
+ gpointer cptr = nullptr;
782
+ long ccount = 0;
783
+ ok = JsToInContainer(env, v, ti, tr, &cptr, &ccount, &inContainers);
784
+ if (ok) {
785
+ in_args[inPos[i]].v_pointer = cptr;
786
+ // Autofill an IN length arg from the array's element count.
787
+ if (tg == GI_TYPE_TAG_ARRAY) {
788
+ unsigned int L = 0;
789
+ if (gi_type_info_get_array_length_index(ti, &L) && L < n_args &&
790
+ dirs[L] == GI_DIRECTION_IN && inPos[L] >= 0) {
791
+ GIArgInfo* la = gi_callable_info_get_arg(callable, L);
792
+ GITypeInfo* lt = gi_arg_info_get_type_info(la);
793
+ WriteLengthValue(lt, &in_args[inPos[L]], ccount);
794
+ gi_base_info_unref(lt);
795
+ gi_base_info_unref(la);
796
+ }
797
+ }
798
+ }
799
+ }
800
+ } else {
801
+ GITransfer tr = gi_arg_info_get_ownership_transfer(ai);
802
+ ok = JsToGIArgument(env, v, ti, &in_args[inPos[i]], &held[i], tr, &ownedInStrings,
803
+ &createdClosures, &createdBytes);
804
+ }
805
+ }
806
+
807
+ if (iface != nullptr) gi_base_info_unref(iface);
808
+ gi_base_info_unref(ti);
809
+ gi_base_info_unref(ai);
810
+ }
811
+ if (!ok) {
812
+ for (NodeGiCallback* cb : created) NodeGiCallbackFree(cb);
813
+ for (const InContainer& c : inContainers) FreeInContainer(c);
814
+ // The callee never ran, so NO closure ref was adopted — release every created
815
+ // closure (refcount 1 → 0 → finalize drops the JS-function ref).
816
+ for (GClosure* c : createdClosures.transferNone) g_closure_unref(c);
817
+ for (GClosure* c : createdClosures.transferFull) g_closure_unref(c);
818
+ // Likewise every fresh GBytes built from a JS typed array — never adopted.
819
+ for (GBytes* b : createdBytes.transferNone) g_bytes_unref(b);
820
+ for (GBytes* b : createdBytes.transferFull) g_bytes_unref(b);
821
+ // Likewise the transfer-full-instance ref: the callee never consumed it.
822
+ if (instanceRefTaken) g_object_unref(static_cast<GObject*>(instance));
823
+ for (gpointer s : ownedInStrings) g_free(s); // never reached the callee (#658)
824
+ // Caller-allocated blobs never reached the callee (or it was never called) —
825
+ // they are zeroed g_malloc0 storage, so a plain g_free is correct here.
826
+ for (gpointer b : callerAllocBlob)
827
+ if (b != nullptr) g_free(b);
828
+ return env.Null();
829
+ }
830
+
831
+ GIArgument retval;
832
+ GError* error = nullptr;
833
+ gboolean success = gi_function_info_invoke(func, in_args.data(), in_args.size(),
834
+ out_args.data(), out_args.size(), &retval, &error);
835
+ // A FAILED invoke never started its async op, so a scope=async callback it was
836
+ // handed will not fire — drop its auto-pump keep-alive ref here or the process
837
+ // could never exit. (The callback allocation itself is left alone: freeing it
838
+ // would be unsafe if a callee retained it despite the error — the pre-existing
839
+ // small leak on this rare path is unchanged.) Runs BEFORE the callScope free
840
+ // below so no freed pointer is revisited; releasing a CALL-scope cb is a no-op.
841
+ if (!success) {
842
+ for (NodeGiCallback* cb : created) NodeGiCallbackPumpRelease(cb);
843
+ }
844
+ // Call-scope closures are only valid for the duration of the invoke; free them
845
+ // now (notified/async closures are owned by the callee / self-freeing).
846
+ for (NodeGiCallback* cb : callScope) NodeGiCallbackFree(cb);
847
+ // Transfer-none GClosure IN-args: drop the ref taken at marshal time (gjs
848
+ // BoxedInTransferNone::release, which runs on success AND error paths). A
849
+ // callee that stored the closure (signal connect, source_set_closure, a DBus
850
+ // registration) holds its own ref+sink, so the closure lives on; a callee that
851
+ // only invoked it synchronously lets this unref finalize it (dropping the JS
852
+ // ref). Transfer-full closures were adopted by the callee — no release (gjs
853
+ // GClosureIn::release skips).
854
+ for (GClosure* c : createdClosures.transferNone) g_closure_unref(c);
855
+ // Transfer-none GBytes IN-args built from JS typed arrays: drop the fresh ref
856
+ // taken at marshal time (gjs GBytesInTransferNone::release, success AND error
857
+ // paths). A callee that kept the bytes (GdkPixbuf.Pixbuf.new_from_bytes) holds
858
+ // its own ref. Transfer-full GBytes were adopted by the callee — no release.
859
+ for (GBytes* b : createdBytes.transferNone) g_bytes_unref(b);
860
+ if (!success) {
861
+ for (const InContainer& c : inContainers) FreeInContainer(c);
862
+ // A failed invoke did not adopt the transfer-full IN/INOUT strings we g_strdup'd
863
+ // for it → free them here so the error path doesn't leak (#658).
864
+ for (gpointer s : ownedInStrings) g_free(s);
865
+ // Free the caller-allocated OUT storage (the callee may have partially filled
866
+ // it; g_free releases the block — a boxed free-func on a half-initialised blob
867
+ // is riskier than the small leak of any internal pointer it set).
868
+ for (gpointer b : callerAllocBlob)
869
+ if (b != nullptr) g_free(b);
870
+ // ThrowGError reads the GError's domain/code/message, frees it, and throws a
871
+ // real GLib.Error (instanceof, with .matches()) via the L1 builder.
872
+ ThrowGError(env, error, displayName);
873
+ return env.Null();
874
+ }
875
+
876
+ // Assemble the JS return per the GJS convention: the function's own return
877
+ // value (if non-void) followed by each OUT/INOUT value in argument order.
878
+ // Exactly one element → return it bare; many → a JS Array; none → undefined.
879
+ std::vector<Napi::Value> results;
880
+ GITypeInfo* return_type = gi_callable_info_get_return_type(callable);
881
+ GITransfer return_transfer = gi_callable_info_get_caller_owns(callable);
882
+ // A non-void return ALWAYS leads the tuple — the `(skip)` annotation is IGNORED
883
+ // for the JS return, exactly as GJS does. GJS's arg-cache derives `m_has_return`
884
+ // purely from the return type (non-void, or a pointer) and unconditionally counts
885
+ // it (refs/gjs/gi/arg-cache.cpp: initialize() `m_has_return = tag != VOID || …`,
886
+ // build_return() `*inc_counter_out = true`); nothing in gjs's marshalling consults
887
+ // `gi_callable_info_skip_return`. Verified against gjs 1.88:
888
+ // GLib.uri_split('http://user@host:80/p?q=1#frag', 0)
889
+ // → [true, 'http', 'user', 'host', 80, '/p', 'q=1', 'frag'] (8, leading bool)
890
+ // even though g_uri_split's gboolean return carries `skip="1"` (and throws). node-gi
891
+ // previously honoured the annotation and dropped it (7) — a divergence from the gold
892
+ // standard. (node-gtk's ShouldSkipReturn does honour it; we match GJS, not node-gtk.)
893
+ if (gi_type_info_get_tag(return_type) != GI_TYPE_TAG_VOID) {
894
+ results.push_back(ReadOutOrReturn(env, callable, return_type, &retval, return_transfer, &slots));
895
+ }
896
+ gi_base_info_unref(return_type);
897
+
898
+ for (unsigned int i = 0; i < n_args && !env.IsExceptionPending(); i++) {
899
+ if (dirs[i] != GI_DIRECTION_OUT && dirs[i] != GI_DIRECTION_INOUT) continue;
900
+ if (skip[i]) continue; // an array length arg — surfaced via its array, not on its own
901
+ GIArgInfo* ai = gi_callable_info_get_arg(callable, i);
902
+ GITypeInfo* ti = gi_arg_info_get_type_info(ai);
903
+ GITransfer transfer = gi_arg_info_get_ownership_transfer(ai);
904
+ if (callerAllocBlob[i] != nullptr) {
905
+ // Caller-allocated OUT: the callee filled the blob IN PLACE (the blob is the
906
+ // data, not a pointer to it). Wrap it, then release the blob — mirroring
907
+ // gjs's CallerAllocatesOut (read the value out, then free the storage).
908
+ gpointer blob = callerAllocBlob[i];
909
+ if (gi_type_info_get_tag(ti) == GI_TYPE_TAG_ARRAY) {
910
+ // A fixed-size C array: read length from the fixed size (len=-1), transfer
911
+ // NOTHING (we own + free the blob ourselves right below).
912
+ GIArgument a;
913
+ memset(&a, 0, sizeof(a));
914
+ a.v_pointer = blob;
915
+ results.push_back(ReadOutOrReturn(env, callable, ti, &a, GI_TRANSFER_NOTHING, &slots));
916
+ g_free(blob);
917
+ } else if (g_type_is_a(callerAllocGType[i], G_TYPE_VALUE)) {
918
+ // A caller-allocated GValue OUT (e.g. GIMarshallingTests.gvalue_out_caller_allocates):
919
+ // GJS UNBOXES the filled GValue to its contained JS value (verified: → 42, a
920
+ // number, not a GObject.Value box), then frees the caller-allocated storage.
921
+ // The blob IS the GValue (g_value_init'd + set IN PLACE by the callee). Unbox
922
+ // via GValueToJs (same as the return/OUT-pointer path in marshal.cc), then
923
+ // g_value_unset (frees any contained data, e.g. a dup'd string) + g_free the
924
+ // blob WE g_malloc0'd — the direct GValue release GJS uses.
925
+ results.push_back(GValueToJs(env, static_cast<GValue*>(blob)));
926
+ g_value_unset(static_cast<GValue*>(blob));
927
+ g_free(blob);
928
+ } else if (callerAllocGType[i] != 0) {
929
+ // A boxed struct/union: hand the JS side an independent boxed COPY it owns
930
+ // (finalizer g_boxed_free's it), then free the caller-allocated blob (also
931
+ // via g_boxed_free — matches gjs BoxedCallerAllocatesOut::release).
932
+ GType gt = callerAllocGType[i];
933
+ gpointer owned = g_boxed_copy(gt, blob);
934
+ results.push_back(MakeBoxedHandle(env, owned, gt, true));
935
+ g_boxed_free(gt, blob);
936
+ } else {
937
+ // A PLAIN (non-boxed) struct/union filled in place (e.g. the
938
+ // PangoRectangles of PangoLayout.get_pixel_extents): no copy function
939
+ // exists, so hand the g_malloc0'd blob ITSELF to JS — the handle owns the
940
+ // block (g_free on finalize; gjs equivalently memdups into its Boxed and
941
+ // g_free's the blob, CallerAllocatesOut::release) and carries the static
942
+ // struct info so fields read back (rect.x / rect.width via getBoxedField).
943
+ // A registered-but-non-boxed GType (if any) is kept for method resolution.
944
+ GIBaseInfo* si = gi_type_info_get_interface(ti);
945
+ GType gt = si != nullptr ? gi_registered_type_info_get_g_type(
946
+ reinterpret_cast<GIRegisteredTypeInfo*>(si))
947
+ : G_TYPE_INVALID;
948
+ GType handleGType = (gt != G_TYPE_INVALID && gt != G_TYPE_NONE) ? gt : G_TYPE_INVALID;
949
+ results.push_back(
950
+ MakeBoxedHandle(env, blob, handleGType, false, si, /* rawOwned = */ true));
951
+ if (si != nullptr) gi_base_info_unref(si);
952
+ }
953
+ callerAllocBlob[i] = nullptr;
954
+ gi_base_info_unref(ti);
955
+ gi_base_info_unref(ai);
956
+ continue;
957
+ }
958
+ results.push_back(ReadOutOrReturn(env, callable, ti, &slots[i], transfer, &slots));
959
+ gi_base_info_unref(ti);
960
+ gi_base_info_unref(ai);
961
+ }
962
+
963
+ // Free the transfer-nothing IN containers now that the results are marshalled
964
+ // into JS — a transfer-none return/OUT (e.g. g_environ_getenv) can point INTO
965
+ // a transfer-none IN container, so this MUST run after the reads above, never
966
+ // before. Transfer-everything/container IN were adopted by the callee, so
967
+ // FreeInContainer leaves those alone.
968
+ for (const InContainer& c : inContainers) FreeInContainer(c);
969
+
970
+ if (env.IsExceptionPending()) return env.Null();
971
+ if (results.empty()) return env.Undefined();
972
+ if (results.size() == 1) return results[0];
973
+ Napi::Array arr = Napi::Array::New(env, results.size());
974
+ for (size_t k = 0; k < results.size(); k++) arr.Set(static_cast<uint32_t>(k), results[k]);
975
+ return arr;
976
+ }
977
+
978
+ // callFunction(namespace, functionName, args?: unknown[]) -> unknown
979
+ // Invokes a namespace-level GI function (not an instance method) with IN-only
980
+ // primitive/string/object args. Instance methods go through callMethod.
981
+ Napi::Value CallFunction(const Napi::CallbackInfo& info) {
982
+ Napi::Env env = info.Env();
983
+ if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
984
+ Napi::TypeError::New(env, "callFunction(namespace: string, functionName: string, args?: unknown[])")
985
+ .ThrowAsJavaScriptException();
986
+ return env.Null();
987
+ }
988
+ std::string ns = info[0].As<Napi::String>().Utf8Value();
989
+ std::string fn = info[1].As<Napi::String>().Utf8Value();
990
+ Napi::Array args = (info.Length() >= 3 && info[2].IsArray()) ? info[2].As<Napi::Array>()
991
+ : Napi::Array::New(env, 0);
992
+
993
+ GIRepository* repo = DupDefaultRepository();
994
+ GIBaseInfo* base = gi_repository_find_by_name(repo, ns.c_str(), fn.c_str());
995
+ if (base == nullptr) {
996
+ g_object_unref(repo);
997
+ Napi::Error::New(env, "No such symbol: " + ns + "." + fn).ThrowAsJavaScriptException();
998
+ return env.Null();
999
+ }
1000
+ if (!GI_IS_FUNCTION_INFO(base)) {
1001
+ gi_base_info_unref(base);
1002
+ g_object_unref(repo);
1003
+ Napi::TypeError::New(env, ns + "." + fn + " is not a function").ThrowAsJavaScriptException();
1004
+ return env.Null();
1005
+ }
1006
+ GIFunctionInfo* func = reinterpret_cast<GIFunctionInfo*>(base);
1007
+ if (gi_callable_info_is_method(reinterpret_cast<GICallableInfo*>(base))) {
1008
+ gi_base_info_unref(base);
1009
+ g_object_unref(repo);
1010
+ Napi::TypeError::New(
1011
+ env, ns + "." + fn + " is an instance method — use callMethod(handle, name, args)")
1012
+ .ThrowAsJavaScriptException();
1013
+ return env.Null();
1014
+ }
1015
+
1016
+ Napi::Value result = InvokeFunctionInfo(env, func, nullptr, args, ns + "." + fn);
1017
+ gi_base_info_unref(base);
1018
+ g_object_unref(repo);
1019
+ return result;
1020
+ }
1021
+
1022
+ // The JS-side camelCase → snake_case alias (gi.js camelToSnake), mirrored here so
1023
+ // method resolution can try the LITERAL introspected name first and only fall
1024
+ // back to the alias. GJS exposes GI method names VERBATIM — a Vala-generated GIR
1025
+ // carries camelCase names (Gwebgl's `getString`), which an unconditional
1026
+ // camelCase→snake conversion destroys (the webgl-glarea spike wall).
1027
+ static std::string CamelToSnake(const std::string& name) {
1028
+ std::string out;
1029
+ out.reserve(name.size() + 4);
1030
+ for (char c : name) {
1031
+ if (c >= 'A' && c <= 'Z') {
1032
+ out += '_';
1033
+ out += static_cast<char>(c - 'A' + 'a');
1034
+ } else {
1035
+ out += c;
1036
+ }
1037
+ }
1038
+ return out;
1039
+ }
1040
+
1041
+ // The instance's concrete GType may lack introspection info (e.g. a private
1042
+ // GLocalFile); walk up to the nearest ancestor GType that has an object info.
1043
+ // Returns a new ref or nullptr. Shared by CallMethod (invoke) and HasMethod
1044
+ // (feature detection) so both resolve identically.
1045
+ static GIObjectInfo* FindNearestObjectInfo(GIRepository* repo, GType gtype) {
1046
+ for (GType t = gtype; t != 0; t = g_type_parent(t)) {
1047
+ GIBaseInfo* bi = gi_repository_find_by_gtype(repo, t);
1048
+ if (bi != nullptr) {
1049
+ if (GI_IS_OBJECT_INFO(bi)) return reinterpret_cast<GIObjectInfo*>(bi);
1050
+ gi_base_info_unref(bi);
1051
+ }
1052
+ }
1053
+ return nullptr;
1054
+ }
1055
+
1056
+ // Resolve one candidate name: own + implemented-interface methods at each
1057
+ // level, then the parent chain; then the live GType's interface list (the
1058
+ // concrete GType may implement introspectable interfaces its nearest
1059
+ // introspectable ANCESTOR's info does not list — e.g. a private GLocalFile
1060
+ // (no info; ancestor = GObject) implementing GFile, so g_file_get_path
1061
+ // resolves). Returns a new ref or nullptr.
1062
+ static GIFunctionInfo* ResolveMethodByName(GIRepository* repo, GType gtype, GIObjectInfo* objInfo,
1063
+ const char* name) {
1064
+ GIObjectInfo* walk =
1065
+ reinterpret_cast<GIObjectInfo*>(gi_base_info_ref(reinterpret_cast<GIBaseInfo*>(objInfo)));
1066
+ GIFunctionInfo* found = nullptr;
1067
+ while (walk != nullptr) {
1068
+ GIBaseInfo* declarer = nullptr;
1069
+ found = gi_object_info_find_method_using_interfaces(walk, name, &declarer);
1070
+ if (declarer != nullptr) gi_base_info_unref(declarer);
1071
+ if (found != nullptr) break;
1072
+ GIObjectInfo* parent = gi_object_info_get_parent(walk);
1073
+ gi_base_info_unref(walk);
1074
+ walk = parent;
1075
+ }
1076
+ if (walk != nullptr) gi_base_info_unref(walk);
1077
+ if (found == nullptr) {
1078
+ unsigned int n_ifaces = 0;
1079
+ GType* ifaces = g_type_interfaces(gtype, &n_ifaces);
1080
+ for (unsigned int i = 0; i < n_ifaces && found == nullptr; i++) {
1081
+ GIBaseInfo* ii = gi_repository_find_by_gtype(repo, ifaces[i]);
1082
+ if (ii != nullptr) {
1083
+ if (GI_IS_INTERFACE_INFO(ii)) {
1084
+ found = gi_interface_info_find_method(reinterpret_cast<GIInterfaceInfo*>(ii), name);
1085
+ }
1086
+ gi_base_info_unref(ii);
1087
+ }
1088
+ }
1089
+ g_free(ifaces);
1090
+ }
1091
+ return found;
1092
+ }
1093
+
1094
+ // Resolve `method` — LITERAL introspected name first (GJS fidelity: GIR names
1095
+ // are exposed verbatim, incl. Vala camelCase like Gwebgl's `getString`),
1096
+ // snake_case alias second. Returns a new ref or nullptr.
1097
+ static GIFunctionInfo* ResolveInstanceMethod(GIRepository* repo, GType gtype, GIObjectInfo* objInfo,
1098
+ const std::string& method) {
1099
+ GIFunctionInfo* func = ResolveMethodByName(repo, gtype, objInfo, method.c_str());
1100
+ if (func == nullptr) {
1101
+ const std::string snake = CamelToSnake(method);
1102
+ if (snake != method) func = ResolveMethodByName(repo, gtype, objInfo, snake.c_str());
1103
+ }
1104
+ return func;
1105
+ }
1106
+
1107
+ // callMethod(handle, methodName, args?: unknown[]) -> unknown
1108
+ // Resolve an instance method by walking the instance's GIObjectInfo (own +
1109
+ // implemented-interface methods at each level, then up the parent chain), then
1110
+ // invoke it with the instance prepended. The Node twin of `obj.method(...)`.
1111
+ // The LITERAL name is resolved first (GJS fidelity — GIR names are exposed
1112
+ // verbatim, incl. Vala camelCase); the snake_case alias is the fallback.
1113
+ Napi::Value CallMethod(const Napi::CallbackInfo& info) {
1114
+ Napi::Env env = info.Env();
1115
+ if (info.Length() < 2 || !info[1].IsString()) {
1116
+ Napi::TypeError::New(env, "callMethod(handle, methodName: string, args?: unknown[])")
1117
+ .ThrowAsJavaScriptException();
1118
+ return env.Null();
1119
+ }
1120
+ GObject* obj = UnwrapGObject(env, info[0]);
1121
+ if (obj == nullptr) return env.Null();
1122
+ std::string method = info[1].As<Napi::String>().Utf8Value();
1123
+ Napi::Array args = (info.Length() >= 3 && info[2].IsArray()) ? info[2].As<Napi::Array>()
1124
+ : Napi::Array::New(env, 0);
1125
+
1126
+ GType gtype = G_OBJECT_TYPE(obj);
1127
+ GIRepository* repo = DupDefaultRepository();
1128
+
1129
+ GIObjectInfo* objInfo = FindNearestObjectInfo(repo, gtype);
1130
+ if (objInfo == nullptr) {
1131
+ g_object_unref(repo);
1132
+ Napi::Error::New(env, std::string("no introspection info for ") + G_OBJECT_TYPE_NAME(obj))
1133
+ .ThrowAsJavaScriptException();
1134
+ return env.Null();
1135
+ }
1136
+
1137
+ GIFunctionInfo* func = ResolveInstanceMethod(repo, gtype, objInfo, method);
1138
+ gi_base_info_unref(objInfo);
1139
+ g_object_unref(repo);
1140
+
1141
+ if (func == nullptr) {
1142
+ // On a terminating env the method-name read (info[1].Utf8Value) degrades to
1143
+ // "" (a swallowed napi failure), so resolution misses and we land here — but
1144
+ // building the Napi::Error on the dying env would abort via Error::New's
1145
+ // fatal funnel. Gate on NodeGiJsAvailable (false on a dying env AND on a live
1146
+ // env with a pending exception); a genuine unknown method on a live env still
1147
+ // throws.
1148
+ if (NodeGiJsAvailable(env)) {
1149
+ Napi::Error::New(env, std::string("no method '") + method + "' on " + G_OBJECT_TYPE_NAME(obj))
1150
+ .ThrowAsJavaScriptException();
1151
+ }
1152
+ return env.Null();
1153
+ }
1154
+ if (!gi_callable_info_is_method(reinterpret_cast<GICallableInfo*>(func))) {
1155
+ gi_base_info_unref(func);
1156
+ Napi::TypeError::New(env, method + " is not an instance method").ThrowAsJavaScriptException();
1157
+ return env.Null();
1158
+ }
1159
+
1160
+ Napi::Value result =
1161
+ InvokeFunctionInfo(env, func, obj, args, std::string(G_OBJECT_TYPE_NAME(obj)) + "." + method);
1162
+ gi_base_info_unref(func);
1163
+ return result;
1164
+ }
1165
+
1166
+ // hasMethod(handle, methodName) -> boolean
1167
+ // TRUE iff callMethod(handle, methodName, …) would resolve an invocable
1168
+ // instance method (literal introspected name or snake_case alias, walking the
1169
+ // same own/interface/parent chain). Backs the L1 wrapper's GJS-parity property
1170
+ // access: `obj.nonexistentMethod` must be `undefined` — real consumers
1171
+ // feature-detect optional native methods (`typeof gl.clearBufferfv ===
1172
+ // 'function'` in `@gjsify/webgl`'s clearBuffer emulation, the exposing call
1173
+ // under Excalibur's RenderTarget.blitToScreen) and the old unconditional
1174
+ // throw-on-call thunk made that detection lie. Never throws for a merely
1175
+ // unknown name; a non-introspectable instance simply reports false.
1176
+ Napi::Value HasMethod(const Napi::CallbackInfo& info) {
1177
+ Napi::Env env = info.Env();
1178
+ if (info.Length() < 2 || !info[1].IsString()) {
1179
+ Napi::TypeError::New(env, "hasMethod(handle, methodName: string)").ThrowAsJavaScriptException();
1180
+ return env.Null();
1181
+ }
1182
+ GObject* obj = UnwrapGObject(env, info[0]);
1183
+ if (obj == nullptr) return env.Null();
1184
+ std::string method = info[1].As<Napi::String>().Utf8Value();
1185
+
1186
+ GType gtype = G_OBJECT_TYPE(obj);
1187
+ GIRepository* repo = DupDefaultRepository();
1188
+ GIObjectInfo* objInfo = FindNearestObjectInfo(repo, gtype);
1189
+ if (objInfo == nullptr) {
1190
+ g_object_unref(repo);
1191
+ return Napi::Boolean::New(env, false);
1192
+ }
1193
+ GIFunctionInfo* func = ResolveInstanceMethod(repo, gtype, objInfo, method);
1194
+ gi_base_info_unref(objInfo);
1195
+ g_object_unref(repo);
1196
+ if (func == nullptr) return Napi::Boolean::New(env, false);
1197
+ const bool invocable = gi_callable_info_is_method(reinterpret_cast<GICallableInfo*>(func));
1198
+ gi_base_info_unref(func);
1199
+ return Napi::Boolean::New(env, invocable);
1200
+ }
1201
+
1202
+ // callStaticMethod(namespace, typeName, methodName, args?) -> unknown
1203
+ // Invoke a type-level constructor/static function (e.g. Gio.File.new_for_path,
1204
+ // Gtk.Label.new) — a function found ON a type but taking no instance. The Node
1205
+ // twin of `Ns.Class.method(...)`.
1206
+ Napi::Value CallStaticMethod(const Napi::CallbackInfo& info) {
1207
+ Napi::Env env = info.Env();
1208
+ if (info.Length() < 3 || !info[0].IsString() || !info[1].IsString() || !info[2].IsString()) {
1209
+ Napi::TypeError::New(
1210
+ env, "callStaticMethod(namespace: string, typeName: string, methodName: string, args?: unknown[])")
1211
+ .ThrowAsJavaScriptException();
1212
+ return env.Null();
1213
+ }
1214
+ std::string ns = info[0].As<Napi::String>().Utf8Value();
1215
+ std::string tn = info[1].As<Napi::String>().Utf8Value();
1216
+ std::string method = info[2].As<Napi::String>().Utf8Value();
1217
+ Napi::Array args = (info.Length() >= 4 && info[3].IsArray()) ? info[3].As<Napi::Array>()
1218
+ : Napi::Array::New(env, 0);
1219
+
1220
+ GIRepository* repo = DupDefaultRepository();
1221
+ GIBaseInfo* typeInfo = gi_repository_find_by_name(repo, ns.c_str(), tn.c_str());
1222
+ if (typeInfo == nullptr) {
1223
+ g_object_unref(repo);
1224
+ Napi::Error::New(env, "no such type: " + ns + "." + tn).ThrowAsJavaScriptException();
1225
+ return env.Null();
1226
+ }
1227
+ GIFunctionInfo* func = nullptr;
1228
+ if (GI_IS_OBJECT_INFO(typeInfo)) {
1229
+ func = gi_object_info_find_method(reinterpret_cast<GIObjectInfo*>(typeInfo), method.c_str());
1230
+ } else if (GI_IS_INTERFACE_INFO(typeInfo)) {
1231
+ func = gi_interface_info_find_method(reinterpret_cast<GIInterfaceInfo*>(typeInfo), method.c_str());
1232
+ } else if (GI_IS_STRUCT_INFO(typeInfo)) {
1233
+ func = gi_struct_info_find_method(reinterpret_cast<GIStructInfo*>(typeInfo), method.c_str());
1234
+ }
1235
+ gi_base_info_unref(typeInfo);
1236
+ if (func == nullptr) {
1237
+ g_object_unref(repo);
1238
+ Napi::Error::New(env, "no static method '" + method + "' on " + ns + "." + tn)
1239
+ .ThrowAsJavaScriptException();
1240
+ return env.Null();
1241
+ }
1242
+ if (gi_callable_info_is_method(reinterpret_cast<GICallableInfo*>(func))) {
1243
+ gi_base_info_unref(func);
1244
+ g_object_unref(repo);
1245
+ Napi::TypeError::New(env, ns + "." + tn + "." + method +
1246
+ " is an instance method — call it on an instance")
1247
+ .ThrowAsJavaScriptException();
1248
+ return env.Null();
1249
+ }
1250
+ Napi::Value result = InvokeFunctionInfo(env, func, nullptr, args, ns + "." + tn + "." + method);
1251
+ gi_base_info_unref(func);
1252
+ g_object_unref(repo);
1253
+ return result;
1254
+ }
1255
+
1256
+ // constructStruct(namespace, typeName, args?) -> boxed handle
1257
+ // The `new <Struct>()` [[Construct]] path for boxed/plain structs — GJS
1258
+ // gi/boxed.cpp parity. Resolution order:
1259
+ // 1. the struct/union HAS a 'new' constructor → invoke it with the args (the
1260
+ // pre-existing `new GLib.MainLoop(null, false)` behavior, unchanged);
1261
+ // 2. no 'new', ZERO args, a struct with a known size → a ZERO-INITIALIZED
1262
+ // instance (GJS zero-allocates in boxed_new — `new Graphene.Rect()`,
1263
+ // `new Gdk.RGBA()`). For a registered BOXED GType the zero blob is
1264
+ // g_boxed_copy'd so the handle's g_boxed_free matches the type's real
1265
+ // allocator (graphene g_slice/malloc-allocates internally — handing a raw
1266
+ // g_malloc0 blob to g_boxed_free would corrupt that allocator); the same
1267
+ // pattern as NewGValue's g_boxed_copy(G_TYPE_VALUE, &zero) in object.cc,
1268
+ // generalized. A struct with NO registered boxed GType keeps the g_malloc0
1269
+ // blob itself, owned via the rawOwned/g_free finalizer arm (the
1270
+ // caller-allocates OUT pattern above).
1271
+ // 3. no 'new' + args → a clear error (GJS supports no positional field args
1272
+ // for boxed construction either);
1273
+ // 4. a union without 'new' keeps throwing — GJS's union.cpp requires a
1274
+ // zero-args constructor and never zero-allocates a union.
1275
+ Napi::Value ConstructStruct(const Napi::CallbackInfo& info) {
1276
+ Napi::Env env = info.Env();
1277
+ if (info.Length() < 2 || !info[0].IsString() || !info[1].IsString()) {
1278
+ Napi::TypeError::New(env,
1279
+ "constructStruct(namespace: string, typeName: string, args?: unknown[])")
1280
+ .ThrowAsJavaScriptException();
1281
+ return env.Null();
1282
+ }
1283
+ std::string ns = info[0].As<Napi::String>().Utf8Value();
1284
+ std::string tn = info[1].As<Napi::String>().Utf8Value();
1285
+ Napi::Array args = (info.Length() >= 3 && info[2].IsArray()) ? info[2].As<Napi::Array>()
1286
+ : Napi::Array::New(env, 0);
1287
+
1288
+ GIRepository* repo = DupDefaultRepository();
1289
+ GIBaseInfo* typeInfo = gi_repository_find_by_name(repo, ns.c_str(), tn.c_str());
1290
+ if (typeInfo == nullptr) {
1291
+ g_object_unref(repo);
1292
+ Napi::Error::New(env, "no such type: " + ns + "." + tn).ThrowAsJavaScriptException();
1293
+ return env.Null();
1294
+ }
1295
+ const bool isStruct = GI_IS_STRUCT_INFO(typeInfo);
1296
+ const bool isUnion = GI_IS_UNION_INFO(typeInfo);
1297
+ if (!isStruct && !isUnion) {
1298
+ gi_base_info_unref(typeInfo);
1299
+ g_object_unref(repo);
1300
+ Napi::TypeError::New(env, ns + "." + tn + " is not a struct/union type")
1301
+ .ThrowAsJavaScriptException();
1302
+ return env.Null();
1303
+ }
1304
+ GIFunctionInfo* func =
1305
+ isStruct ? gi_struct_info_find_method(reinterpret_cast<GIStructInfo*>(typeInfo), "new")
1306
+ : gi_union_info_find_method(reinterpret_cast<GIUnionInfo*>(typeInfo), "new");
1307
+ if (func != nullptr) {
1308
+ // Has a 'new' constructor → the exact pre-existing static-call path.
1309
+ gi_base_info_unref(typeInfo);
1310
+ Napi::Value result = InvokeFunctionInfo(env, func, nullptr, args, ns + "." + tn + ".new");
1311
+ gi_base_info_unref(func);
1312
+ g_object_unref(repo);
1313
+ return result;
1314
+ }
1315
+ if (isUnion) {
1316
+ gi_base_info_unref(typeInfo);
1317
+ g_object_unref(repo);
1318
+ Napi::Error::New(env, "unable to construct union " + ns + "." + tn +
1319
+ ": it has no 'new' constructor")
1320
+ .ThrowAsJavaScriptException();
1321
+ return env.Null();
1322
+ }
1323
+ if (args.Length() > 0) {
1324
+ gi_base_info_unref(typeInfo);
1325
+ g_object_unref(repo);
1326
+ Napi::Error::New(env, ns + "." + tn +
1327
+ ": constructor takes no arguments (boxed struct has no 'new')")
1328
+ .ThrowAsJavaScriptException();
1329
+ return env.Null();
1330
+ }
1331
+ const size_t size = gi_struct_info_get_size(reinterpret_cast<GIStructInfo*>(typeInfo));
1332
+ if (size == 0) {
1333
+ // Opaque struct (size unknown to GI) — nothing to zero-allocate; matches
1334
+ // GJS, which cannot allocate a boxed of unknown size either.
1335
+ gi_base_info_unref(typeInfo);
1336
+ g_object_unref(repo);
1337
+ Napi::Error::New(env, "unable to construct " + ns + "." + tn +
1338
+ ": no 'new' constructor and unknown size (opaque struct)")
1339
+ .ThrowAsJavaScriptException();
1340
+ return env.Null();
1341
+ }
1342
+ const GType gt =
1343
+ gi_registered_type_info_get_g_type(reinterpret_cast<GIRegisteredTypeInfo*>(typeInfo));
1344
+ Napi::Value result;
1345
+ if (gt != G_TYPE_INVALID && gt != G_TYPE_NONE && G_TYPE_IS_BOXED(gt)) {
1346
+ gpointer zero = g_malloc0(size);
1347
+ gpointer owned = g_boxed_copy(gt, zero);
1348
+ g_free(zero);
1349
+ result = MakeBoxedHandle(env, owned, gt, /* owns */ true, typeInfo);
1350
+ } else {
1351
+ // Plain (or registered-but-non-boxed) C struct: the handle owns the zeroed
1352
+ // block directly (g_free on finalize); a registered GType is kept for
1353
+ // method/field resolution, `owns` stays false so g_boxed_free never runs.
1354
+ const GType handleGType = (gt != G_TYPE_INVALID && gt != G_TYPE_NONE) ? gt : G_TYPE_INVALID;
1355
+ gpointer blob = g_malloc0(size);
1356
+ result = MakeBoxedHandle(env, blob, handleGType, /* owns */ false, typeInfo,
1357
+ /* rawOwned */ true);
1358
+ }
1359
+ gi_base_info_unref(typeInfo);
1360
+ g_object_unref(repo);
1361
+ return result;
1362
+ }
1363
+
1364
+ // callBoxedMethod(handle, methodName, args?) -> unknown
1365
+ // Invoke an instance method on a boxed/struct handle (e.g. mainLoop.run() /
1366
+ // mainLoop.quit()). Resolves the method against the boxed GType's GIStructInfo
1367
+ // (or union info) and invokes it with the boxed pointer prepended as instance.
1368
+ Napi::Value CallBoxedMethod(const Napi::CallbackInfo& info) {
1369
+ Napi::Env env = info.Env();
1370
+ if (info.Length() < 2 || !info[1].IsString()) {
1371
+ Napi::TypeError::New(env, "callBoxedMethod(handle, methodName: string, args?: unknown[])")
1372
+ .ThrowAsJavaScriptException();
1373
+ return env.Null();
1374
+ }
1375
+ if (!info[0].IsExternal() ||
1376
+ !info[0].As<Napi::External<BoxedHandle>>().CheckTypeTag(&kBoxedHandleTag)) {
1377
+ Napi::TypeError::New(env, "expected a node-gi boxed handle").ThrowAsJavaScriptException();
1378
+ return env.Null();
1379
+ }
1380
+ BoxedHandle* bh = info[0].As<Napi::External<BoxedHandle>>().Data();
1381
+ if (bh == nullptr || bh->ptr == nullptr) {
1382
+ Napi::TypeError::New(env, "invalid boxed handle").ThrowAsJavaScriptException();
1383
+ return env.Null();
1384
+ }
1385
+ std::string method = info[1].As<Napi::String>().Utf8Value();
1386
+ Napi::Array args = (info.Length() >= 3 && info[2].IsArray()) ? info[2].As<Napi::Array>()
1387
+ : Napi::Array::New(env, 0);
1388
+ if (bh->gtype == G_TYPE_INVALID) {
1389
+ Napi::Error::New(env, "boxed handle has no introspection GType for method resolution")
1390
+ .ThrowAsJavaScriptException();
1391
+ return env.Null();
1392
+ }
1393
+
1394
+ GIRepository* repo = DupDefaultRepository();
1395
+ GIBaseInfo* bi = gi_repository_find_by_gtype(repo, bh->gtype);
1396
+ GIFunctionInfo* func = nullptr;
1397
+ if (bi != nullptr && GI_IS_STRUCT_INFO(bi)) {
1398
+ func = gi_struct_info_find_method(reinterpret_cast<GIStructInfo*>(bi), method.c_str());
1399
+ } else if (bi != nullptr && GI_IS_UNION_INFO(bi)) {
1400
+ func = gi_union_info_find_method(reinterpret_cast<GIUnionInfo*>(bi), method.c_str());
1401
+ }
1402
+ if (bi != nullptr) gi_base_info_unref(bi);
1403
+ if (func == nullptr) {
1404
+ g_object_unref(repo);
1405
+ Napi::Error::New(env, std::string("no method '") + method + "' on " + g_type_name(bh->gtype))
1406
+ .ThrowAsJavaScriptException();
1407
+ return env.Null();
1408
+ }
1409
+
1410
+ Napi::Value result = InvokeFunctionInfo(env, func, bh->ptr, args,
1411
+ std::string(g_type_name(bh->gtype)) + "." + method);
1412
+ gi_base_info_unref(func);
1413
+ g_object_unref(repo);
1414
+ return result;
1415
+ }
1416
+
1417
+ // isBoxedHandle(value) -> boolean (tag-checked; no dereference)
1418
+ Napi::Value IsBoxedHandle(const Napi::CallbackInfo& info) {
1419
+ Napi::Env env = info.Env();
1420
+ bool is = info.Length() >= 1 && info[0].IsExternal() &&
1421
+ info[0].As<Napi::External<BoxedHandle>>().CheckTypeTag(&kBoxedHandleTag);
1422
+ return Napi::Boolean::New(env, is);
1423
+ }
1424
+
1425
+ } // namespace nodegi