@gjsify/node-gi 0.44.0 → 0.46.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/marshal.cc CHANGED
@@ -296,6 +296,28 @@ bool TryGetBoxedPtr(Napi::Value v, gpointer* out) {
296
296
  return true;
297
297
  }
298
298
 
299
+ // __boxedAddress(value) -> the address a boxed handle wraps, as a hex string, or
300
+ // null when `value` is not one. TEST-ONLY (the `__` prefix marks it, as on the
301
+ // __stressRefUnref* pair) — nothing on the runtime path may branch on it.
302
+ //
303
+ // It exists because the IN-transfer ownership rules are statements about POINTER
304
+ // IDENTITY ("a transfer-full boxed IN arg gets an independent copy, never the
305
+ // block this handle still owns"), and nothing else could observe one. The tests
306
+ // guarding them ran the call many times and waited for a double free to abort —
307
+ // which only happens if the allocator has NOT recycled the block between the two
308
+ // frees. Measured while reviewing #1496: with `TransferBoxedIn` replaced by a bare
309
+ // borrow — a real double free on every round — `gerror-out.test.mjs` passed 5 runs
310
+ // out of 5, and the same program under valgrind reported 0 errors. An identity
311
+ // assertion fails on the first round instead.
312
+ Napi::Value BoxedAddress(const Napi::CallbackInfo& info) {
313
+ Napi::Env env = info.Env();
314
+ gpointer ptr = nullptr;
315
+ if (info.Length() < 1 || !TryGetBoxedPtr(info[0], &ptr)) return env.Null();
316
+ char buf[2 + sizeof(gpointer) * 2 + 1];
317
+ g_snprintf(buf, sizeof(buf), "%p", ptr);
318
+ return Napi::String::New(env, buf);
319
+ }
320
+
299
321
  // boxedTypeName(value) -> the boxed handle's GType name (e.g. "GBytes"), or null
300
322
  // when the handle carries no registered GType. Lets the L1 layer attach a
301
323
  // type-specific convenience (GLib.Bytes.toArray) without a per-type wrapper.
@@ -441,6 +463,55 @@ static const char* InformalValueTypeName(const Napi::Value& v) {
441
463
  return "Object";
442
464
  }
443
465
 
466
+ // The refusal a GError-typed IN arg raises, named after the argument the way gjs
467
+ // names its marshalling errors.
468
+ static void ThrowExpectedGLibError(Napi::Env env, const char* argName, const std::string& got) {
469
+ std::string where = argName != nullptr ? std::string("argument '") + argName + "'" : "argument";
470
+ Napi::TypeError::New(env, "expected a GLib.Error for " + where + ", got " + got)
471
+ .ThrowAsJavaScriptException();
472
+ }
473
+
474
+ // Whether `v` is an instance of the L1 `GLib.Error` class — the shape a `catch`
475
+ // around a failed GI invoke hands you, and what `new GLib.Error(domain, code,
476
+ // message)` builds. Recognition is `instanceof` against the class L1 registered
477
+ // alongside its error builder, never duck-typing: a plain object carrying a
478
+ // `code` must still be refused.
479
+ static bool IsL1GLibError(Napi::Env env, Napi::Value v) {
480
+ if (!v.IsObject()) return false;
481
+ NodeGiEnvData* d = EnvData(env);
482
+ if (d == nullptr || d->errorClass == nullptr) return false;
483
+ napi_value cls = nullptr;
484
+ if (napi_get_reference_value(env, d->errorClass, &cls) != napi_ok || cls == nullptr) return false;
485
+ Napi::Value clsValue(env, cls);
486
+ return clsValue.IsFunction() && v.As<Napi::Object>().InstanceOf(clsValue.As<Napi::Function>());
487
+ }
488
+
489
+ // Build a real GError out of an L1 `GLib.Error`, which carries no `GError*` of
490
+ // its own. Throws + returns nullptr when its domain is unusable.
491
+ //
492
+ // `.domainQuark` is the numeric GQuark the engine read off the original GError;
493
+ // `.domain` is its NAME (the node-gi divergence documented in
494
+ // docs/node-gi-gjs-surface.md), which is what an error constructed in JS from a
495
+ // quark name carries. Either resolves to the same quark; a value with neither is
496
+ // not a usable GError — quark 0 would produce an error no `matches()` can ever
497
+ // answer for, so it is refused rather than marshalled into a silent mismatch.
498
+ static GError* ErrorFromL1GLibError(Napi::Env env, Napi::Value v, const char* argName) {
499
+ Napi::Object err = v.As<Napi::Object>();
500
+ Napi::Value quark = err.Get("domainQuark");
501
+ Napi::Value domain = err.Get("domain");
502
+ GQuark gq = 0;
503
+ if (quark.IsNumber()) gq = static_cast<GQuark>(NodeGiToUint32(quark));
504
+ else if (domain.IsString())
505
+ gq = g_quark_from_string(domain.As<Napi::String>().Utf8Value().c_str());
506
+ if (gq == 0) {
507
+ ThrowExpectedGLibError(env, argName, "a GLib.Error with no domain");
508
+ return nullptr;
509
+ }
510
+ Napi::Value message = err.Get("message");
511
+ std::string text = message.IsString() ? message.As<Napi::String>().Utf8Value() : std::string();
512
+ return g_error_new_literal(gq, NodeGiToInt32(err.Get("code")), text.c_str());
513
+ }
514
+
444
515
  // "Gtk.Box" for an introspected GType, g_type_name() otherwise (a registerClass
445
516
  // subtype has no GI info of its own; its C name is still exact). The gjs twin is
446
517
  // GIWrapperBase::format_name() (refs/gjs/gi/wrapperutils.h).
@@ -469,6 +540,7 @@ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument*
469
540
  CreatedClosures* closures,
470
541
  CreatedBytes* bytes,
471
542
  CreatedValues* values,
