Module: Lich::Util::GtkCompaction
- Defined in:
- documented/util/gtk_compaction.rb
Overview
Keeps Lich's periodic GC.compact calls (MemoryReleaser, Combat::AsyncProcessor) safe to use alongside gtk3/glib2/gdk3/pango -- on a stock, unpatched ruby-gnome install, with GC.compact still actually running.
ruby-gnome's native gems cache a Ruby Proc for each "boxed"/"object" GType conversion inside a plain C struct that Ruby's GC can't see or update (BoxedInstance2RObjData / ObjectInstance2RObjData in gobject-introspection's rb-gi-loader.c). In the current stack this is only ever exercised through the boxed half in practice -- gdk3's Gdk::Event/Gdk::Rectangle and pango's Pango::Attribute are the real examples, and they're all boxed. Nothing calls register_object_class_converter today; that half is wrapped defensively, on the same reasoning, but has no real-gem example and is not independently exercised the way the boxed path has been (empirically, via forced compaction against Pango::Attribute). GC.compact can relocate that Proc without anything updating this one untracked reference to it, so the next GTK signal marshaled through it dereferences a dangling pointer and crashes with SIGBUS/SIGSEGV.
The clean fix is a one-line C patch: pin each converter Proc as a permanent GC root (rb_gc_register_mark_object) instead of caching a raw, GC-invisible reference to it. That function is a public, documented libruby API -- exported from an already-loaded shared library -- which means we can call it ourselves, from pure Ruby, via Fiddle, without patching or recompiling anything:
1. GObjectIntrospection::Loader.register_boxed_class_converter and
.register_object_class_converter are the same *public* methods
gdk3/pango/etc. call to install these converters in the first
place. We wrap them so our wrapper captures the block as `&block`
(a real, addressable Proc -- Ruby always reifies `&block`
params, and identity is preserved when forwarding it on), pins
*that* object via rb_gc_register_mark_object, then calls the
original method with the same block. Whatever the C
implementation's own rb_block_proc() resolves to is that exact,
now-pinned Proc -- confirmed empirically (forced compaction via
GC.verify_compaction_references against a stock gem, before and
after this wrapper: crashes without it, survives with it).
2. This has to happen on the *original* registration -- there is no
re-registration involved, ever. (Re-registering an
already-registered GType is a separate, deterministic crash on
stock gems: the free callback on the replaced entry dereferences
a field g_new never initializes. We never trigger that path.)
Timing is everything here: this only works if our wrapper is defined
before gdk3/pango/etc. ever call register_boxed_class_converter for
the first time -- i.e. before gobject-introspection is first
required, transitively or otherwise. lich.rbw requires this file and
then explicitly calls install! immediately after, before lib/init.rb's
require 'gtk3' runs. Requiring this file by itself does not
install anything -- see install!'s own doc for why that's deliberate.
If something still manages to load gtk3 before install! gets a chance
to run (a custom boot path, a script requiring it unusually early),
we can't retroactively pin what's already registered -- in that edge
case we fall back to checking whether the installed gem itself
happens to carry the equivalent native patch (see
native_gem_patched?), and only disable compaction outright if neither
applies.
RETIREMENT: this whole module is a stopgap, not permanent architecture. It exists because most users run a stock gobject-introspection gem that doesn't carry the native fix. Once Lich's enforced minimum gobject-introspection version is one that includes rb_gc_register_mark_object in rb-gi-loader.c upstream (i.e. GemCheck can assert gobject-introspection >= that version the same way it already asserts other minimums), every installed gem satisfies native_gem_patched? by construction and this module's Fiddle-based pinning wrapper (install!, and everything it sets up) is provably redundant -- delete it, and have MemoryReleaser / Combat::AsyncProcessor call GC.compact directly again. Don't let "it works" be the reason it's still here a year from now; the removal condition is a version-floor check, not a feeling.
Constant Summary collapse
- NATIVE_PATCH_MARKER =
Returns the C source code marker that indicates the gobject-introspection native gem carries the GC.compact safety patch for converter pinning.
'rb_gc_register_mark_object(data->rb_converter)'.freeze
Class Method Summary collapse
-
.gtk_loaded? ⇒ Boolean
Whether gtk3 (or glib2/gdk3/pango directly) has been loaded into this process.
-
.install! ⇒ void
Installs the pinning wrapper around GObjectIntrospection::Loader's converter-registration methods.
-
.native_gem_patched? ⇒ Boolean
Whether the currently-loaded gobject-introspection gem carries the GC.compact safety patch natively (i.e. someone applied the native gem patch directly).
-
.safe_compact! ⇒ void
Runs GC.compact safely.
Class Method Details
.gtk_loaded? ⇒ Boolean
Returns whether gtk3 (or glib2/gdk3/pango directly) has been loaded into this process. Once true, it stays true for the life of the process -- Ruby never unloads C extensions, and Lich runs every script in one shared VM.
91 92 93 |
# File 'documented/util/gtk_compaction.rb', line 91 def gtk_loaded? !!(defined?(GLib) || defined?(Gtk) || defined?(Gdk)) end |
.install! ⇒ void
This method returns an undefined value.
Installs the pinning wrapper around GObjectIntrospection::Loader's converter-registration methods. Safe to call more than once (no-ops after the first successful install). Called explicitly from lich.rbw, immediately after requiring this file -- not automatically as a side effect of the require itself, and not lazily from safe_compact! either.
Deliberately not auto-called at the bottom of this file: this
module needs to be require-able (in specs, tools, a script) as
a plain library, without side-loading gobject-introspection just
because someone loaded the file. Requiring gtk_compaction.rb must
stay inert; install! is the only thing that acts.
This has to run before lib/init.rb's require 'gtk3' -- by the
time MemoryReleaser or AsyncProcessor first calls safe_compact!,
gtk3 has almost always already been loaded and it would be too
late.
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 |
# File 'documented/util/gtk_compaction.rb', line 114 def install! return if defined?(@installed) @installed = true if gtk_loaded? # Something already required gtk3/gdk3/pango/glib2 before we # got here -- the original converter registrations already # happened, unpinned, and there's no way to retroactively find # and pin them. native_gem_patched? is the fallback for this. @pinning_active = false return end begin require 'gobject-introspection' rescue LoadError # gtk3 isn't installed at all for this Lich install (headless # / --no-gtk). Nothing to protect -- gtk_loaded? will always # be false, so safe_compact! never touches any of this. @pinning_active = false return end begin register_mark_object = Fiddle::Function.new( Fiddle::Handle::DEFAULT['rb_gc_register_mark_object'], [Fiddle::TYPE_UINTPTR_T], Fiddle::TYPE_VOID ) # Both converter kinds get the identical treatment. In the # current ruby-gnome stack only the boxed variant is actually # exercised by real gems (gdk3's Gdk::Event/Gdk::Rectangle, # pango's Pango::Attribute are all boxed); nothing calls # register_object_class_converter today. It's wrapped anyway, # on the same reasoning, but that half is defensive -- it has # not been independently exercised against a real converter the # way the boxed path has (empirically, via forced compaction # against Pango::Attribute). loader_singleton = GObjectIntrospection::Loader.singleton_class %i[register_boxed_class_converter register_object_class_converter].each do |method_name| unpinned_name = :"__gtk_compaction_unpinned_#{method_name}" loader_singleton.send(:alias_method, unpinned_name, method_name) loader_singleton.send(:define_method, method_name) do |gtype, &block| register_mark_object.call(Fiddle.dlwrap(block)) send(unpinned_name, gtype, &block) end end @pinning_active = true rescue StandardError, ::Fiddle::DLError # Anything going wrong here -- a Ruby implementation that # doesn't export this libruby symbol the same way (JRuby, # TruffleRuby), a future gobject-introspection release renaming # these methods, whatever -- must not take the rest of Lich # down with it. install! runs unconditionally near the very # start of lich.rbw's boot sequence; a raised exception here # propagates straight out of that call and crashes startup # entirely, which is a strictly worse failure mode than the # thing this module exists to prevent. Fail into the same # conservative fallback as any other "can't pin" case. @pinning_active = false end end |
.native_gem_patched? ⇒ Boolean
Returns whether the currently-loaded gobject-introspection gem carries the GC.compact safety patch natively (i.e. someone applied the native gem patch directly). Fallback for the case where install! ran too late to install our own pinning wrapper. Memoized: a loaded C extension can't be swapped out mid-process, so this can't change once computed.
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'documented/util/gtk_compaction.rb', line 185 def native_gem_patched? return @native_gem_patched if defined?(@native_gem_patched) @native_gem_patched = begin # Reads the .c source on disk, not the already-loaded compiled # bundle/so -- a last-resort heuristic, not a certainty. Source # and binary can disagree (source patched after the extension # was already compiled and loaded this process; or vice versa, # a stale checked-out source tree next to a binary built from a # different version). Good enough for "does this install look # like someone ran the patch script," not a guarantee about # what's actually running in memory right now. loaded = $LOADED_FEATURES.find { |f| f =~ /gobject_introspection\.(bundle|so)\z/ } gem_dir = loaded && File.(File.join(File.dirname(loaded), '..')) src = gem_dir && File.join(gem_dir, 'ext', 'gobject-introspection', 'rb-gi-loader.c') !!(src && File.exist?(src) && File.read(src).include?(NATIVE_PATCH_MARKER)) rescue StandardError false end end |
.safe_compact! ⇒ void
This method returns an undefined value.
Runs GC.compact safely.
If gtk3 hasn't been loaded, compacts immediately -- nothing in this module applies. If it has, compacts whenever we know it's safe: either our own pinning wrapper caught the original converter registrations (the common case), or the installed gem carries the native patch directly. Only gives up on compaction -- for the rest of the process -- if neither applies.
216 217 218 219 220 221 222 223 224 |
# File 'documented/util/gtk_compaction.rb', line 216 def safe_compact! return unless GC.respond_to?(:compact) return GC.compact unless gtk_loaded? return GC.compact if @pinning_active return GC.compact if native_gem_patched? warn_compaction_disabled_once! nil end |