543
+ CreatedErrors* errors,
472
544
  const char* argName) {
473
545
  if (v.IsEmpty()) {
474
546
  // Residue of a swallowed napi failure (a fallible Get()/coercion upstream
@@ -712,6 +784,54 @@ bool JsToGIArgument(Napi::Env env, Napi::Value v, GITypeInfo* type, GIArgument*
712
784
  out->v_size = static_cast<gsize>(gt);
713
785
  return true;
714
786
  }
787
+ case GI_TYPE_TAG_ERROR: {
788
+ // A GError IN/INOUT argument: `Gst.Message.new_error(src, error, debug)` —
789
+ // how an application POSTS the failure the bus accessors read back —
790
+ // `GLib.propagate_error`, and the INOUT `err` of `GLib.prefix_error_literal`.
791
+ // null/undefined → a NULL GError, the same leniency the object/boxed arms
792
+ // keep (gjs refuses both for a non-nullable arg — status/open-todos.md).
793
+ if (v.IsNull() || v.IsUndefined()) {
794
+ out->v_pointer = nullptr;
795
+ return true;
796
+ }
797
+ // Shape 1: the boxed handle a GError-typed return/OUT produced. Ownership is
798
+ // the ordinary boxed one and TransferBoxedIn supplies it: transfer-none is a
799
+ // borrow, transfer-full hands the callee an independent g_error_copy
800
+ // (g_propagate_error ADOPTS and frees its `src`) so the JS handle's finalizer
801
+ // cannot double-free it. Measured against gjs: after `GLib.propagate_error(e)`
802
+ // the original `e` is still readable there too.
803
+ //
804
+ // Type-safety is UNCONDITIONAL here, unlike the struct/boxed arm's "compare
805
+ // only when both sides carry a registered GType": the expected type is known
806
+ // (G_TYPE_ERROR, always), so a handle without it is a wrong pointer — and a
807
+ // wrong pointer in a GError slot is a wild dereference inside GLib.
808
+ BoxedHandle* h = TryGetBoxedHandle(v);
809
+ if (h != nullptr) {
810
+ if (h->gtype == G_TYPE_INVALID || !g_type_is_a(h->gtype, G_TYPE_ERROR)) {
811
+ ThrowExpectedGLibError(env, argName,
812
+ h->gtype != G_TYPE_INVALID ? g_type_name(h->gtype)
813
+ : InformalValueTypeName(v));
814
+ return false;
815
+ }
816
+ out->v_pointer = TransferBoxedIn(h, transfer);
817
+ return true;
818
+ }
819
+ // Shape 2: the L1 `GLib.Error` JS class — a caught error, or one built with
820
+ // `new GLib.Error(…)`. It carries no GError*, so build one and release it per
821
+ // transfer (CreatedErrors, the rule GBytes and GValue already follow). gjs has
822
+ // ONE object for both shapes and accepts either; refusing this one left the
823
+ // hand-back the OUT direction exists for closed to exactly the errors an
824
+ // application has in hand.
825
+ if (errors != nullptr && IsL1GLibError(env, v)) {
826
+ GError* built = ErrorFromL1GLibError(env, v, argName);
827
+ if (built == nullptr) return false; // ErrorFromL1GLibError already threw
828
+ out->v_pointer = built;
829
+ errors->Record(built, transfer);
830
+ return true;
831
+ }
832
+ ThrowExpectedGLibError(env, argName, InformalValueTypeName(v));
833
+ return false;
834
+ }
715
835
  default:
716
836
  Napi::TypeError::New(
717
837
  env, "Unsupported IN argument type tag " + std::to_string(static_cast<int>(tag)) +
@@ -892,10 +1012,35 @@ Napi::Value GIArgumentToJs(Napi::Env env, GITypeInfo* type, GIArgument* arg,
892
1012
  // pass it, and free it (container + any g_strdup'd strings) AFTER the invoke.
893
1013
  // TRANSFER_EVERYTHING / TRANSFER_CONTAINER means the callee adopts what we
894
1014
  // built — we must NOT free it (that would UAF / double-free).
1015
+ // * IN, PER ELEMENT: the container transfer also decides who owns the ELEMENTS,
1016
+ // and the two answers differ. CONTAINER hands over only the container, so the
1017
+ // elements stay ours and a borrowed pointer is right. EVERYTHING hands over the
1018
+ // elements TOO — a borrowed object/boxed pointer there is freed by the callee
1019
+ // AND by the JS handle that still owns it. Measured on the pointer-struct case
1020
+ // the moment it was admitted: `Pango.Font.descriptions_free([desc])` (`descs` is
1021
+ // `(transfer full)`) read back garbage and died in `free(): invalid size`. So
1022
+ // ElementToGIArgument takes the ELEMENT transfer — NOTHING for a borrow (incl.
1023
+ // CONTAINER), EVERYTHING for adoption, exactly the rule the read side already
1024
+ // uses — and refs/copies on EVERYTHING. Strings were always right (g_strdup'd
1025
+ // per element either way).
1026
+ // * IN, ROOTING: a borrowed element pointer stays valid across the invoke because
1027
+ // the JS array is an ARGUMENT of the running native call — it and everything
1028
+ // reachable from it are held by that call's handle scope, which is not popped
1029
+ // until we return. So nothing can be collected between building the container
1030
+ // and the callee reading it, not even an element a getter/Proxy synthesised.
895
1031
 
896
1032
  // Element-type support shared by every container kind. *why receives a short
897
1033
  // label on refusal (so the caller can throw a precise deferral message).
898
- static bool IsSupportedElementType(GITypeInfo* elem, std::string* why) {
1034
+ //
1035
+ // `byValueOk` is true only where the element is a SIZED CELL the IN path lays out
1036
+ // itself — a C array being built for an IN argument. It is false for a read-back
1037
+ // (ReadCElement still dereferences an interface element as a pointer) and false for
1038
+ // GList/GSList/GHashTable, whose elements are POINTER slots filled through
1039
+ // gi_type_info_hash_pointer_from_argument: that helper passes a pointer through
1040
+ // unchanged, which is right for a pointer-struct element and meaningless for a
1041
+ // four-byte enum or a twenty-four-byte record. So the by-value admission is scoped to
1042
+ // the one container kind that has somewhere to put the bytes.
1043
+ static bool IsSupportedElementType(GITypeInfo* elem, std::string* why, bool byValueOk) {
899
1044
  switch (gi_type_info_get_tag(elem)) {
900
1045
  case GI_TYPE_TAG_BOOLEAN:
901
1046
  case GI_TYPE_TAG_INT8:
@@ -914,6 +1059,39 @@ static bool IsSupportedElementType(GITypeInfo* elem, std::string* why) {
914
1059
  case GI_TYPE_TAG_INTERFACE: {
915
1060
  GIBaseInfo* iface = gi_type_info_get_interface(elem);
916
1061
  bool ok = iface != nullptr && (GI_IS_OBJECT_INFO(iface) || GI_IS_INTERFACE_INFO(iface));
1062
+ // A struct element that is a POINTER occupies exactly one pointer slot, which is
1063
+ // the same shape an object element already has: CElementSize answers
1064
+ // sizeof(gpointer) and the write loop memcpy's the low 8 bytes.
1065
+ // `GLib.Variant.new_tuple` is the call this unblocks: measured against the
1066
+ // installed typelib, its `children` element is `tag=interface ptr=1 kind=STRUCT`.
1067
+ //
1068
+ // Admission alone was NOT enough, and the reading that said it was is worth
1069
+ // keeping: "FreeInContainer's non-string branch already frees the buffer without
1070
+ // touching what the pointers point at" is true and beside the point, because
1071
+ // FreeInContainer runs only for TRANSFER_NOTHING. On a (transfer full) container
1072
+ // the CALLEE frees the elements — see the per-element half of the OWNERSHIP note
1073
+ // above and ElementToGIArgument, which is where that is now handled.
1074
+ //
1075
+ // A BY-VALUE struct element stays refused, and the distinction is the whole point
1076
+ // of testing is_pointer here rather than the kind alone: `Gtk.Accessible
1077
+ // .update_property`'s values are `ptr=0 kind=STRUCT size=24`, so admitting them
1078
+ // would lay 24-byte GValues out at an 8-byte stride and hand the callee garbage.
1079
+ // That case needs gi_struct_info_get_size plus an ownership rule (each in-place
1080
+ // g_value_init would need unsetting, and FreeInContainer has no branch for it) —
1081
+ // the same deferred work this file already records at its CALLER_ALLOCATES site.
1082
+ if (!ok && iface != nullptr && GI_IS_STRUCT_INFO(iface) && gi_type_info_is_pointer(elem)) {
1083
+ ok = true;
1084
+ }
1085
+ // BY-VALUE elements, admitted only where there is a sized cell to write them
1086
+ // into (see `byValueOk`). An enum/flags element is its storage integer; a
1087
+ // record/union element is its own bytes, copied out of a boxed handle. Both go
1088
+ // through CInElementSize + ElementToCArrayCell, which is the write path that
1089
+ // does NOT run the GIArgument union through memcpy — see the note there.
1090
+ if (!ok && byValueOk && iface != nullptr && !gi_type_info_is_pointer(elem) &&
1091
+ (GI_IS_ENUM_INFO(iface) || GI_IS_FLAGS_INFO(iface) || GI_IS_STRUCT_INFO(iface) ||
1092
+ GI_IS_UNION_INFO(iface))) {
1093
+ ok = true;
1094
+ }
917
1095
  if (!ok && why != nullptr) *why = "struct/union/enum element";
918
1096
  if (iface != nullptr) gi_base_info_unref(iface);
919
1097
  return ok;
@@ -929,7 +1107,7 @@ static bool IsSupportedElementType(GITypeInfo* elem, std::string* why) {
929
1107
  // up front. Array-type kind is intentionally NOT restricted here — the read side
930
1108
  // (GIArrayToJs) handles C/BYTE_ARRAY/GArray/GPtrArray; the IN side rejects
931
1109
  // GArray/GPtrArray separately (they're rare as IN in headless GLib).
932
- bool IsSupportedContainerType(GITypeInfo* type, std::string* why) {
1110
+ bool IsSupportedContainerType(GITypeInfo* type, std::string* why, ContainerUse use) {
933
1111
  switch (gi_type_info_get_tag(type)) {
934
1112
  case GI_TYPE_TAG_ARRAY:
935
1113
  case GI_TYPE_TAG_GLIST:
@@ -939,7 +1117,14 @@ bool IsSupportedContainerType(GITypeInfo* type, std::string* why) {
939
1117
  if (why != nullptr) *why = "untyped container";
940
1118
  return false;
941
1119
  }
942
- bool ok = IsSupportedElementType(elem, why);
1120
+ // A by-value element needs a sized cell, which only a C array being BUILT has.
1121
+ // GArray is deliberately excluded alongside the lists: its elements are sized,
1122
+ // but g_array_append_vals copies from a buffer this path would have to size the
1123
+ // same way, and no installed typelib has a by-value-element GArray as IN.
1124
+ bool byValueOk = use == ContainerUse::kIn &&
1125
+ gi_type_info_get_tag(type) == GI_TYPE_TAG_ARRAY &&
1126
+ gi_type_info_get_array_type(type) == GI_ARRAY_TYPE_C;
1127
+ bool ok = IsSupportedElementType(elem, why, byValueOk);
943
1128
  gi_base_info_unref(elem);
944
1129
  return ok;
945
1130
  }
@@ -954,7 +1139,7 @@ bool IsSupportedContainerType(GITypeInfo* type, std::string* why) {
954
1139
  ktag == GI_TYPE_TAG_INT8 || ktag == GI_TYPE_TAG_UINT8 ||
955
1140
  ktag == GI_TYPE_TAG_INT16 || ktag == GI_TYPE_TAG_UINT16 ||
956
1141
  ktag == GI_TYPE_TAG_INT32 || ktag == GI_TYPE_TAG_UINT32;
957
- bool vok = vt != nullptr && IsSupportedElementType(vt, nullptr);
1142
+ bool vok = vt != nullptr && IsSupportedElementType(vt, nullptr, false);
958
1143
  if (!kok && why != nullptr) *why = "unsupported GHashTable key";
959
1144
  else if (!vok && why != nullptr) *why = "unsupported GHashTable value";
960
1145
  if (kt != nullptr) gi_base_info_unref(kt);
@@ -988,6 +1173,88 @@ size_t CElementSize(GITypeInfo* elem) {
988
1173
  }
989
1174
  }
990
1175
 
1176
+ // Storage size of one C-array element on the IN path, INCLUDING the by-value
1177
+ // interface elements CElementSize declines to size.
1178
+ //
1179
+ // Deliberately NOT folded into CElementSize, and the split is a scope statement rather
1180
+ // than duplication: CElementSize is also the READ stride (GIArrayToJs), where a
1181
+ // by-value interface element is still unreadable — ReadCElement dereferences it as a
1182
+ // pointer, which is why ElementsAreReadable exists. Teaching CElementSize the true
1183
+ // size there would turn a wrong stride in front of a wrong read into a RIGHT stride in
1184
+ // front of a wrong read: more plausible output, the same defect. Opening the read side
1185
+ // is separate work (this file records it at the inline-record field path and calls.cc
1186
+ // records it at CALLER_ALLOCATES); until then the IN path answers for itself. Every
1187
+ // non-interface tag delegates, so the two answers cannot drift.
1188
+ //
1189
+ // An enum's size is its OWN storage type, not a hardcoded 4: gi_enum_info_get_storage
1190
+ // _type is what the typelib records for the C enum, and gjs's sizeof(unsigned int) is
1191
+ // an assumption this does not have to inherit.
1192
+ static size_t CInElementSize(GITypeInfo* elem) {
1193
+ if (gi_type_info_get_tag(elem) != GI_TYPE_TAG_INTERFACE) return CElementSize(elem);
1194
+ if (gi_type_info_is_pointer(elem)) return sizeof(gpointer);
1195
+ GIBaseInfo* iface = gi_type_info_get_interface(elem);
1196
+ if (iface == nullptr) return sizeof(gpointer);
1197
+ size_t size;
1198
+ if (GI_IS_ENUM_INFO(iface) || GI_IS_FLAGS_INFO(iface)) {
1199
+ switch (gi_enum_info_get_storage_type(reinterpret_cast<GIEnumInfo*>(iface))) {
1200
+ case GI_TYPE_TAG_INT8:
1201
+ case GI_TYPE_TAG_UINT8: size = 1; break;
1202
+ case GI_TYPE_TAG_INT16:
1203
+ case GI_TYPE_TAG_UINT16: size = 2; break;
1204
+ case GI_TYPE_TAG_INT64:
1205
+ case GI_TYPE_TAG_UINT64: size = 8; break;
1206
+ default: size = 4; break; // int32/uint32 and anything unannotated
1207
+ }
1208
+ } else if (GI_IS_STRUCT_INFO(iface)) {
1209
+ size = gi_struct_info_get_size(reinterpret_cast<GIStructInfo*>(iface));
1210
+ } else if (GI_IS_UNION_INFO(iface)) {
1211
+ size = gi_union_info_get_size(reinterpret_cast<GIUnionInfo*>(iface));
1212
+ } else {
1213
+ size = sizeof(gpointer); // object/interface instance: one pointer slot
1214
+ }
1215
+ gi_base_info_unref(iface);
1216
+ return size;
1217
+ }
1218
+
1219
+ // Whether an element is a by-value RECORD (struct or union) — the one kind that must
1220
+ // not travel through a GIArgument. See ByValueRecordToCell.
1221
+ static bool IsByValueRecordElement(GITypeInfo* elem) {
1222
+ if (gi_type_info_get_tag(elem) != GI_TYPE_TAG_INTERFACE) return false;
1223
+ if (gi_type_info_is_pointer(elem)) return false;
1224
+ GIBaseInfo* iface = gi_type_info_get_interface(elem);
1225
+ if (iface == nullptr) return false;
1226
+ bool record = GI_IS_STRUCT_INFO(iface) || GI_IS_UNION_INFO(iface);
1227
+ gi_base_info_unref(iface);
1228
+ return record;
1229
+ }
1230
+
1231
+ // The GType of a by-value record element, or G_TYPE_INVALID for an unregistered one.
1232
+ static GType ByValueRecordGType(GITypeInfo* elem) {
1233
+ GIBaseInfo* iface = gi_type_info_get_interface(elem);
1234
+ if (iface == nullptr) return G_TYPE_INVALID;
1235
+ GType gtype = GI_IS_REGISTERED_TYPE_INFO(iface)
1236
+ ? gi_registered_type_info_get_g_type(
1237
+ reinterpret_cast<GIRegisteredTypeInfo*>(iface))
1238
+ : G_TYPE_INVALID;
1239
+ gi_base_info_unref(iface);
1240
+ return gtype == G_TYPE_NONE ? G_TYPE_INVALID : gtype;
1241
+ }
1242
+
1243
+ // Whether a by-value record element array is one this path OWNS the contents of —
1244
+ // true only for GValue, the one element kind written by initialising in place rather
1245
+ // than by copying a caller's bytes. FreeInContainer needs the same answer, so it is
1246
+ // derived from the type both times instead of carried in InContainer: a flag would be
1247
+ // a second truth that a later element kind could get wrong in only one of the places.
1248
+ static bool ElementsAreOwnedGValues(GITypeInfo* type) {
1249
+ if (gi_type_info_get_tag(type) != GI_TYPE_TAG_ARRAY) return false;
1250
+ if (gi_type_info_get_array_type(type) != GI_ARRAY_TYPE_C) return false;
1251
+ GITypeInfo* elem = gi_type_info_get_param_type(type, 0);
1252
+ if (elem == nullptr) return false;
1253
+ bool owned = IsByValueRecordElement(elem) && ByValueRecordGType(elem) == G_TYPE_VALUE;
1254
+ gi_base_info_unref(elem);
1255
+ return owned;
1256
+ }
1257
+
991
1258
  // Write the array's element count into a length arg's GIArgument slot (IN length
992
1259
  // autofill). The field is selected by the length arg's own tag; the slot is
993
1260
  // zero-initialised so writing the matching field is correct on little-endian
@@ -1006,6 +1273,29 @@ void WriteLengthValue(GITypeInfo* lenType, GIArgument* slot, long n) {
1006
1273
  }
1007
1274
  }
1008
1275
 
1276
+ // Two array arguments can name the SAME length argument — `Gtk.Accessible
1277
+ // .update_property(n, properties[], values[])` is the shape, and `update_state` and
1278
+ // `update_relation` repeat it. The autofill writes that argument once per array, so the
1279
+ // LAST array silently decides the count the callee then reads BOTH arrays by; a shorter
1280
+ // one is read past its end, inside the callee, with nothing on this side to attribute it
1281
+ // to afterwards.
1282
+ //
1283
+ // gjs does not check this — measured on 1.88.1: `update_property([LABEL, DESCRIPTION],
1284
+ // ['only one'])` is accepted and reads out of bounds. Refusing it is therefore a
1285
+ // deliberate divergence, and the cheaper side of one: the caller passed two arrays that
1286
+ // cannot both be right, and the alternative to a TypeError is an overread.
1287
+ bool RecordLengthWrite(std::vector<LengthWrite>* seen, unsigned int index, long count,
1288
+ long* other) {
1289
+ for (const LengthWrite& w : *seen) {
1290
+ if (w.index != index) continue;
1291
+ if (w.count == count) return true;
1292
+ if (other != nullptr) *other = w.count;
1293
+ return false;
1294
+ }
1295
+ seen->push_back(LengthWrite{index, count});
1296
+ return true;
1297
+ }
1298
+
1009
1299
  // Read a length value the callee wrote into a length arg's slot (OUT length).
1010
1300
  static long ReadLengthValue(GITypeInfo* lenType, GIArgument* slot) {
1011
1301
  switch (gi_type_info_get_tag(lenType)) {
@@ -1285,10 +1575,14 @@ Napi::Value ReadOutOrReturn(Napi::Env env, GICallableInfo* callable, GITypeInfo*
1285
1575
  }
1286
1576
  }
1287
1577
 
1288
- // Fill a GIArgument for a single list/hash element from a JS value. Strings are
1289
- // g_strdup'd (the container owns them); objects contribute their borrowed
1290
- // handle pointer. Throws + returns false on an unsupported element.
1291
- static bool ElementToGIArgument(Napi::Env env, GITypeInfo* elem, Napi::Value v, GIArgument* a) {
1578
+ // Fill a GIArgument for a single array/list/hash element from a JS value. Strings
1579
+ // are g_strdup'd (the container owns them). `elemTransfer` is the ELEMENT half of
1580
+ // the container's transfer (see the OWNERSHIP note above): NOTHING → an object /
1581
+ // boxed handle contributes its BORROWED pointer; EVERYTHING → the callee adopts
1582
+ // the element, so it gets a ref (objects) or an independent copy (boxed) instead.
1583
+ // Throws + returns false on an unsupported element.
1584
+ static bool ElementToGIArgument(Napi::Env env, GITypeInfo* elem, Napi::Value v, GIArgument* a,
1585
+ GITransfer elemTransfer) {
1292
1586
  memset(a, 0, sizeof(*a));
1293
1587
  switch (gi_type_info_get_tag(elem)) {
1294
1588
  // Scalar coercions via the terminate-safe helpers (common.h): a swallowed
@@ -1312,15 +1606,61 @@ static bool ElementToGIArgument(Napi::Env env, GITypeInfo* elem, Napi::Value v,
1312
1606
  : g_strdup(NodeGiToUtf8(v).c_str());
1313
1607
  return true;
1314
1608
  case GI_TYPE_TAG_INTERFACE: {
1609
+ // A BY-VALUE enum/flags element is a plain JS number, and it is the one interface
1610
+ // element that legitimately reaches this function without a handle. It writes the
1611
+ // member's integer into the union's low bytes; JsToCArray then copies exactly
1612
+ // CInElementSize bytes, which for an enum is its own storage size (1/2/4/8) and
1613
+ // therefore never more than the union holds. Flags take the unsigned field for
1614
+ // the same reason g_value_set_flags does — a mask with the top bit set must not
1615
+ // become negative on its way through.
1616
+ if (!gi_type_info_is_pointer(elem)) {
1617
+ GIBaseInfo* enumIface = gi_type_info_get_interface(elem);
1618
+ if (enumIface != nullptr &&
1619
+ (GI_IS_ENUM_INFO(enumIface) || GI_IS_FLAGS_INFO(enumIface))) {
1620
+ bool isFlags = GI_IS_FLAGS_INFO(enumIface);
1621
+ gi_base_info_unref(enumIface);
1622
+ if (isFlags) {
1623
+ a->v_uint32 = NodeGiToUint32(v);
1624
+ } else {
1625
+ a->v_int32 = NodeGiToInt32(v);
1626
+ }
1627
+ return true;
1628
+ }
1629
+ if (enumIface != nullptr) gi_base_info_unref(enumIface);
1630
+ }
1315
1631
  if (v.IsNull() || v.IsUndefined()) {
1316
1632
  a->v_pointer = nullptr;
1317
1633
  return true;
1318
1634
  }
1319
1635
  if (v.IsExternal() && v.As<Napi::External<GObject>>().CheckTypeTag(&kGObjectHandleTag)) {
1320
- a->v_pointer = v.As<Napi::External<GObject>>().Data();
1636
+ GObject* obj = v.As<Napi::External<GObject>>().Data();
1637
+ // The callee adopts one ref PER ELEMENT on EVERYTHING (Gdk.ContentProvider
1638
+ // .new_union's `providers`, Gtk.ClosureExpression.new's `params`, the
1639
+ // Gst/GstPbutils `*_list_free` helpers): hand it one it may drop, never the
1640
+ // wrapper's own — that would under-ref and free the object out from under JS.
1641
+ if (obj != nullptr && elemTransfer == GI_TRANSFER_EVERYTHING) g_object_ref(obj);
1642
+ a->v_pointer = obj;
1321
1643
  return true;
1322
1644
  }
1323
- Napi::TypeError::New(env, "expected a GObject handle as a container element")
1645
+ // A boxed/struct handle contributes its pointer, exactly as an object handle
1646
+ // does. Only reachable for a POINTER struct element — IsSupportedElementType
1647
+ // refuses the by-value kind before the invoke, so no by-value record can arrive
1648
+ // here and be silently treated as a pointer. TransferBoxedIn is the SAME
1649
+ // adopt-or-borrow helper the scalar boxed IN arg uses (g_variant_ref /
1650
+ // g_boxed_copy on EVERYTHING, relinquish for a struct with no copy function),
1651
+ // so a container element and a plain argument cannot drift apart.
1652
+ if (BoxedHandle* boxed = TryGetBoxedHandle(v); boxed != nullptr) {
1653
+ a->v_pointer = TransferBoxedIn(boxed, elemTransfer);
1654
+ return true;
1655
+ }
1656
+ // Everything else refuses LOUDLY here rather than silently contributing a
1657
+ // nullptr. Two element kinds reach this on purpose: a GObject FUNDAMENTAL
1658
+ // (GskRenderNode — object info, so the predicate admits it, but its handle
1659
+ // carries kFundamentalHandleTag) and a FOREIGN struct (cairo, kCairoHandleTag),
1660
+ // neither of which owns its pointer the way the two branches above assume. No
1661
+ // installed typelib has a foreign-struct container element today; if one is
1662
+ // added, route it through ForeignStructOps::to rather than widening this test.
1663
+ Napi::TypeError::New(env, "expected a GObject or boxed handle as a container element")
1324
1664
  .ThrowAsJavaScriptException();
1325
1665
  return false;
1326
1666
  }
@@ -1331,20 +1671,132 @@ static bool ElementToGIArgument(Napi::Env env, GITypeInfo* elem, Napi::Value v,
1331
1671
  }
1332
1672
  }
1333
1673
 
1674
+ // The introspected size of a record handle's own type, or 0 when it has none.
1675
+ static size_t BoxedInfoSize(GIBaseInfo* info) {
1676
+ if (info == nullptr) return 0;
1677
+ if (GI_IS_STRUCT_INFO(info)) return gi_struct_info_get_size(reinterpret_cast<GIStructInfo*>(info));
1678
+ if (GI_IS_UNION_INFO(info)) return gi_union_info_get_size(reinterpret_cast<GIUnionInfo*>(info));
1679
+ return 0;
1680
+ }
1681
+
1682
+ // Write ONE by-value record element straight into its C-array cell.
1683
+ //
1684
+ // WHY THIS IS NOT A GIArgument, which is the whole reason the function exists. The
1685
+ // array write loop copies `elemSize` bytes out of a GIArgument — a union, eight bytes
1686
+ // wide. A by-value record is its own size: GValue is 24 on this ABI, Gio.ActionEntry
1687
+ // 64. Sizing the loop without moving the record off the union would read sixteen bytes
1688
+ // past it, off the stack, once per element, and would produce a green build with
1689
+ // plausible-looking output. So a record never enters a GIArgument; its bytes are
1690
+ // written where they finally live.
1691
+ //
1692
+ // TWO SOURCES, and only one of them leaves us owning anything:
1693
+ // * GObject.Value — INITIALISED IN PLACE, either from any JS value (gjs 1.88.1
1694
+ // accepts `update_property([Gtk.AccessibleProperty.LABEL], ['text'])`, measured)
1695
+ // or by copying an existing GObject.Value handle. Both end with a GValue THIS call
1696
+ // constructed, so it is unset after the invoke — see ElementsAreOwnedGValues and
1697
+ // FreeInContainer. Copying rather than memcpy'ing a handle's GValue is what makes
1698
+ // that rule uniform: a bitwise copy would share the string or boxed the original
1699
+ // holds, and unsetting it would free the handle's contents underneath JS.
1700
+ // * every other record — COPIED from a boxed handle, which is also what gjs requires
1701
+ // (measured: a plain object is refused there with "not a subclass of
1702
+ // GObject_Struct"). The copy shares whatever the source points at, so nothing here
1703
+ // is freed and the handle keeps owning its own storage across the invoke.
1704
+ //
1705
+ // THE TYPE CHECK IS A SAFETY PROPERTY rather than ergonomics: copying `elemSize` bytes
1706
+ // out of a handle of a DIFFERENT record type reads past the end of the source whenever
1707
+ // the source is smaller. So the source must be the element's own type — compared by
1708
+ // GType where the element has one, and by introspected size where it does not, an
1709
+ // unregistered struct having no GType to compare.
1710
+ static bool ByValueRecordToCell(Napi::Env env, GITypeInfo* elem, Napi::Value v, void* dst,
1711
+ size_t elemSize, gpointer* writeBackTo) {
1712
+ if (writeBackTo != nullptr) *writeBackTo = nullptr;
1713
+ GType want = ByValueRecordGType(elem);
1714
+ if (want == G_TYPE_VALUE) {
1715
+ GValue* cell = static_cast<GValue*>(dst);
1716
+ memset(cell, 0, elemSize);
1717
+ if (NodeGiIsGValueHandle(v)) {
1718
+ BoxedHandle* h = TryGetBoxedHandle(v);
1719
+ GValue* src = h == nullptr ? nullptr : static_cast<GValue*>(h->ptr);
1720
+ if (src == nullptr || !G_IS_VALUE(src)) {
1721
+ Napi::TypeError::New(env,
1722
+ "an uninitialised GObject.Value cannot be an array element")
1723
+ .ThrowAsJavaScriptException();
1724
+ return false;
1725
+ }
1726
+ g_value_init(cell, G_VALUE_TYPE(src));
1727
+ g_value_copy(src, cell);
1728
+ if (writeBackTo != nullptr) *writeBackTo = src;
1729
+ return true;
1730
+ }
1731
+ return JsToFreshGValue(env, v, cell);
1732
+ }
1733
+
1734
+ BoxedHandle* h = TryGetBoxedHandle(v);
1735
+ if (h == nullptr || h->ptr == nullptr) {
1736
+ Napi::TypeError::New(
1737
+ env, std::string("expected a ") +
1738
+ (want == G_TYPE_INVALID ? "record" : g_type_name(want)) +
1739
+ " handle as a by-value array element")
1740
+ .ThrowAsJavaScriptException();
1741
+ return false;
1742
+ }
1743
+ bool sameType = want != G_TYPE_INVALID
1744
+ ? (h->gtype != G_TYPE_INVALID && g_type_is_a(h->gtype, want))
1745
+ : (BoxedInfoSize(h->info) == elemSize && elemSize > 0);
1746
+ if (!sameType) {
1747
+ Napi::TypeError::New(
1748
+ env, std::string("a by-value array element must be a ") +
1749
+ (want == G_TYPE_INVALID ? "record of the same size" : g_type_name(want)) +
1750
+ ", got " +
1751
+ (h->gtype == G_TYPE_INVALID ? "an unregistered record" : g_type_name(h->gtype)))
1752
+ .ThrowAsJavaScriptException();
1753
+ return false;
1754
+ }
1755
+ memcpy(dst, h->ptr, elemSize);
1756
+ return true;
1757
+ }
1758
+
1334
1759
  // Build a C array / GStrv / GByteArray / GArray / GPtrArray from a JS value.
1335
1760
  // *outCount = element count (for the IN length autofill + later free). Throws +
1336
1761
  // returns false on refusal (BEFORE the invoke).
1337
- static bool JsToCArray(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer* outPtr,
1338
- long* outCount) {
1762
+ static bool JsToCArray(Napi::Env env, Napi::Value v, GITypeInfo* type, GITransfer transfer,
1763
+ gpointer* outPtr, long* outCount, std::vector<gpointer>* writeBack) {
1764
+ GITransfer elemTransfer =
1765
+ transfer == GI_TRANSFER_EVERYTHING ? GI_TRANSFER_EVERYTHING : GI_TRANSFER_NOTHING;
1339
1766
  GIArrayType at = gi_type_info_get_array_type(type);
1340
1767
  GITypeInfo* elem = gi_type_info_get_param_type(type, 0);
1341
1768
  GITypeTag etag = gi_type_info_get_tag(elem);
1342
1769
  bool zt = gi_type_info_is_zero_terminated(type);
1343
- size_t elemSize = CElementSize(elem);
1770
+ // CInElementSize, not CElementSize: this is the write side, and it is the side that
1771
+ // knows how wide a by-value element's cell is (see the note on that function).
1772
+ size_t elemSize = CInElementSize(elem);
1773
+ bool byValueRecord = IsByValueRecordElement(elem);
1344
1774
  bool isByte = etag == GI_TYPE_TAG_UINT8 || etag == GI_TYPE_TAG_INT8;
1345
1775
  *outPtr = nullptr;
1346
1776
  *outCount = 0;
1347
1777
 
1778
+ // A by-value record element is a BITWISE copy of a handle's storage, which shares
1779
+ // whatever that record points at. That is safe exactly while WE free the buffer:
1780
+ // on TRANSFER_EVERYTHING the callee frees the elements too, and it would be freeing
1781
+ // strings and boxeds the caller's handles still own — the same use-after-free the
1782
+ // pointer-element half of this work shipped once and had to fix, wearing a different
1783
+ // shape. There is no general remedy: an arbitrary record has no copy function to
1784
+ // deep-copy with.
1785
+ //
1786
+ // Measured across every installed typelib rather than guessed: of 140 IN parameters
1787
+ // with a by-value element, 139 are `transfer=none` and ONE is not — Gsf
1788
+ // .property_settings_free's `GObject.Parameter[]`, which exists to free what it is
1789
+ // given. So this refusal costs one callable, and that callable is the one where
1790
+ // handing over borrowed contents would be exactly wrong.
1791
+ if (byValueRecord && transfer != GI_TRANSFER_NOTHING) {
1792
+ gi_base_info_unref(elem);
1793
+ Napi::TypeError::New(
1794
+ env, "a by-value record array with transfer other than none is not yet supported: the "
1795
+ "callee would free element contents the caller's handles still own")
1796
+ .ThrowAsJavaScriptException();
1797
+ return false;
1798
+ }
1799
+
1348
1800
  // null/undefined → a NULL array (count 0), as GJS marshals a null array arg
1349
1801
  // (refs/gjs/gi/arg.cpp gjs_array_to_explicit_array: null in → NULL out).
1350
1802
  // Exposing call: `Gst.init(null)` — a NULLABLE (inout) argv array every GJS
@@ -1454,18 +1906,47 @@ static bool JsToCArray(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer*
1454
1906
  // pdata memcpy (refs/gjs/gi/arg.cpp gjs_value_to_basic_array_gi_argument).
1455
1907
  void* buf = g_malloc0(elemSize * (count + (zt ? 1 : 0)));
1456
1908
  bool ok = true;
1909
+ long written = 0;
1457
1910
  for (long i = 0; i < count && ok; i++) {
1911
+ void* dst = static_cast<char*>(buf) + i * elemSize;
1912
+ if (byValueRecord) {
1913
+ // The record writes ITSELF into the cell — it never passes through a GIArgument,
1914
+ // because the union is eight bytes and the record is its own size.
1915
+ gpointer source = nullptr;
1916
+ ok = ByValueRecordToCell(env, elem, arr.Get(static_cast<uint32_t>(i)), dst, elemSize,
1917
+ &source);
1918
+ if (ok) {
1919
+ if (writeBack != nullptr) {
1920
+ writeBack->resize(static_cast<size_t>(i) + 1, nullptr);
1921
+ (*writeBack)[static_cast<size_t>(i)] = source;
1922
+ }
1923
+ written++;
1924
+ }
1925
+ continue;
1926
+ }
1458
1927
  GIArgument a;
1459
- ok = ElementToGIArgument(env, elem, arr.Get(static_cast<uint32_t>(i)), &a);
1928
+ ok = ElementToGIArgument(env, elem, arr.Get(static_cast<uint32_t>(i)), &a, elemTransfer);
1460
1929
  if (!ok) break;
1461
- void* dst = static_cast<char*>(buf) + i * elemSize;
1462
1930
  // Copy the element's storage bytes (LE: the low elemSize bytes of the union
1463
1931
  // alias the active field — v_string/v_pointer for pointers, the scalar
1464
- // otherwise).
1932
+ // otherwise). elemSize never exceeds the union here: the one element kind that is
1933
+ // wider took the branch above.
1465
1934
  memcpy(dst, &a, elemSize);
1935
+ written++;
1466
1936
  }
1467
1937
  if (!ok) {
1468
- g_free(buf); // partial g_strdup'd strings leak on this error path (pre-checked, rare)
1938
+ // The GValues already initialised are ours and are released here — the container is
1939
+ // never recorded for cleanup on this path, so nothing else will. (Partial
1940
+ // g_strdup'd strings, and on an EVERYTHING container the refs/copies taken for
1941
+ // earlier elements, still leak: pre-checked and rare, needing a non-handle value
1942
+ // mid-array. A leak, never a double free.)
1943
+ if (byValueRecord && ElementsAreOwnedGValues(type)) {
1944
+ for (long i = 0; i < written; i++) {
1945
+ GValue* cell = reinterpret_cast<GValue*>(static_cast<char*>(buf) + i * elemSize);
1946
+ if (G_IS_VALUE(cell)) g_value_unset(cell);
1947
+ }
1948
+ }
1949
+ g_free(buf);
1469
1950
  gi_base_info_unref(elem);
1470
1951
  return false;
1471
1952
  }
@@ -1498,7 +1979,9 @@ static bool JsToCArray(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer*
1498
1979
 
1499
1980
  // Build a GList / GSList from a JS array.
1500
1981
  static bool JsToGListLike(Napi::Env env, Napi::Value v, GITypeInfo* type, bool isSList,
1501
- gpointer* outPtr) {
1982
+ GITransfer transfer, gpointer* outPtr) {
1983
+ GITransfer elemTransfer =
1984
+ transfer == GI_TRANSFER_EVERYTHING ? GI_TRANSFER_EVERYTHING : GI_TRANSFER_NOTHING;
1502
1985
  *outPtr = nullptr;
1503
1986
  if (!v.IsArray()) {
1504
1987
  Napi::TypeError::New(env, "expected an array for the list argument").ThrowAsJavaScriptException();
@@ -1511,8 +1994,12 @@ static bool JsToGListLike(Napi::Env env, Napi::Value v, GITypeInfo* type, bool i
1511
1994
  bool ok = true;
1512
1995
  for (uint32_t i = 0; i < arr.Length() && ok; i++) {
1513
1996
  GIArgument a;
1514
- ok = ElementToGIArgument(env, elem, arr.Get(i), &a);
1997
+ ok = ElementToGIArgument(env, elem, arr.Get(i), &a, elemTransfer);
1515
1998
  if (!ok) break;
1999
+ // Measured, not assumed: for an INTERFACE element that is a pointer this helper
2000
+ // passes v_pointer through unchanged and gi_type_info_argument_from_hash_pointer
2001
+ // reads the same address back, so a pointer-struct element fills a node's data
2002
+ // slot exactly as an object element does.
1516
2003
  gpointer p = gi_type_info_hash_pointer_from_argument(elem, &a);
1517
2004
  if (!isSList) glist = g_list_prepend(glist, p);
1518
2005
  else gslist = g_slist_prepend(gslist, p);
@@ -1534,7 +2021,14 @@ static bool JsToGListLike(Napi::Env env, Napi::Value v, GITypeInfo* type, bool i
1534
2021
  // Key/value GDestroyNotify funcs are attached so a transfer-none teardown
1535
2022
  // (g_hash_table_unref in FreeInContainer) releases everything; a transfer-full
1536
2023
  // hash is adopted by the callee, which frees it (and thus runs the notifies).
1537
- static bool JsToGHashIn(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer* outPtr) {
2024
+ static bool JsToGHashIn(Napi::Env env, Napi::Value v, GITypeInfo* type, GITransfer transfer,
2025
+ gpointer* outPtr) {
2026
+ // A hash's VALUE destroy-notify is set below for the string/heap kinds only, so an
2027
+ // EVERYTHING hash of object/boxed values leaks the copies rather than double-freeing
2028
+ // the originals. That is the safe side of the trade and the only one reachable: no
2029
+ // installed typelib has an EVERYTHING GHashTable IN with a POINTER-STRUCT value.
2030
+ GITransfer elemTransfer =
2031
+ transfer == GI_TRANSFER_EVERYTHING ? GI_TRANSFER_EVERYTHING : GI_TRANSFER_NOTHING;
1538
2032
  *outPtr = nullptr;
1539
2033
  if (!v.IsObject() || v.IsArray()) {
1540
2034
  Napi::TypeError::New(env, "expected an object for the GHashTable argument")
@@ -1578,7 +2072,7 @@ static bool JsToGHashIn(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer
1578
2072
  } else {
1579
2073
  GIArgument ka;
1580
2074
  Napi::Value keyNum = Napi::Number::New(env, static_cast<double>(g_ascii_strtoll(ks.c_str(), nullptr, 10)));
1581
- ok = ElementToGIArgument(env, kt, keyNum, &ka);
2075
+ ok = ElementToGIArgument(env, kt, keyNum, &ka, GI_TRANSFER_NOTHING);
1582
2076
  if (!ok) break;
1583
2077
  kp = gi_type_info_hash_pointer_from_argument(kt, &ka);
1584
2078
  }
@@ -1589,7 +2083,7 @@ static bool JsToGHashIn(Napi::Env env, Napi::Value v, GITypeInfo* type, gpointer
1589
2083
  Napi::Value val = obj.Get(key);
1590
2084
  gpointer vp = nullptr;
1591
2085
  GIArgument va;
1592
- ok = ElementToGIArgument(env, vt, val, &va);
2086
+ ok = ElementToGIArgument(env, vt, val, &va, elemTransfer);
1593
2087
  if (!ok) {
1594
2088
  g_free(kp);
1595
2089
  break;
@@ -1661,6 +2155,44 @@ void FreeInContainer(const InContainer& c) {
1661
2155
  g_free(s);
1662
2156
  }
1663
2157
  } else {
2158
+ // A GValue element array is the one C array whose CELLS hold something beyond
2159
+ // their own bytes: JsToCArray initialised each one (from a JS value or by
2160
+ // copying a handle's GValue), so each one holds a string, a boxed or a ref that
2161
+ // g_free on the buffer would strand. Only this transfer reaches here — on
2162
+ // EVERYTHING/CONTAINER the callee owns the buffer and unsets them itself.
2163
+ if (ElementsAreOwnedGValues(c.type)) {
2164
+ size_t elemSize = elem != nullptr ? CInElementSize(elem) : 0;
2165
+ for (long i = 0; elemSize > 0 && i < c.count; i++) {
2166
+ GValue* cell = reinterpret_cast<GValue*>(static_cast<char*>(c.ptr) + i * elemSize);
2167
+ if (!G_IS_VALUE(cell)) continue;
2168
+ // WRITE-BACK, and the reason it is not optional. Some callees FILL these
2169
+ // cells rather than read them — `GObject.Object.getv`, `Gst.Object
2170
+ // .get_g_value_array`, `Gst.ControlBinding.get_g_value_array` — and the
2171
+ // typelib gives NO way to tell them apart: measured on `g_object_getv`,
2172
+ // its `values` parameter reports direction=IN, caller-allocates=false,
2173
+ // transfer=none, byte for byte the same flags as `GLib.parse_debug_string`'s
2174
+ // read-only `keys`. Without this, such a call silently does nothing, which
2175
+ // is worse than the refusal it replaced. (gjs has the same gap: `getv`
2176
+ // there also leaves the caller's GValue empty — measured on 1.88.1. This is
2177
+ // a deliberate divergence, and the annotation is an upstream candidate.)
2178
+ //
2179
+ // `g_value_copy` rather than a memcpy: the cell is unset immediately after,
2180
+ // and a bitwise copy would leave the caller's GValue pointing at a string
2181
+ // this line is about to free. The type is matched first because g_value_copy
2182
+ // requires it, and a callee may have set a type the caller did not.
2183
+ GValue* dest = i < static_cast<long>(c.writeBack.size())
2184
+ ? static_cast<GValue*>(c.writeBack[static_cast<size_t>(i)])
2185
+ : nullptr;
2186
+ if (dest != nullptr) {
2187
+ if (!G_IS_VALUE(dest) || G_VALUE_TYPE(dest) != G_VALUE_TYPE(cell)) {
2188
+ if (G_IS_VALUE(dest)) g_value_unset(dest);
2189
+ g_value_init(dest, G_VALUE_TYPE(cell));
2190
+ }
2191
+ g_value_copy(cell, dest);
2192
+ }
2193
+ g_value_unset(cell);
2194
+ }
2195
+ }
1664
2196
  g_free(c.ptr);
1665
2197
  }
1666
2198
  }
@@ -1694,23 +2226,24 @@ bool JsToInContainer(Napi::Env env, Napi::Value v, GITypeInfo* type, GITransfer
1694
2226
  GITypeTag tag = gi_type_info_get_tag(type);
1695
2227
  *outCount = 0;
1696
2228
  bool ok = false;
2229
+ std::vector<gpointer> writeBack;
1697
2230
  if (tag == GI_TYPE_TAG_ARRAY) {
1698
- ok = JsToCArray(env, v, type, outPtr, outCount);
2231
+ ok = JsToCArray(env, v, type, transfer, outPtr, outCount, &writeBack);
1699
2232
  } else if (tag == GI_TYPE_TAG_GLIST || tag == GI_TYPE_TAG_GSLIST) {
1700
- ok = JsToGListLike(env, v, type, tag == GI_TYPE_TAG_GSLIST, outPtr);
2233
+ ok = JsToGListLike(env, v, type, tag == GI_TYPE_TAG_GSLIST, transfer, outPtr);
1701
2234
  } else if (tag == GI_TYPE_TAG_GHASH) {
1702
- ok = JsToGHashIn(env, v, type, outPtr);
2235
+ ok = JsToGHashIn(env, v, type, transfer, outPtr);
1703
2236
  }
1704
2237
  if (ok && *outPtr != nullptr) {
1705
- containers->push_back(InContainer{
1706
- reinterpret_cast<GITypeInfo*>(gi_base_info_ref(type)), *outPtr, transfer, *outCount});
2238
+ containers->push_back(InContainer{reinterpret_cast<GITypeInfo*>(gi_base_info_ref(type)),
2239
+ *outPtr, transfer, *outCount, std::move(writeBack)});
1707
2240
  } else if (!ok && *outPtr != nullptr) {
1708
2241
  // An element conversion threw mid-build (JsToGListLike / JsToGHashIn still
1709
2242
  // published the partial container). It was never recorded for cleanup, so
1710
2243
  // the nodes (and any g_strdup'd keys/string values) would leak. Free it now
1711
2244
  // with NOTHING semantics — we still own all of it; the callee never saw it.
1712
2245
  InContainer partial{reinterpret_cast<GITypeInfo*>(gi_base_info_ref(type)), *outPtr,
1713
- GI_TRANSFER_NOTHING, *outCount};
2246
+ GI_TRANSFER_NOTHING, *outCount, {}};
1714
2247
  FreeInContainer(partial); // unrefs the type ref taken above
1715
2248
  *outPtr = nullptr;
1716
2249
  }